Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 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
27fd1a3
Merge branch 'main' into pr/70740
chrispader Mar 17, 2026
8d3fd86
fix: last error not shown as "multiple files error"
chrispader Mar 17, 2026
3994787
fix: invalid errors after converting files
chrispader Mar 17, 2026
96c29c4
fix: failing test
chrispader Mar 17, 2026
dfd392e
Merge branch 'main' into pr/70740
chrispader Mar 18, 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
8 changes: 4 additions & 4 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2359,16 +2359,16 @@ const CONST = {
},

FILE_VALIDATION_ERRORS: {
FILE_INVALID: 'fileInvalid',
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',
HEIC_OR_HEIF_IMAGE: 'heicOrHeifImage',
IMAGE_DIMENSIONS_TOO_LARGE: 'imageDimensionsTooLarge',
FOLDER_NOT_ALLOWED: 'folderNotAllowed',
MAX_FILE_LIMIT_EXCEEDED: 'maxFileLimitExceeded',
},

IOS_CAMERA_ROLL_ACCESS_ERROR: 'Access to photo library was denied',
Expand Down
350 changes: 163 additions & 187 deletions src/hooks/useFilesValidation.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add unit tests for this entire hook and its functionality? it is quite complex

Large diffs are not rendered by default.

106 changes: 2 additions & 104 deletions src/libs/AttachmentUtils.ts
Original file line number Diff line number Diff line change
@@ -1,106 +1,4 @@
import CONST from '@src/CONST';
import type {FileObject} from '@src/types/utils/Attachment';
import {cleanFileName, validateImageForCorruption} from './fileDownload/FileUtils';

type AttachmentValidationError = CorruptionError | 'fileDoesNotExist' | 'fileInvalid';
type ValidResult = {
isValid: true;
fileType: 'file' | 'uri';
source: string;
file: FileObject;
};
type InvalidResult = {
isValid: false;
error: AttachmentValidationError;
};

type AttachmentValidationResult = ValidResult | InvalidResult;

function validateAttachmentFile(file: FileObject): Promise<AttachmentValidationResult> {
if (!file || !isDirectoryCheck(file)) {
return Promise.resolve({isValid: false, error: 'fileDoesNotExist'});
}

let fileObject = file;
const fileConverted = file.getAsFile?.();
if (fileConverted) {
fileObject = fileConverted;
}
if (!fileObject) {
return Promise.resolve({isValid: false, error: 'fileInvalid'});
}

return isFileCorrupted(fileObject).then((corruptionResult) => {
if (!corruptionResult.isValid) {
return corruptionResult as InvalidResult;
}

if (fileObject instanceof File) {
/**
* Cleaning file name, done here so that it covers all cases:
* upload, drag and drop, copy-paste
*/
let updatedFile = fileObject;
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;

return {isValid: true, fileType: 'file', source: inputSource, file: updatedFile} as ValidResult;
}

return {isValid: true, fileType: 'uri', source: fileObject.uri, file: fileObject} as ValidResult;
});
}

type CorruptionError = 'tooLarge' | 'tooSmall' | 'error';
type NoCorruptionResult = {
isValid: true;
};
type CorruptionResult = {
isValid: false;
error: CorruptionError;
};
type AttachmentCorruptionValidationResult = NoCorruptionResult | CorruptionResult;

function isFileCorrupted(fileObject: FileObject): Promise<AttachmentCorruptionValidationResult> {
return validateImageForCorruption(fileObject)
.then(() => {
if (fileObject.size && fileObject.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
return {
isValid: false,
error: 'tooLarge',
} satisfies AttachmentCorruptionValidationResult;
}

if (fileObject.size && fileObject.size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
return {
isValid: false,
error: 'tooSmall',
} satisfies AttachmentCorruptionValidationResult;
}

return {
isValid: true,
} satisfies AttachmentCorruptionValidationResult;
})
.catch(() => {
return {
isValid: false,
error: 'error',
};
});
}

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

