Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
a058146
feat: improve attachment validation
chrispader Sep 17, 2025
6739696
remove;: unused import
chrispader Sep 18, 2025
542334d
Merge branch 'main' into pr/70740
chrispader Feb 18, 2026
0c4a513
refactor: rename CONST variable block
chrispader Feb 18, 2026
76ee521
fix: add missing CONST values
chrispader Feb 18, 2026
0079017
fix: legacy CONST key
chrispader Feb 18, 2026
ff9c18c
fix: incorrect usage of `useFilesValidation` hook
chrispader Feb 18, 2026
6035f9a
fix: update usage of validation functions
chrispader Feb 18, 2026
33e2bfc
Merge branch 'main' into pr/70740
chrispader Feb 19, 2026
3be89c0
fix: `getFileValidationErrorText` type error
chrispader Feb 19, 2026
4f9692c
fix: use `Log` instaed of `console`
chrispader Feb 19, 2026
662e189
fix: pass down validation options to validator function
chrispader Feb 19, 2026
34063ca
fix: pass file objects instead of wrapper struct
chrispader Feb 19, 2026
f014e7f
refactor: remove unused `getConfirmModalPrompt` function
chrispader Feb 19, 2026
33cd2be
refactor: refactor `useFilesValidation` hook for more readability
chrispader Feb 19, 2026
843acd4
test: fix test cases
chrispader Feb 19, 2026
02e9868
test: fix file error cases with multiple files
chrispader Feb 19, 2026
24019c5
test: add more test cases for attachment file validation
chrispader Feb 19, 2026
5e585aa
fix: failing tests
chrispader Feb 19, 2026
fade3fd
fix: invalid options param
chrispader Feb 19, 2026
662308c
refactor: convert to async functions
chrispader Feb 19, 2026
9b5a71c
fix: remove unnecessary array vs. single instance checks
chrispader Feb 19, 2026
b6993e7
fix: replace `console.error`
chrispader Feb 19, 2026
67425eb
Merge branch 'main' into pr/70740
chrispader Feb 26, 2026
b341077
fix: prettier
chrispader Feb 26, 2026
d07538e
Merge branch 'main' into pr/70740
chrispader Feb 26, 2026
89479bb
refactor: AttachmentValidation functions
chrispader Feb 27, 2026
3f73a1e
refactor: further simplify validation logic
chrispader Feb 27, 2026
667393f
fix: reset on valid validation
chrispader Feb 27, 2026
2d12c2e
Merge branch 'main' into pr/70740
chrispader Mar 2, 2026
77ea218
fix: prettier
chrispader Mar 2, 2026
27e0b96
Merge branch 'main' into pr/70740
chrispader Mar 6, 2026
431f5be
fix: undo invalid change
chrispader Mar 6, 2026
68bbd95
Merge branch 'main' into pr/70740
chrispader Mar 9, 2026
c192b3d
Merge branch 'main' into pr/70740
chrispader Mar 12, 2026
696c820
fix: validate corrupted files
chrispader Mar 12, 2026
8410652
refactor: merge validation error constants
chrispader Mar 12, 2026
f8fdeea
refactor: improve `useFilesValidation` code and make use of async-await
chrispader Mar 12, 2026
a8ae217
fix: only set validate multiple files state if more than 1
chrispader Mar 12, 2026
5811936
refactor: simplify attachment validation logic
chrispader Mar 13, 2026
893f03a
refactor: extract back `validateAttachmentFile` function
chrispader Mar 13, 2026
2cab9ce
chore: update tests
chrispader Mar 13, 2026
60a8d05
chore: update tests
chrispader Mar 13, 2026
592e71b
fix: attachment validation in `ReportAddAttachmentModalContent`
chrispader Mar 13, 2026
92e4dd5
test: update tests to match new `validateAttachmentFile` implementation
chrispader Mar 13, 2026
3729607
refactor: rename reset callback
chrispader Mar 13, 2026
acee6c8
Merge branch 'main' into pr/70740
chrispader Mar 13, 2026
73b5ca3
refactor: file validation logic
chrispader Mar 13, 2026
a1fb241
test: fix failing test
chrispader Mar 13, 2026
ae0deea
fix: always resolve file conversion promise
chrispader Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2315,16 +2315,22 @@ const CONST = {
},

