From 5add194d3435ab8a98194bf87a361984d61fef38 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 10:03:24 +0100 Subject: [PATCH 1/2] fix: Downscale oversized in-app camera photos before use --- .../AttachmentPicker/AttachmentCamera.tsx | 38 +++++++++---- src/libs/cameraCapture/downscalePhoto.ts | 55 +++++++++++++++++++ src/libs/cameraCapture/releaseQuietly.ts | 12 ++++ .../utils/rotatePhotoToUpright.ts | 10 +--- tests/unit/downscalePhotoTest.ts | 45 +++++++++++++++ 5 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 src/libs/cameraCapture/downscalePhoto.ts create mode 100644 src/libs/cameraCapture/releaseQuietly.ts create mode 100644 tests/unit/downscalePhotoTest.ts diff --git a/src/components/AttachmentPicker/AttachmentCamera.tsx b/src/components/AttachmentPicker/AttachmentCamera.tsx index 14b17b238293..c6666385e7cf 100644 --- a/src/components/AttachmentPicker/AttachmentCamera.tsx +++ b/src/components/AttachmentPicker/AttachmentCamera.tsx @@ -24,6 +24,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {capturePhotoToPath} from '@libs/cameraCapture'; +import downscalePhoto from '@libs/cameraCapture/downscalePhoto'; import {getZoomProp} from '@libs/cameraCapture/getWideLensZoom'; import selectWideCameraDevice from '@libs/cameraCapture/selectWideCameraDevice'; import {getFileName} from '@libs/fileDownload/FileUtils'; @@ -178,18 +179,33 @@ function AttachmentCamera({isVisible, onCapture, onClose, onModalHide}: Attachme .then((photo) => { if (!isActiveRef.current) { discardPhoto(photo.path); - return; + return undefined; } - const uri = getPhotoSource(photo.path); - const fileName = getFileName(photo.path) || `photo_${Date.now()}.jpg`; - - setCapturedPhoto({ - uri, - fileName, - type: 'image/jpeg', - width: photo.width, - height: photo.height, - }); + return downscalePhoto(photo) + .catch((error: unknown) => { + Log.warn('[AttachmentCamera] could not shrink the captured photo, using the original', {error: error instanceof Error ? error.message : String(error)}); + return undefined; + }) + .then((downscaledPhoto) => { + if (downscaledPhoto) { + discardPhoto(photo.path); + } + const finalPhoto = downscaledPhoto ?? photo; + if (!isActiveRef.current) { + discardPhoto(finalPhoto.path); + return; + } + const uri = getPhotoSource(finalPhoto.path); + const fileName = getFileName(finalPhoto.path) || `photo_${Date.now()}.jpg`; + + setCapturedPhoto({ + uri, + fileName, + type: 'image/jpeg', + width: finalPhoto.width, + height: finalPhoto.height, + }); + }); }) .catch((error: Error) => { if (!isActiveRef.current) { diff --git a/src/libs/cameraCapture/downscalePhoto.ts b/src/libs/cameraCapture/downscalePhoto.ts new file mode 100644 index 000000000000..68efbb83472f --- /dev/null +++ b/src/libs/cameraCapture/downscalePhoto.ts @@ -0,0 +1,55 @@ +/** + * Shrinks a captured photo to the camera's target resolution. VisionCamera v5 treats `targetResolution` as a hint, so + * some Android devices return a full-sensor photo that is too big for flows with tight size limits, like avatars. + */ +import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; +import getPhotoSource from '@libs/fileDownload/getPhotoSource'; +import Log from '@libs/Log'; + +import CONST from '@src/CONST'; + +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; + +import type CapturedPhoto from './types'; + +import releaseQuietly from './releaseQuietly'; + +const MAX_LONG_EDGE = Math.max(CONST.RECEIPT_CAMERA.PHOTO_WIDTH, CONST.RECEIPT_CAMERA.PHOTO_HEIGHT); + +type PhotoSize = { + width: number; + height: number; +}; + +function getDownscaledSize({width, height}: PhotoSize): PhotoSize | undefined { + const longEdge = Math.max(width, height); + if (longEdge <= MAX_LONG_EDGE) { + return undefined; + } + const scale = MAX_LONG_EDGE / longEdge; + return {width: Math.round(width * scale), height: Math.round(height * scale)}; +} + +function downscalePhoto(photo: CapturedPhoto): Promise { + const size = getDownscaledSize(photo); + if (!size) { + return Promise.resolve(undefined); + } + + // The manipulator applies the file's rotation metadata while decoding, so resize in the display frame `photo` reports. + const context = ImageManipulator.manipulate(getPhotoSource(photo.path)).resize(size); + return context + .renderAsync() + .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG}).finally(() => releaseQuietly(image))) + .then((result) => { + Log.info('[downscalePhoto] shrank the captured photo', false, { + source: `${photo.width}x${photo.height}`, + result: `${result.width}x${result.height}`, + }); + return {path: result.uri, width: result.width, height: result.height, orientation: photo.orientation}; + }) + .finally(() => releaseQuietly(context)); +} + +export default downscalePhoto; +export {getDownscaledSize}; diff --git a/src/libs/cameraCapture/releaseQuietly.ts b/src/libs/cameraCapture/releaseQuietly.ts new file mode 100644 index 000000000000..6a680683d82d --- /dev/null +++ b/src/libs/cameraCapture/releaseQuietly.ts @@ -0,0 +1,12 @@ +import Log from '@libs/Log'; + +/** The garbage collector can't see the native memory a 12 MP bitmap holds, so free it as soon as the photo is saved. */ +function releaseQuietly(releasable: {release: () => void}) { + try { + releasable.release(); + } catch (error) { + Log.warn('[cameraCapture] could not release a native image', {error: error instanceof Error ? error.message : String(error)}); + } +} + +export default releaseQuietly; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts index 57d84fc810da..8bb0354436eb 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -1,6 +1,7 @@ /** * Rotates a captured still so the receipt reads upright. Returns undefined when the photo is already upright. */ +import releaseQuietly from '@libs/cameraCapture/releaseQuietly'; import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import Log from '@libs/Log'; @@ -32,15 +33,6 @@ function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientatio return orientation === 'right' ? 270 : 90; } -/** The garbage collector can't see the native memory a 12 MP bitmap holds, so free it as soon as the photo is saved. */ -function releaseQuietly(releasable: {release: () => void}) { - try { - releasable.release(); - } catch (error) { - Log.warn('[PhotoUpgrade] could not release a native image', {error: error instanceof Error ? error.message : String(error)}); - } -} - function rotatePhotoToUpright(stillPath: string, orientation?: CameraOrientation): Promise { const sourceUri = getPhotoSource(stillPath); const startedAt = Date.now(); diff --git a/tests/unit/downscalePhotoTest.ts b/tests/unit/downscalePhotoTest.ts new file mode 100644 index 000000000000..31f3aa83757f --- /dev/null +++ b/tests/unit/downscalePhotoTest.ts @@ -0,0 +1,45 @@ +import {getDownscaledSize} from '@libs/cameraCapture/downscalePhoto'; + +describe('getDownscaledSize', () => { + it('leaves a photo at the target resolution alone, rather than re-encoding it', () => { + // Given photos that already match the camera's 2880x2160 target, in either orientation + const landscape = {width: 2880, height: 2160}; + const portrait = {width: 2160, height: 2880}; + + // When the downscaled size is derived + // Then neither needs shrinking, so the capture is used as is + expect(getDownscaledSize(landscape)).toBeUndefined(); + expect(getDownscaledSize(portrait)).toBeUndefined(); + }); + + it('leaves a smaller photo alone', () => { + // Given a photo below the target, as a low-end device may produce + const size = {width: 1440, height: 1920}; + + // When the downscaled size is derived + // Then it is not upscaled + expect(getDownscaledSize(size)).toBeUndefined(); + }); + + it('shrinks a full-sensor photo so its long edge matches the target and keeps its aspect ratio', () => { + // Given a 50 MP portrait photo, as VisionCamera v5 returns on some Samsung devices when it ignores the resolution hint + const size = {width: 6120, height: 8160}; + + // When the downscaled size is derived + const result = getDownscaledSize(size); + + // Then the long edge is capped at 2880 and the 3:4 ratio is preserved, so the file fits the avatar size limit + expect(result).toEqual({width: 2160, height: 2880}); + }); + + it('caps the width of a landscape photo', () => { + // Given a 16:9 landscape photo larger than the target + const size = {width: 4000, height: 2250}; + + // When the downscaled size is derived + const result = getDownscaledSize(size); + + // Then the width becomes the long edge cap and the height scales with it + expect(result).toEqual({width: 2880, height: 1620}); + }); +}); From 7414598efa3e8a69e280d044b5704cbfe1cb76b0 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 10:43:59 +0100 Subject: [PATCH 2/2] fix: Resize from the decoded bitmap and fall back on early errors --- src/libs/cameraCapture/downscalePhoto.ts | 50 +++++++++++++++++------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/libs/cameraCapture/downscalePhoto.ts b/src/libs/cameraCapture/downscalePhoto.ts index 68efbb83472f..afb36b5fa4e0 100644 --- a/src/libs/cameraCapture/downscalePhoto.ts +++ b/src/libs/cameraCapture/downscalePhoto.ts @@ -31,24 +31,46 @@ function getDownscaledSize({width, height}: PhotoSize): PhotoSize | undefined { } function downscalePhoto(photo: CapturedPhoto): Promise { - const size = getDownscaledSize(photo); - if (!size) { + if (!getDownscaledSize(photo)) { return Promise.resolve(undefined); } - // The manipulator applies the file's rotation metadata while decoding, so resize in the display frame `photo` reports. - const context = ImageManipulator.manipulate(getPhotoSource(photo.path)).resize(size); - return context - .renderAsync() - .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG}).finally(() => releaseQuietly(image))) - .then((result) => { - Log.info('[downscalePhoto] shrank the captured photo', false, { - source: `${photo.width}x${photo.height}`, - result: `${result.width}x${result.height}`, - }); - return {path: result.uri, width: result.width, height: result.height, orientation: photo.orientation}; + const sourceUri = getPhotoSource(photo.path); + const nativeHandles: Array<{release: () => void}> = []; + // Starts inside a promise so a synchronous throw from the native module reaches the caller's fallback. + return Promise.resolve() + .then(() => { + const decodeContext = ImageManipulator.manipulate(sourceUri); + nativeHandles.push(decodeContext); + return decodeContext.renderAsync(); }) - .finally(() => releaseQuietly(context)); + .then((decoded) => { + nativeHandles.push(decoded); + // The loader applies the file's orientation metadata, including the transposed one a mirrored front-camera photo + // carries, so size from the decoded bitmap rather than the capture's reported size. + const size = getDownscaledSize(decoded); + if (!size) { + return undefined; + } + + const resizeContext = ImageManipulator.manipulate(decoded).resize(size); + nativeHandles.push(resizeContext); + return resizeContext + .renderAsync() + .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG}).finally(() => releaseQuietly(image))) + .then((result) => { + Log.info('[downscalePhoto] shrank the captured photo', false, { + source: `${decoded.width}x${decoded.height}`, + result: `${result.width}x${result.height}`, + }); + return {path: result.uri, width: result.width, height: result.height, orientation: photo.orientation}; + }); + }) + .finally(() => { + for (const handle of nativeHandles) { + releaseQuietly(handle); + } + }); } export default downscalePhoto;