Skip to content
Merged
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
38 changes: 27 additions & 11 deletions src/components/AttachmentPicker/AttachmentCamera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
77 changes: 77 additions & 0 deletions src/libs/cameraCapture/downscalePhoto.ts
Original file line number Diff line number Diff line change
@@ -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<CapturedPhoto | undefined> {
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};
12 changes: 12 additions & 0 deletions src/libs/cameraCapture/releaseQuietly.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string | undefined> {
const sourceUri = getPhotoSource(stillPath);
const startedAt = Date.now();
Expand Down
45 changes: 45 additions & 0 deletions tests/unit/downscalePhotoTest.ts
Original file line number Diff line number Diff line change
@@ -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});
});
});
Loading