diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 3fa8cba4721f..aacfa26e03ba 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -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'; @@ -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; diff --git a/src/libs/cropOrRotateImage/index.native.ts b/src/libs/cropOrRotateImage/index.native.ts index 208443d63643..2930e9530402 100644 --- a/src/libs/cropOrRotateImage/index.native.ts +++ b/src/libs/cropOrRotateImage/index.native.ts @@ -1,3 +1,4 @@ +import {getBoundedResizeForDimensions} from '@libs/getBoundedImageResize'; import Log from '@libs/Log'; import {ImageManipulator} from 'expo-image-manipulator'; @@ -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 diff --git a/src/libs/fileDownload/heicConverter/index.native.ts b/src/libs/fileDownload/heicConverter/index.native.ts index 750dea50e680..82291f4338c2 100644 --- a/src/libs/fileDownload/heicConverter/index.native.ts +++ b/src/libs/fileDownload/heicConverter/index.native.ts @@ -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'; @@ -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); + } + return imageManipulatorContext.renderAsync(); + }) .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG})) .then((manipulationResult) => { const convertedFile = { diff --git a/src/libs/getBoundedImageResize.ts b/src/libs/getBoundedImageResize.ts new file mode 100644 index 000000000000..032696a0a234 --- /dev/null +++ b/src/libs/getBoundedImageResize.ts @@ -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 { + 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; diff --git a/tests/unit/cropOrRotateImageTest.ts b/tests/unit/cropOrRotateImageTest.ts new file mode 100644 index 000000000000..9277ab063059 --- /dev/null +++ b/tests/unit/cropOrRotateImageTest.ts @@ -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; + + 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}); + }); +}); diff --git a/tests/unit/getBoundedImageResizeTest.ts b/tests/unit/getBoundedImageResizeTest.ts new file mode 100644 index 000000000000..63fb1299938f --- /dev/null +++ b/tests/unit/getBoundedImageResizeTest.ts @@ -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; + + 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; + + 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; + + 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; + + 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}); + }); + }); +}); diff --git a/tests/unit/heicConverterTest.ts b/tests/unit/heicConverterTest.ts new file mode 100644 index 000000000000..f127055ac29e --- /dev/null +++ b/tests/unit/heicConverterTest.ts @@ -0,0 +1,69 @@ +import convertHeicImage from '@libs/fileDownload/heicConverter/index.native'; + +import CONST from '@src/CONST'; +import type {FileObject} from '@src/types/utils/Attachment'; + +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('expo-image-manipulator', () => { + const saveAsync = jest.fn(() => Promise.resolve({uri: 'file://converted.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 `resize` mock once. +// eslint-disable-next-line @typescript-eslint/unbound-method +const resizeMock = jest.mocked(ImageManipulator.manipulate('probe').resize); + +const heicFile: FileObject = {name: 'photo.heic', uri: 'file://photo.heic', type: 'image/heic'}; + +const convertHeic = (file: FileObject) => + new Promise((resolve, reject) => { + convertHeicImage(file, {onSuccess: resolve, onError: (error) => reject(error)}); + }); + +describe('convertHeicImage (native)', () => { + let platformReplaceProperty: jest.ReplaceProperty; + + beforeEach(() => { + platformReplaceProperty = jest.replaceProperty(Platform, 'OS', 'ios'); + }); + + afterEach(() => { + platformReplaceProperty.restore(); + jest.clearAllMocks(); + }); + + it('caps the decode dimensions for a large HEIC photo before rendering', async () => { + mockedGetSize.mockResolvedValue({width: 8000, height: 6000}); + + await convertHeic(heicFile); + + expect(resizeMock).toHaveBeenCalledWith({width: CONST.MAX_IMAGE_DIMENSION}); + }); + + it('does not resize a HEIC photo that already fits within the budget', async () => { + mockedGetSize.mockResolvedValue({width: 1200, height: 800}); + + await convertHeic(heicFile); + + expect(resizeMock).not.toHaveBeenCalled(); + }); + + it('returns the original file untouched when it is not a HEIC/HEIF image', async () => { + const jpegFile: FileObject = {name: 'photo.jpg', uri: 'file://photo.jpg', type: 'image/jpeg'}; + + const result = await convertHeic(jpegFile); + + expect(result).toBe(jpegFile); + expect(resizeMock).not.toHaveBeenCalled(); + }); +});