Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

When uploading, automatically reduce the size of receipt image #45448

Merged
merged 16 commits into from
Aug 5, 2024
Merged
71 changes: 71 additions & 0 deletions patches/expo-image-manipulator+11.8.0.patch
Original file line number Diff line number Diff line change
@@ -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);
3 changes: 3 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,6 +144,8 @@ const CONST = {

LOGO_MAX_SCALE: 1.5,

MAX_IMAGE_DIMENSION: 2400,

BREADCRUMB_TYPE: {
ROOT: 'root',
STRONG: 'strong',
Expand Down
20 changes: 19 additions & 1 deletion src/libs/fileDownload/FileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -244,7 +245,7 @@ function base64ToFile(base64: string, filename: string): File {
return file;
}

function validateImageForCorruption(file: FileObject): Promise<void> {
function validateImageForCorruption(file: FileObject): Promise<{width: number; height: number} | void> {
if (!Str.isImage(file.name ?? '') || !file.uri) {
return Promise.resolve();
}
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

@shubham1206agra I think any image over 2MB should do it

@luacmartins This is not true. I need 24MB file to check this flow. See this line.

Copy link
Contributor

Choose a reason for hiding this comment

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

Or do you want to manipulate this condition?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@shubham1206agra you can download the large image here

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,
Expand All @@ -302,4 +318,6 @@ export {
isImage,
getFileResolution,
isHighResolutionImage,
getImageDimensionsAfterResize,
resizeImageIfNeeded,
};
13 changes: 13 additions & 0 deletions src/libs/fileDownload/getImageManipulator/index.native.ts
Original file line number Diff line number Diff line change
@@ -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<FileObject> {
return manipulateAsync(fileUri ?? '', [{resize: {width, height}}]).then((result) => ({
uri: result.uri,
width: result.width,
height: result.height,
type,
name: fileName,
}));
}
14 changes: 14 additions & 0 deletions src/libs/fileDownload/getImageManipulator/index.ts
Original file line number Diff line number Diff line change
@@ -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<File> {
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;
}),
);
}
9 changes: 9 additions & 0 deletions src/libs/fileDownload/getImageManipulator/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
type ImageManipulatorConfig = {
fileUri: string;
fileName: string;
width: number;
height: number;
type?: string;
};

export default ImageManipulatorConfig;
30 changes: 16 additions & 14 deletions src/pages/iou/request/step/IOURequestStepScan/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(() => {
Expand Down
33 changes: 18 additions & 15 deletions src/pages/iou/request/step/IOURequestStepScan/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PermissionState | undefined>('prompt');
const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false);
const [isTorchAvailable, setIsTorchAvailable] = useState(false);
Expand All @@ -78,6 +77,7 @@ function IOURequestStepScan({
const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(false);

const getScreenshotTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`);

const [videoConstraints, setVideoConstraints] = useState<MediaTrackConstraints>();
const tabIndex = 1;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
});
});
};

Expand Down
Loading