diff --git a/patches/expo-image-manipulator+11.8.0.patch b/patches/expo-image-manipulator+11.8.0.patch new file mode 100644 index 000000000000..672b7c99bdac --- /dev/null +++ b/patches/expo-image-manipulator+11.8.0.patch @@ -0,0 +1,71 @@ +diff --git a/node_modules/expo-image-manipulator/build/ExpoImageManipulator.web.js b/node_modules/expo-image-manipulator/build/ExpoImageManipulator.web.js +index 5b77ad6..a3ecdb0 100644 +--- a/node_modules/expo-image-manipulator/build/ExpoImageManipulator.web.js ++++ b/node_modules/expo-image-manipulator/build/ExpoImageManipulator.web.js +@@ -1,5 +1,13 @@ + import { crop, extent, flip, resize, rotate } from './actions/index.web'; + import { getContext } from './utils/getContext.web'; ++ ++const SAFARI_MOBILE_CANVAS_LIMIT = 4096; ++ ++const isMobileIOS = () => { ++ const userAgent = navigator.userAgent; ++ return /iP(ad|od|hone)/i.test(userAgent) && /(WebKit|CriOS|FxiOS|OPiOS|mercury)/i.test(userAgent); ++}; ++ + function getResults(canvas, options) { + let uri; + if (options) { +@@ -21,16 +29,49 @@ function getResults(canvas, options) { + base64: uri.replace(/^data:image\/\w+;base64,/, ''), + }; + } ++ ++function getAdjustedCanvasSize(originalWidth, originalHeight) { ++ if(!isMobileIOS()) return { width: originalWidth, height: originalHeight }; ++ ++ const aspectRatio = originalWidth / originalHeight; ++ let newWidth; ++ let newHeight; ++ ++ if (originalWidth <= SAFARI_MOBILE_CANVAS_LIMIT && originalHeight <= SAFARI_MOBILE_CANVAS_LIMIT) { ++ return { width: originalWidth, height: originalHeight }; ++ } ++ ++ if (aspectRatio > 1) { ++ newWidth = SAFARI_MOBILE_CANVAS_LIMIT; ++ newHeight = Math.round(newWidth / aspectRatio); ++ } else { ++ newHeight = SAFARI_MOBILE_CANVAS_LIMIT; ++ newWidth = Math.round(newHeight * aspectRatio); ++ } ++ ++ if (newWidth > SAFARI_MOBILE_CANVAS_LIMIT) { ++ newWidth = SAFARI_MOBILE_CANVAS_LIMIT; ++ newHeight = Math.round(newWidth / aspectRatio); ++ } else if (newHeight > SAFARI_MOBILE_CANVAS_LIMIT) { ++ newHeight = SAFARI_MOBILE_CANVAS_LIMIT; ++ newWidth = Math.round(newHeight * aspectRatio); ++ } ++ ++ return { width: newWidth, height: newHeight }; ++} ++ + function loadImageAsync(uri) { + return new Promise((resolve, reject) => { + const imageSource = new Image(); + imageSource.crossOrigin = 'anonymous'; + const canvas = document.createElement('canvas'); + imageSource.onload = () => { +- canvas.width = imageSource.naturalWidth; +- canvas.height = imageSource.naturalHeight; ++ const adjudstedCanvasSize = getAdjustedCanvasSize(imageSource.naturalWidth, imageSource.naturalHeight); ++ ++ canvas.width = adjudstedCanvasSize.width; ++ canvas.height = adjudstedCanvasSize.height; + const context = getContext(canvas); +- context.drawImage(imageSource, 0, 0, imageSource.naturalWidth, imageSource.naturalHeight); ++ context.drawImage(imageSource, 0, 0, adjudstedCanvasSize.width, adjudstedCanvasSize.height); + resolve(canvas); + }; + imageSource.onerror = () => reject(canvas); diff --git a/src/CONST.ts b/src/CONST.ts index ed4de999c78c..73c9bf605b47 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -102,6 +102,7 @@ const CONST = { BACKGROUND_IMAGE_TRANSITION_DURATION: 1000, SCREEN_TRANSITION_END_TIMEOUT: 1000, ARROW_HIDE_DELAY: 3000, + MAX_IMAGE_CANVAS_AREA: 16777216, API_ATTACHMENT_VALIDATIONS: { // 24 megabytes in bytes, this is limit set on servers, do not update without wider internal discussion @@ -143,6 +144,8 @@ const CONST = { LOGO_MAX_SCALE: 1.5, + MAX_IMAGE_DIMENSION: 2400, + BREADCRUMB_TYPE: { ROOT: 'root', STRONG: 'strong', diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 7422d8bd8d8b..acf2e3bb32c5 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -6,6 +6,7 @@ import DateUtils from '@libs/DateUtils'; import * as Localize from '@libs/Localize'; import Log from '@libs/Log'; import CONST from '@src/CONST'; +import getImageManipulator from './getImageManipulator'; import getImageResolution from './getImageResolution'; import type {ReadFileAsync, SplitExtensionFromFileName} from './types'; @@ -244,7 +245,7 @@ function base64ToFile(base64: string, filename: string): File { return file; } -function validateImageForCorruption(file: FileObject): Promise { +function validateImageForCorruption(file: FileObject): Promise<{width: number; height: number} | void> { if (!Str.isImage(file.name ?? '') || !file.uri) { return Promise.resolve(); } @@ -285,6 +286,21 @@ function isHighResolutionImage(resolution: {width: number; height: number} | nul return resolution !== null && (resolution.width > CONST.IMAGE_HIGH_RESOLUTION_THRESHOLD || resolution.height > CONST.IMAGE_HIGH_RESOLUTION_THRESHOLD); } +const getImageDimensionsAfterResize = (file: FileObject) => + ImageSize.getSize(file.uri ?? '').then(({width, height}) => { + const scaleFactor = CONST.MAX_IMAGE_DIMENSION / (width < height ? height : width); + const newWidth = Math.max(1, width * scaleFactor); + const newHeight = Math.max(1, height * scaleFactor); + + return {width: newWidth, height: newHeight}; + }); + +const resizeImageIfNeeded = (file: FileObject) => { + if (!file || !Str.isImage(file.name ?? '') || (file?.size ?? 0) <= CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + return Promise.resolve(file); + } + return getImageDimensionsAfterResize(file).then(({width, height}) => getImageManipulator({fileUri: file.uri ?? '', width, height, fileName: file.name ?? '', type: file.type})); +}; export { showGeneralErrorAlert, showSuccessAlert, @@ -302,4 +318,6 @@ export { isImage, getFileResolution, isHighResolutionImage, + getImageDimensionsAfterResize, + resizeImageIfNeeded, }; diff --git a/src/libs/fileDownload/getImageManipulator/index.native.ts b/src/libs/fileDownload/getImageManipulator/index.native.ts new file mode 100644 index 000000000000..07b97d2e375b --- /dev/null +++ b/src/libs/fileDownload/getImageManipulator/index.native.ts @@ -0,0 +1,13 @@ +import {manipulateAsync} from 'expo-image-manipulator'; +import type {FileObject} from '@components/AttachmentModal'; +import type ImageManipulatorConfig from './type'; + +export default function getImageManipulator({fileUri, width, height, type, fileName}: ImageManipulatorConfig): Promise { + return manipulateAsync(fileUri ?? '', [{resize: {width, height}}]).then((result) => ({ + uri: result.uri, + width: result.width, + height: result.height, + type, + name: fileName, + })); +} diff --git a/src/libs/fileDownload/getImageManipulator/index.ts b/src/libs/fileDownload/getImageManipulator/index.ts new file mode 100644 index 000000000000..87319978caa8 --- /dev/null +++ b/src/libs/fileDownload/getImageManipulator/index.ts @@ -0,0 +1,14 @@ +import {manipulateAsync} from 'expo-image-manipulator'; +import type ImageManipulatorConfig from './type'; + +export default function getImageManipulator({fileUri, width, height, fileName}: ImageManipulatorConfig): Promise { + return manipulateAsync(fileUri ?? '', [{resize: {width, height}}]).then((result) => + fetch(result.uri) + .then((res) => res.blob()) + .then((blob) => { + const resizedFile = new File([blob], `${fileName}.jpeg`, {type: 'image/jpeg'}); + resizedFile.uri = URL.createObjectURL(resizedFile); + return resizedFile; + }), + ); +} diff --git a/src/libs/fileDownload/getImageManipulator/type.ts b/src/libs/fileDownload/getImageManipulator/type.ts new file mode 100644 index 000000000000..e976b1eff477 --- /dev/null +++ b/src/libs/fileDownload/getImageManipulator/type.ts @@ -0,0 +1,9 @@ +type ImageManipulatorConfig = { + fileUri: string; + fileName: string; + width: number; + height: number; + type?: string; +}; + +export default ImageManipulatorConfig; diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 8fdecbacb75b..2630b82a7ba4 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -180,7 +180,7 @@ function IOURequestStepScan({ return false; } - if ((file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { Alert.alert(translate('attachmentPicker.attachmentTooLarge'), translate('attachmentPicker.sizeExceeded')); return false; } @@ -387,28 +387,30 @@ function IOURequestStepScan({ /** * Sets the Receipt objects and navigates the user to the next page */ - const setReceiptAndNavigate = (file: FileObject, isPdfValidated?: boolean) => { - if (!validateReceipt(file)) { + const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { + if (!validateReceipt(originalFile)) { return; } // If we have a pdf file and if it is not validated then set the pdf file for validation and return - if (Str.isPDF(file.name ?? '') && !isPdfValidated) { - setPdfFile(file); + if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { + setPdfFile(originalFile); return; } - // Store the receipt on the transaction object in Onyx - // On Android devices, fetching blob for a file with name containing spaces fails to retrieve the type of file. - // So, let us also save the file type in receipt for later use during blob fetch - IOU.setMoneyRequestReceipt(transactionID, file?.uri ?? '', file.name ?? '', action !== CONST.IOU.ACTION.EDIT, file.type); + FileUtils.resizeImageIfNeeded(originalFile).then((file) => { + // Store the receipt on the transaction object in Onyx + // On Android devices, fetching blob for a file with name containing spaces fails to retrieve the type of file. + // So, let us also save the file type in receipt for later use during blob fetch + IOU.setMoneyRequestReceipt(transactionID, file?.uri ?? '', file.name ?? '', action !== CONST.IOU.ACTION.EDIT, file.type); - if (action === CONST.IOU.ACTION.EDIT) { - updateScanAndNavigate(file, file?.uri ?? ''); - return; - } + if (action === CONST.IOU.ACTION.EDIT) { + updateScanAndNavigate(file, file?.uri ?? ''); + return; + } - navigateToConfirmationStep(file, file.uri ?? ''); + navigateToConfirmationStep(file, file.uri ?? ''); + }); }; const capturePhoto = useCallback(() => { diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 601e105d294b..faf67efa857d 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -69,7 +69,6 @@ function IOURequestStepScan({ const {isSmallScreenWidth} = useResponsiveLayout(); const {translate} = useLocalize(); const {isDraggingOver} = useContext(DragAndDropContext); - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); const [cameraPermissionState, setCameraPermissionState] = useState('prompt'); const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false); const [isTorchAvailable, setIsTorchAvailable] = useState(false); @@ -78,6 +77,7 @@ function IOURequestStepScan({ const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(false); const getScreenshotTimeoutRef = useRef(null); + const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); const [videoConstraints, setVideoConstraints] = useState(); const tabIndex = 1; @@ -207,7 +207,7 @@ function IOURequestStepScan({ return false; } - if ((file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceeded'); return false; } @@ -419,27 +419,30 @@ function IOURequestStepScan({ /** * Sets the Receipt objects and navigates the user to the next page */ - const setReceiptAndNavigate = (file: FileObject, isPdfValidated?: boolean) => { - validateReceipt(file).then((isFileValid) => { + const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { + validateReceipt(originalFile).then((isFileValid) => { if (!isFileValid) { return; } // If we have a pdf file and if it is not validated then set the pdf file for validation and return - if (Str.isPDF(file.name ?? '') && !isPdfValidated) { - setPdfFile(file); + if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { + setPdfFile(originalFile); return; } - // Store the receipt on the transaction object in Onyx - const source = URL.createObjectURL(file as Blob); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - IOU.setMoneyRequestReceipt(transactionID, source, file.name || '', action !== CONST.IOU.ACTION.EDIT); - if (action === CONST.IOU.ACTION.EDIT) { - updateScanAndNavigate(file, source); - return; - } - navigateToConfirmationStep(file, source); + FileUtils.resizeImageIfNeeded(originalFile).then((file) => { + // Store the receipt on the transaction object in Onyx + const source = URL.createObjectURL(file as Blob); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + IOU.setMoneyRequestReceipt(transactionID, source, file.name || '', action !== CONST.IOU.ACTION.EDIT); + + if (action === CONST.IOU.ACTION.EDIT) { + updateScanAndNavigate(file, source); + return; + } + navigateToConfirmationStep(file, source); + }); }); };