Skip to content
Closed
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
13 changes: 11 additions & 2 deletions src/components/AttachmentPicker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import {cleanFileName, showCameraPermissionsAlert, verifyFileFormat} from '@libs/fileDownload/FileUtils';
import getBoundedImageResize from '@libs/getBoundedImageResize';
import Log from '@libs/Log';
import moveReceiptToDurableStorage from '@libs/moveReceiptToDurableStorage';

Expand Down Expand Up @@ -241,8 +242,16 @@ function AttachmentPicker({
.then((isHEIC) => {
// react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature
if (isHEIC && asset.uri) {
ImageManipulator.manipulate(asset.uri)
.renderAsync()
const sourceUri = asset.uri;
// Bound the decode dimensions before rendering so a full-resolution HEIC bitmap can't OOM iOS (no-op off iOS).
getBoundedImageResize(sourceUri)
.then((resize) => {
const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
if (resize) {
imageManipulatorContext.resize(resize);
}
return imageManipulatorContext.renderAsync();
})
.then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
.then((manipulationResult) => {
const uri = manipulationResult.uri;
Expand Down
26 changes: 24 additions & 2 deletions src/libs/cropOrRotateImage/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {getBoundedResizeForDimensions} from '@libs/getBoundedImageResize';
import Log from '@libs/Log';

import {ImageManipulator} from 'expo-image-manipulator';
Expand Down Expand Up @@ -25,8 +26,29 @@ const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) =>
context.rotate(action.rotate);
}
}
context
.renderAsync()
// Bound the result so a large source — or a crop that still keeps most of a 48MP photo — can't OOM iOS.
// We replay the crop/rotate pipeline over the source dimensions to get the output size, then append the
// resize last so it never shifts the source-pixel crop coordinates. Reading the size can't fail the
// conversion (we just skip the cap on error).
ImageSize.getSize(uri)
.then(({width, height}) => {
let outputWidth = width;
let outputHeight = height;
for (const action of actions) {
if ('crop' in action) {
outputWidth = action.crop.width;
outputHeight = action.crop.height;
} else if ('rotate' in action && Math.abs(action.rotate % 180) === 90) {
[outputWidth, outputHeight] = [outputHeight, outputWidth];
}
}
const resize = getBoundedResizeForDimensions(outputWidth, outputHeight);
if (resize) {
context.resize(resize);
}
})
.catch(() => {})
.then(() => context.renderAsync())
.then((imageRef) => imageRef.saveAsync({compress: options.compress, format}))
// We need to remove the base64 value from the result, as it is causing crashes on Release builds.
// More info: https://github.com/Expensify/App/issues/37963#issuecomment-1989260033
Expand Down
13 changes: 10 additions & 3 deletions src/libs/fileDownload/heicConverter/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {verifyFileFormat} from '@libs/fileDownload/FileUtils';
import getBoundedImageResize from '@libs/getBoundedImageResize';
import Log from '@libs/Log';

import CONST from '@src/CONST';
Expand Down Expand Up @@ -29,9 +30,15 @@ const convertImageWithManipulator = (
onFinish?: () => void;
} = {},
) => {
const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
imageManipulatorContext
.renderAsync()
// Bound the decode dimensions before rendering so a full-resolution HEIC bitmap can't OOM iOS.
getBoundedImageResize(sourceUri)
.then((resize) => {
const imageManipulatorContext = ImageManipulator.manipulate(sourceUri);
if (resize) {
imageManipulatorContext.resize(resize);
Comment on lines +36 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Downsample before constructing the manipulator context

On iOS with oversized HEICs, this resize is queued only after ImageManipulator.manipulate(sourceUri) has created the Expo context. In expo-image-manipulator@55, context creation immediately loads the source and runs the built-in orientation transformer, which allocates a full-size CGContext before any queued resize transformer executes. That means a 48MP HEIC can still hit the same full-bitmap allocation and OOM before this cap helps; the same pattern is repeated in the AttachmentPicker and crop/rotate paths. This needs a path that downscales during the native load/Photos request, or otherwise avoids ImageManipulator for the initial decode.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, and I verified it in the expo-image-manipulator@55 source. manipulate() always queues ImageFixOrientationTransformer before any user resize, and that transformer unconditionally allocates a full-size CGContext (width × height, RGBA) and draws the source into it — so the full-resolution bitmap is materialized before the resize runs. The resize only shrinks the output; it doesn't lower the peak, so this cap doesn't prevent the OOM on a single large decode.

Given that, the effective mitigation is the sequencing PR (#94976), which bounds how many full-resolution decodes are alive at once. A true per-image cap would need a downsampling decode at load time (e.g. ImageIO CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize + kCGImageSourceCreateThumbnailWithTransform), which ImageManipulator doesn't expose. Deferring to the team on whether to close this PR or repurpose it for a native downsample.

}
return imageManipulatorContext.renderAsync();
})
.then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
.then((manipulationResult) => {
const convertedFile = {
Expand Down
48 changes: 48 additions & 0 deletions src/libs/getBoundedImageResize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import CONST from '@src/CONST';

import {Platform} from 'react-native';
import ImageSize from 'react-native-image-size';

type BoundedResize = {width: number} | {height: number};

/**
* Returns a resize action that bounds the longest side of an image with the given dimensions to
* `CONST.MAX_IMAGE_DIMENSION`, or `undefined` when it already fits within that budget (so we never
* upscale). Resizing with only one side preserves the aspect ratio, so bounding the longer side is enough.
*
* The cap exists to keep `ImageManipulator`'s `renderAsync()` from materializing an oversized bitmap
* (`width × height × 4` bytes) in one contiguous native buffer. On iOS HybridApp — where NewDot shares a
* single jetsam memory budget with the resident OldDot — a large decode is exactly the allocation that
* triggers the `std::bad_alloc` OOM crash. It is a no-op off iOS, leaving other platforms untouched.
*/
function getBoundedResizeForDimensions(width: number, height: number): BoundedResize | undefined {
// The OOM is specific to iOS HybridApp's shared jetsam budget, so we only cap there (per the issue
// owner's guidance on #93846).
if (Platform.OS !== 'ios') {
return undefined;
}

if (Math.max(width, height) <= CONST.MAX_IMAGE_DIMENSION) {
return undefined;
}

return width >= height ? {width: CONST.MAX_IMAGE_DIMENSION} : {height: CONST.MAX_IMAGE_DIMENSION};
}

/**
* Reads the source dimensions of the image at `uri` and returns the bounding resize action to apply
* before `renderAsync()` (see `getBoundedResizeForDimensions`). If the dimensions can't be read we fall
* back to no resize rather than blocking the decode.
*/
function getBoundedImageResize(uri: string): Promise<BoundedResize | undefined> {
if (Platform.OS !== 'ios') {
return Promise.resolve(undefined);
}

return ImageSize.getSize(uri)
.then(({width, height}) => getBoundedResizeForDimensions(width, height))
.catch(() => undefined);
}

export {getBoundedResizeForDimensions};
export default getBoundedImageResize;
76 changes: 76 additions & 0 deletions tests/unit/cropOrRotateImageTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import cropOrRotateImage from '@libs/cropOrRotateImage/index.native';

import CONST from '@src/CONST';

import {ImageManipulator} from 'expo-image-manipulator';
import {Platform} from 'react-native';
import ImageSize from 'react-native-image-size';

jest.mock('react-native-image-size');
jest.mock('react-native-blob-util', () => ({
__esModule: true,
default: {fs: {stat: jest.fn(() => Promise.resolve({size: 1024}))}},
}));
jest.mock('expo-image-manipulator', () => {
const saveAsync = jest.fn(() => Promise.resolve({uri: 'file://cropped.jpg', width: 2400, height: 1800}));
const renderAsync = jest.fn(() => Promise.resolve({saveAsync}));
const context = {resize: jest.fn(), crop: jest.fn(), rotate: jest.fn(), renderAsync};
return {
ImageManipulator: {manipulate: jest.fn(() => context)},
SaveFormat: {JPEG: 'jpeg', PNG: 'png', WEBP: 'webp'},
};
});

const mockedGetSize = jest.mocked(ImageSize.getSize);
// `manipulate` returns the same context singleton on every call, so we can capture its action mocks once.
const probeContext = ImageManipulator.manipulate('probe');
/* eslint-disable @typescript-eslint/unbound-method */
const resizeMock = jest.mocked(probeContext.resize);
const cropMock = jest.mocked(probeContext.crop);
const rotateMock = jest.mocked(probeContext.rotate);
/* eslint-enable @typescript-eslint/unbound-method */

const options = {type: 'image/jpeg', name: 'cropped.jpg', compress: 1};

describe('cropOrRotateImage (native)', () => {
let platformReplaceProperty: jest.ReplaceProperty<string>;

beforeEach(() => {
platformReplaceProperty = jest.replaceProperty(Platform, 'OS', 'ios');
});

afterEach(() => {
platformReplaceProperty.restore();
jest.clearAllMocks();
});

it('bounds a crop that is still oversized, without dropping the crop action', async () => {
mockedGetSize.mockResolvedValue({width: 12000, height: 9000});
const crop = {originX: 0, originY: 0, width: 8000, height: 6000};

await cropOrRotateImage('file://receipt.heic', [{crop}], options);

expect(cropMock).toHaveBeenCalledWith(crop);
expect(resizeMock).toHaveBeenCalledWith({width: CONST.MAX_IMAGE_DIMENSION});
});

it('does not resize when the cropped output already fits the budget', async () => {
mockedGetSize.mockResolvedValue({width: 12000, height: 9000});
const crop = {originX: 0, originY: 0, width: 1000, height: 800};

await cropOrRotateImage('file://receipt.heic', [{crop}], options);

expect(cropMock).toHaveBeenCalledWith(crop);
expect(resizeMock).not.toHaveBeenCalled();
});

it('bounds a rotate-only large image using the rotated dimensions', async () => {
mockedGetSize.mockResolvedValue({width: 8000, height: 6000});

await cropOrRotateImage('file://photo.heic', [{rotate: 90}], options);

expect(rotateMock).toHaveBeenCalledWith(90);
// After a 90° rotate the 8000×6000 source becomes 6000×8000, so the taller side is bounded.
expect(resizeMock).toHaveBeenCalledWith({height: CONST.MAX_IMAGE_DIMENSION});
});
});
142 changes: 142 additions & 0 deletions tests/unit/getBoundedImageResizeTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import getBoundedImageResize, {getBoundedResizeForDimensions} from '@libs/getBoundedImageResize';

import CONST from '@src/CONST';

import {Platform} from 'react-native';
import ImageSize from 'react-native-image-size';

jest.mock('react-native-image-size');

const mockedGetSize = jest.mocked(ImageSize.getSize);
const MAX = CONST.MAX_IMAGE_DIMENSION;

describe('getBoundedImageResize', () => {
afterEach(() => {
jest.clearAllMocks();
});

describe('on non-iOS platforms', () => {
const nonIOSPlatforms = ['android', 'web', 'macos', 'windows'] as const;

describe.each(nonIOSPlatforms)('%s', (platform) => {
let platformReplaceProperty: jest.ReplaceProperty<string>;

beforeEach(() => {
platformReplaceProperty = jest.replaceProperty(Platform, 'OS', platform);
});

afterEach(() => {
platformReplaceProperty.restore();
});

it('returns undefined without reading the image size (the cap is iOS-only)', async () => {
const result = await getBoundedImageResize('file://photo.heic');

expect(result).toBeUndefined();
expect(mockedGetSize).not.toHaveBeenCalled();
});
});
});

describe('on iOS', () => {
let platformReplaceProperty: jest.ReplaceProperty<string>;

beforeEach(() => {
platformReplaceProperty = jest.replaceProperty(Platform, 'OS', 'ios');
});

afterEach(() => {
platformReplaceProperty.restore();
});

it('returns undefined when the image already fits within the budget (so it does not upscale)', async () => {
mockedGetSize.mockResolvedValue({width: 1200, height: 800});

const result = await getBoundedImageResize('file://small.heic');

expect(result).toBeUndefined();
});

it('returns undefined when the longest side equals the budget (boundary is inclusive)', async () => {
mockedGetSize.mockResolvedValue({width: MAX, height: 1000});

const result = await getBoundedImageResize('file://exact.heic');

expect(result).toBeUndefined();
});

it('caps the width for a landscape image that exceeds the budget', async () => {
mockedGetSize.mockResolvedValue({width: 8000, height: 6000});

const result = await getBoundedImageResize('file://landscape.heic');

expect(result).toEqual({width: MAX});
});

it('caps the height for a portrait image that exceeds the budget', async () => {
mockedGetSize.mockResolvedValue({width: 6000, height: 8000});

const result = await getBoundedImageResize('file://portrait.heic');

expect(result).toEqual({height: MAX});
});

it('caps the width for a square image that exceeds the budget', async () => {
mockedGetSize.mockResolvedValue({width: 8000, height: 8000});

const result = await getBoundedImageResize('file://square.heic');

expect(result).toEqual({width: MAX});
});

it('falls back to no resize when the image size cannot be read', async () => {
mockedGetSize.mockRejectedValue(new Error('Could not read image size'));

const result = await getBoundedImageResize('file://corrupt.heic');

expect(result).toBeUndefined();
});
});
});

describe('getBoundedResizeForDimensions', () => {
describe('on non-iOS platforms', () => {
let platformReplaceProperty: jest.ReplaceProperty<string>;

beforeEach(() => {
platformReplaceProperty = jest.replaceProperty(Platform, 'OS', 'android');
});

afterEach(() => {
platformReplaceProperty.restore();
});

it('returns undefined even for an oversized image (the cap is iOS-only)', () => {
expect(getBoundedResizeForDimensions(8000, 6000)).toBeUndefined();
});
});

describe('on iOS', () => {
let platformReplaceProperty: jest.ReplaceProperty<string>;

beforeEach(() => {
platformReplaceProperty = jest.replaceProperty(Platform, 'OS', 'ios');
});

afterEach(() => {
platformReplaceProperty.restore();
});

it('returns undefined when both sides fit within the budget', () => {
expect(getBoundedResizeForDimensions(2000, 1500)).toBeUndefined();
});

it('caps the width for an oversized landscape crop', () => {
expect(getBoundedResizeForDimensions(8000, 6000)).toEqual({width: MAX});
});

it('caps the height for an oversized portrait crop', () => {
expect(getBoundedResizeForDimensions(6000, 8000)).toEqual({height: MAX});
});
});
});
Loading
Loading