FILE_VALIDATION_ERRORS: {
WRONG_FILE_TYPE: 'wrongFileType',
WRONG_FILE_TYPE_MULTIPLE: 'wrongFileTypeMultiple',
FILE_TOO_LARGE: 'fileTooLarge',
FILE_TOO_LARGE_MULTIPLE: 'fileTooLargeMultiple',
FILE_TOO_SMALL: 'fileTooSmall',
FILE_CORRUPTED: 'fileCorrupted',
FOLDER_NOT_ALLOWED: 'folderNotAllowed',
MAX_FILE_LIMIT_EXCEEDED: 'fileLimitExceeded',
PROTECTED_FILE: 'protectedFile',
IMAGE_DIMENSIONS_TOO_LARGE: 'imageDimensionsTooLarge',
SINGLE_FILE: {
NO_FILE_PROVIDED: 'noFileProvided',
FILE_INVALID: 'fileInvalid',
WRONG_FILE_TYPE: 'wrongFileType',
FILE_TOO_LARGE: 'fileTooLarge',
FILE_TOO_SMALL: 'fileTooSmall',
FILE_CORRUPTED: 'fileCorrupted',
PROTECTED_FILE: 'protectedFile',
FOLDER_NOT_ALLOWED: 'folderNotAllowed',
HEIC_OR_HEIF_IMAGE: 'heicOrHeifImage',
IMAGE_DIMENSIONS_TOO_LARGE: 'imageDimensionsTooLarge',
},
MULTIPLE_FILES: {
FOLDER_NOT_ALLOWED: 'multipleAttachmentsFolderNotAllowed',
MAX_FILE_LIMIT_EXCEEDED: 'multipleAttachmentsMaxFileLimitExceeded',
},
},

IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied',
Expand Down
2 changes: 1 addition & 1 deletion src/components/AttachmentPicker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ function AttachmentPicker({
(error: unknown) => {
const errorMessage = error instanceof Error ? error.message : undefined;

if (errorMessage === CONST.FILE_VALIDATION_ERRORS.IMAGE_DIMENSIONS_TOO_LARGE) {
if (errorMessage === CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.IMAGE_DIMENSIONS_TOO_LARGE) {
showGeneralAlert(translate('attachmentPicker.imageDimensionsTooLarge'));
} else if (errorMessage) {
showGeneralAlert(errorMessage);
Expand Down
448 changes: 268 additions & 180 deletions src/hooks/useFilesValidation.tsx

Large diffs are not rendered by default.

106 changes: 0 additions & 106 deletions src/libs/AttachmentUtils.ts

This file was deleted.

215 changes: 215 additions & 0 deletions src/libs/AttachmentValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import {Str} from 'expensify-common';
import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
import type {FileObject} from '@src/types/utils/Attachment';
import {cleanFileName, hasHeicOrHeifExtension, isValidReceiptExtension, normalizeFileObject, validateImageForCorruption} from './fileDownload/FileUtils';

type ValidatedFile = {
fileType: 'file' | 'uri';
source?: string;
file: FileObject;
};

type SingleAttachmentValidResult = {
isValid: true;
validatedFile: ValidatedFile;
};

type SingleAttachmentValidationError = ValueOf<typeof CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE>;
type SingleAttachmentInvalidResult = {
isValid: false;
error: SingleAttachmentValidationError;
file: FileObject;
};

type SingleAttachmentValidationResult = SingleAttachmentValidResult | SingleAttachmentInvalidResult;

function isSingleAttachmentValidationResult(result: unknown): result is SingleAttachmentValidationResult {
return typeof result === 'object' && result !== null && 'isValid' in result && typeof result.isValid === 'boolean' && ('validatedFile' in result || 'error' in result);
}

async function validateAttachmentFile(file: FileObject, item?: DataTransferItem, isValidatingReceipts = false): Promise<SingleAttachmentValidationResult> {
if (!file) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.NO_FILE_PROVIDED, file});
}

if (!file.name || file.size == null) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, file});
}

if (isValidatingReceipts && !isValidReceiptExtension(file)) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.WRONG_FILE_TYPE, file});
}

if (hasHeicOrHeifExtension(file)) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.HEIC_OR_HEIF_IMAGE, file});
}

const isImage = Str.isImage(file.name);
const maxFileSize = isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE;
if (!isImage && !hasHeicOrHeifExtension(file) && file.size > maxFileSize) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE, file});
}

if (isValidatingReceipts && file.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL, file});
}

let fileObject = file;
const fileConverted = file.getAsFile?.();
if (fileConverted) {
fileObject = fileConverted;
}

if (!fileObject) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID, file});
}

if (item && item.kind === 'file' && 'webkitGetAsEntry' in item) {
const entry = item.webkitGetAsEntry();

if (entry?.isDirectory) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FOLDER_NOT_ALLOWED, file});
}
}

