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..afb36b5fa4e0 --- /dev/null +++ b/src/libs/cameraCapture/downscalePhoto.ts @@ -0,0 +1,77 @@ +/** + * 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 { + if (!getDownscaledSize(photo)) { + return Promise.resolve(undefined); + } + + 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(); + }) + .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; +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}); + }); +});