return true;
}

/**
* Returns image cache file extension based from mime type
Expand All @@ -110,5 +8,5 @@ function getImageCacheFileExtension(contentType: string) {
return imageCacheFileTypes[contentType] ?? '';
}

export {validateAttachmentFile, getImageCacheFileExtension};
export type {AttachmentValidationResult};
// eslint-disable-next-line import/prefer-default-export
export {getImageCacheFileExtension};
138 changes: 53 additions & 85 deletions src/libs/fileDownload/FileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,71 +644,44 @@ const hasHeicOrHeifExtension = (file: FileObject) => {
* Otherwise, it attempts to fetch the file via its URI and reconstruct a File
* with full metadata (name, size, type).
*/
const normalizeFileObject = (file: FileObject): Promise<FileObject> => {
const normalizeFileObject = async (file: FileObject): Promise<FileObject> => {
if (file instanceof File || file instanceof Blob) {
return Promise.resolve(file);
return file;
}

const isAndroidNative = getPlatform() === CONST.PLATFORM.ANDROID;
const isIOSNative = getPlatform() === CONST.PLATFORM.IOS;
const isNativePlatform = isAndroidNative || isIOSNative;

if (!isNativePlatform || 'size' in file) {
return Promise.resolve(file);
return file;
}

if (typeof file.uri !== 'string') {
return Promise.resolve(file);
return file;
}

return fetch(file.uri)
.then((response) => response.blob())
.then((blob) => {
const name = file.name ?? 'unknown';
const type = file.type ?? blob.type ?? 'application/octet-stream';
const normalizedFile = new File([blob], name, {type});
return normalizedFile;
})
.catch((error) => {
return Promise.reject(error);
});
const response = await fetch(file.uri);
const blob = await response.blob();
const name = file.name ?? 'unknown';
const type = file.type ?? blob.type ?? 'application/octet-stream';
return new File([blob], name, {type});
};

type ValidateAttachmentOptions = {
isValidatingReceipts?: boolean;
type FileValidationError = {
error: ValueOf<typeof CONST.FILE_VALIDATION_ERRORS>;
isValidatingMultipleFiles?: boolean;
fileType?: string;
};

const validateAttachment = (file: FileObject, validationOptions?: ValidateAttachmentOptions) => {
const maxFileSize = validationOptions?.isValidatingReceipts ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE;

if (validationOptions?.isValidatingReceipts && !isValidReceiptExtension(file)) {
return validationOptions?.isValidatingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE;
}

// Images are exempt from file size check since they will be resized
if (!Str.isImage(file.name ?? '') && !hasHeicOrHeifExtension(file) && (file?.size ?? 0) > maxFileSize) {
return validationOptions?.isValidatingMultipleFiles ? CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE : CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE;
}

if (validationOptions?.isValidatingReceipts && (file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
return CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL;
}

return '';
};

type TranslationAdditionalData = {
maxUploadSizeInMB?: number;
fileLimit?: number;
fileType?: string;
type GetFileValidationErrorTextOptions = {
isValidatingReceipt?: boolean;
};

const getFileValidationErrorText = (
translate: LocalizedTranslate,
validationError: ValueOf<typeof CONST.FILE_VALIDATION_ERRORS> | null,
additionalData: TranslationAdditionalData = {},
isValidatingReceipt = false,
validationError: FileValidationError | null,
options: GetFileValidationErrorTextOptions = {},
): {
title: string;
reason: string;
Expand All @@ -719,45 +692,51 @@ const getFileValidationErrorText = (
reason: '',
};
}
const maxSize = isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE;
switch (validationError) {
const maxSize = options.isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE;

if (validationError.isValidatingMultipleFiles) {
switch (validationError.error) {
case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE:
return {
title: translate('attachmentPicker.someFilesCantBeUploaded'),
reason: translate('attachmentPicker.unsupportedFileType', validationError.fileType ?? ''),
};
case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE:
return {
title: translate('attachmentPicker.someFilesCantBeUploaded'),
reason: translate('attachmentPicker.sizeLimitExceeded', maxSize / 1024 / 1024),
};
case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED:
return {
title: translate('attachmentPicker.attachmentError'),
reason: translate('attachmentPicker.folderNotAllowedMessage'),
};
case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED:
return {
title: translate('attachmentPicker.someFilesCantBeUploaded'),
reason: translate('attachmentPicker.maxFileLimitExceeded'),
};
default:
break;
}
}

switch (validationError.error) {
case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE:
return {
title: translate('attachmentPicker.wrongFileType'),
reason: translate('attachmentPicker.notAllowedExtension'),
};
case CONST.FILE_VALIDATION_ERRORS.WRONG_FILE_TYPE_MULTIPLE:
return {
title: translate('attachmentPicker.someFilesCantBeUploaded'),
reason: translate('attachmentPicker.unsupportedFileType', additionalData.fileType ?? ''),
};
case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE:
return {
title: translate('attachmentPicker.attachmentTooLarge'),
reason: isValidatingReceipt
? translate('attachmentPicker.sizeExceededWithLimit', additionalData.maxUploadSizeInMB ?? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / 1024 / 1024)
: translate('attachmentPicker.sizeExceeded'),
};
case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE_MULTIPLE:
return {
title: translate('attachmentPicker.someFilesCantBeUploaded'),
reason: translate('attachmentPicker.sizeLimitExceeded', additionalData.maxUploadSizeInMB ?? maxSize / 1024 / 1024),
reason: options.isValidatingReceipt ? translate('attachmentPicker.sizeExceededWithLimit', maxSize / 1024 / 1024) : translate('attachmentPicker.sizeExceeded'),
};
case CONST.FILE_VALIDATION_ERRORS.FILE_TOO_SMALL:
return {
title: translate('attachmentPicker.attachmentTooSmall'),
reason: translate('attachmentPicker.sizeNotMet'),
};
case CONST.FILE_VALIDATION_ERRORS.FOLDER_NOT_ALLOWED:
return {
title: translate('attachmentPicker.attachmentError'),
reason: translate('attachmentPicker.folderNotAllowedMessage'),
};
case CONST.FILE_VALIDATION_ERRORS.MAX_FILE_LIMIT_EXCEEDED:
return {
title: translate('attachmentPicker.someFilesCantBeUploaded'),
reason: translate('attachmentPicker.maxFileLimitExceeded'),
};
case CONST.FILE_VALIDATION_ERRORS.FILE_CORRUPTED:
return {
title: translate('attachmentPicker.attachmentError'),
Expand All @@ -774,21 +753,13 @@ const getFileValidationErrorText = (
reason: translate('attachmentPicker.imageDimensionsTooLarge'),
};
default:
return {
title: translate('attachmentPicker.attachmentError'),
reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'),
};
break;
}
};

const getConfirmModalPrompt = (translate: LocalizedTranslate, attachmentInvalidReason: TranslationPaths | undefined) => {
if (!attachmentInvalidReason) {
return '';
}
if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') {
return translate(attachmentInvalidReason, CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024));
}
return translate(attachmentInvalidReason);
return {
title: translate('attachmentPicker.attachmentError'),
reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'),
};
};

const MAX_CANVAS_SIZE = 4096;
Expand Down Expand Up @@ -902,16 +873,13 @@ export {
resizeImageIfNeeded,
createFile,
validateReceipt,
validateAttachment,
normalizeFileObject,
isValidReceiptExtension,
getFileValidationErrorText,
hasHeicOrHeifExtension,
getConfirmModalPrompt,
canvasFallback,
getFilesFromClipboardEvent,
cleanFileObject,
cleanFileObjectName,
};

export type {ValidateAttachmentOptions};
export type {FileValidationError};
Loading
Loading