const corruptionResult = await isFileCorrupted(fileObject, isValidatingReceipts);
if (!corruptionResult.isValid) {
return {isValid: false, error: corruptionResult.error, file: fileObject};
}

const corruptionFreeFile = corruptionResult.file;
if (corruptionFreeFile instanceof File) {
/**
* Cleaning file name, done here so that it covers all cases:
* upload, drag and drop, copy-paste
*/
let updatedFile = corruptionFreeFile;
const cleanName = cleanFileName(updatedFile.name);
if (updatedFile.name !== cleanName) {
updatedFile = new File([updatedFile], cleanName, {type: updatedFile.type});
}
const inputSource = URL.createObjectURL(updatedFile);
updatedFile.uri = inputSource;

const validatedFile: ValidatedFile = {
fileType: 'file',
source: inputSource,
file: updatedFile,
};

return {isValid: true, validatedFile};
}

const validatedFile: ValidatedFile = {
fileType: 'uri',
source: corruptionFreeFile.uri ?? '',
file: corruptionFreeFile,
};

return {isValid: true, validatedFile};
}

type MultipleAttachmentsValidResult = {
isValid: true;
validatedFiles: ValidatedFile[];
};

type MultipleAttachmentsValidationError = ValueOf<typeof CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES>;
type MultipleAttachmentsInvalidResult = {
isValid: false;
error?: MultipleAttachmentsValidationError;
fileResults: SingleAttachmentValidationResult[];
files: FileObject[];
};
type MultipleAttachmentsValidationResult = MultipleAttachmentsValidResult | MultipleAttachmentsInvalidResult;

function isMultipleAttachmentsValidationResult(result: unknown): result is MultipleAttachmentsValidationResult {
return typeof result === 'object' && result !== null && 'isValid' in result && typeof result.isValid === 'boolean' && ('validatedFiles' in result || 'fileResults' in result);
}

async function validateMultipleAttachmentFiles(files: FileObject[], items?: DataTransferItem[], isValidatingReceipts = false): Promise<MultipleAttachmentsValidationResult> {
if (!files?.length || files.some((f) => isDirectory(f))) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.FOLDER_NOT_ALLOWED, fileResults: [], files});
}

if (files.length > CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT) {
return Promise.resolve({isValid: false, error: CONST.FILE_VALIDATION_ERRORS.MULTIPLE_FILES.MAX_FILE_LIMIT_EXCEEDED, fileResults: [], files});
}

const results = await Promise.all(files.map((f, index) => validateAttachmentFile(f, items?.at(index), isValidatingReceipts)));
if (results.every((result) => result.isValid)) {
return {
isValid: true,
validatedFiles: results.map((r) => r.validatedFile),
};
}
return {
isValid: false,
fileResults: results,
files,
};
}

type FileCorruptionValidResult = {
isValid: true;
file: FileObject;
};
type FileCorruptionInvalidResult = {
isValid: false;
error: SingleAttachmentValidationError;
};

type FileCorruptionResult = FileCorruptionValidResult | FileCorruptionInvalidResult;

async function isFileCorrupted(fileObject: FileObject, isValidatingReceipts?: boolean): Promise<FileCorruptionResult> {
const normalizedFile = await normalizeFileObject(fileObject);

try {
await validateImageForCorruption(normalizedFile);

if (normalizedFile.size && normalizedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
return {
isValid: false,
error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_LARGE,
} satisfies FileCorruptionInvalidResult;
}

if (isValidatingReceipts !== false && normalizedFile.size && normalizedFile.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
return {
isValid: false,
error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_TOO_SMALL,
} satisfies FileCorruptionInvalidResult;
}

return {
isValid: true,
file: normalizedFile,
} satisfies FileCorruptionValidResult;
} catch (error) {
return {
isValid: false,
error: CONST.FILE_VALIDATION_ERRORS.SINGLE_FILE.FILE_INVALID,
} satisfies FileCorruptionInvalidResult;
}
}

function isDirectory(data: FileObject) {
if ('webkitGetAsEntry' in data && (data as DataTransferItem).webkitGetAsEntry()?.isDirectory) {
return true;
}

return false;
}

export {validateAttachmentFile, validateMultipleAttachmentFiles, isSingleAttachmentValidationResult, isMultipleAttachmentsValidationResult};
export type {
SingleAttachmentValidationResult,
SingleAttachmentValidResult,
SingleAttachmentInvalidResult,
SingleAttachmentValidationError,
MultipleAttachmentsValidationResult,
MultipleAttachmentsValidResult,
MultipleAttachmentsInvalidResult,
MultipleAttachmentsValidationError,
};
Loading
Loading