Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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: 187 additions & 163 deletions src/hooks/useFilesValidation.tsx

Large diffs are not rendered by default.

106 changes: 104 additions & 2 deletions src/libs/AttachmentUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,106 @@
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 @@ -8,5 +110,5 @@ function getImageCacheFileExtension(contentType: string) {
return imageCacheFileTypes[contentType] ?? '';
}

// eslint-disable-next-line import/prefer-default-export
export {getImageCacheFileExtension};
export {validateAttachmentFile, getImageCacheFileExtension};
export type {AttachmentValidationResult};
138 changes: 85 additions & 53 deletions src/libs/fileDownload/FileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,44 +644,71 @@ 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 = async (file: FileObject): Promise<FileObject> => {
const normalizeFileObject = (file: FileObject): Promise<FileObject> => {
if (file instanceof File || file instanceof Blob) {
return file;
return Promise.resolve(file);
}

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

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

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

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});
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);
});
};

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

type GetFileValidationErrorTextOptions = {
isValidatingReceipt?: boolean;
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;
};

const getFileValidationErrorText = (
translate: LocalizedTranslate,
validationError: FileValidationError | null,
options: GetFileValidationErrorTextOptions = {},
validationError: ValueOf<typeof CONST.FILE_VALIDATION_ERRORS> | null,
additionalData: TranslationAdditionalData = {},
isValidatingReceipt = false,
): {
title: string;
reason: string;
Expand All @@ -692,51 +719,45 @@ const getFileValidationErrorText = (
reason: '',
};
}
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) {
const maxSize = isValidatingReceipt ? CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE : CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE;
switch (validationError) {
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: options.isValidatingReceipt ? translate('attachmentPicker.sizeExceededWithLimit', maxSize / 1024 / 1024) : translate('attachmentPicker.sizeExceeded'),
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),
};
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 @@ -753,13 +774,21 @@ const getFileValidationErrorText = (
reason: translate('attachmentPicker.imageDimensionsTooLarge'),
};
default:
break;
return {
title: translate('attachmentPicker.attachmentError'),
reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'),
};
}
};

return {
title: translate('attachmentPicker.attachmentError'),
reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'),
};
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);
};

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

export type {ValidateAttachmentOptions};
Loading
Loading