From 2b7e31be809d8bed4c6712dbe19ede5923c9ff86 Mon Sep 17 00:00:00 2001 From: dilshodmackbook-sketch <279628751+dilshodmackbook-sketch@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:50:57 +0500 Subject: [PATCH 1/7] fix: cap iOS HEIC decode dimensions and sequence multi-select to prevent OOM iPhone photos are HEIC, so almost every iOS image attachment goes through a HEIC->JPEG conversion whose ImageManipulator.renderAsync() decodes the full source bitmap (width*height*4 bytes) into one native buffer. On HybridApp this shares a single jetsam budget with OldDot, and multi-select fires the conversions concurrently, so several large decodes coexist and trip std::bad_alloc (Sentry APP-63S). - Add getBoundedImageResize, which caps the longest side to CONST.MAX_IMAGE_DIMENSION before renderAsync() and never upscales. Gated to iOS so other platforms are untouched. - Apply the cap in heicConverter, the AttachmentPicker inline HEIC branch, and cropOrRotateImage (skipped when cropping, where coords are source-relative). - On iOS, process picked assets one at a time so at most one heavy decode is alive; other platforms keep the original concurrent behavior. --- .../AttachmentPicker/index.native.tsx | 120 ++++++++++-------- src/libs/cropOrRotateImage/index.native.ts | 15 ++- .../heicConverter/index.native.ts | 13 +- src/libs/getBoundedImageResize.ts | 38 ++++++ 4 files changed, 125 insertions(+), 61 deletions(-) create mode 100644 src/libs/getBoundedImageResize.ts diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index af4d231ffd98..76fcd3c3b3df 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -3,7 +3,7 @@ import {keepLocalCopy, pick, types} from '@react-native-documents/picker'; import {Str} from 'expensify-common'; import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import React, {useCallback, useMemo, useRef, useState} from 'react'; -import {Alert, View} from 'react-native'; +import {Alert, Platform, View} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; import {launchImageLibrary} from 'react-native-image-picker'; import type {Asset, Callback, CameraOptions, ImageLibraryOptions, ImagePickerResponse} from 'react-native-image-picker'; @@ -19,6 +19,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; 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'; import CONST from '@src/CONST'; @@ -214,68 +215,75 @@ function AttachmentPicker({ } const processedAssets: Asset[] = []; - let processedCount = 0; - const checkAllProcessed = () => { - processedCount++; - if (processedCount === assets.length) { - resolve(processedAssets.length > 0 ? processedAssets : undefined); + const processAsset = async (asset: Asset): Promise => { + if (!asset.uri) { + return; } - }; - for (const asset of assets) { - if (!asset.uri) { - checkAllProcessed(); - continue; + if (!asset.type?.startsWith('image')) { + // Ensure the asset has proper fileName and type + processedAssets.push(processAssetWithFallbacks(asset)); + return; } - if (asset.type?.startsWith('image')) { - verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES}) - .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() - .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG})) - .then((manipulationResult) => { - const uri = manipulationResult.uri; - const convertedAsset = { - uri, - name: uri - .substring(uri.lastIndexOf('/') + 1) - .split('?') - .at(0), - type: 'image/jpeg', - width: manipulationResult.width, - height: manipulationResult.height, - }; - processedAssets.push(convertedAsset); - checkAllProcessed(); - }) - .catch((error: Error) => { - Log.warn('Failed to convert HEIC image, falling back to original', {error: error.message}); - const fallbackAsset = processAssetWithFallbacks(asset); - processedAssets.push(fallbackAsset); - checkAllProcessed(); - }); - } else { - // Ensure the asset has proper fileName and type for non-HEIC images - const processedAsset = processAssetWithFallbacks(asset); - processedAssets.push(processedAsset); - checkAllProcessed(); - } - }) - .catch((error: Error) => { - showGeneralAlert(error.message ?? 'An unknown error occurred'); - checkAllProcessed(); - }); + try { + const isHEIC = await verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES}); + // 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) { + // Ensure the asset has proper fileName and type for non-HEIC images + processedAssets.push(processAssetWithFallbacks(asset)); + return; + } + + try { + // Bound the decode dimensions before rendering so a full-resolution HEIC bitmap can't OOM iOS (no-op off iOS). + const resize = await getBoundedImageResize(asset.uri); + const imageManipulatorContext = ImageManipulator.manipulate(asset.uri); + if (resize) { + imageManipulatorContext.resize(resize); + } + const manipulatedImage = await imageManipulatorContext.renderAsync(); + const manipulationResult = await manipulatedImage.saveAsync({format: SaveFormat.JPEG}); + const uri = manipulationResult.uri; + const convertedAsset = { + uri, + name: uri + .substring(uri.lastIndexOf('/') + 1) + .split('?') + .at(0), + type: 'image/jpeg', + width: manipulationResult.width, + height: manipulationResult.height, + }; + processedAssets.push(convertedAsset); + } catch (error) { + Log.warn('Failed to convert HEIC image, falling back to original', {error: error instanceof Error ? error.message : String(error)}); + processedAssets.push(processAssetWithFallbacks(asset)); + } + } catch (error) { + showGeneralAlert(error instanceof Error ? error.message : 'An unknown error occurred'); + } + }; + + // Each HEIC asset is decoded to a full native bitmap during conversion. On iOS HybridApp the + // process shares a single jetsam budget with OldDot, so converting the whole selection at once can + // allocate N large bitmaps simultaneously and OOM. There we process assets one at a time to keep at + // most one heavy decode alive; other platforms keep the original concurrent behavior (per #93846). + const processAssets = async () => { + if (Platform.OS === 'ios') { + for (const asset of assets) { + // eslint-disable-next-line no-await-in-loop + await processAsset(asset); + } } else { - // Ensure the asset has proper fileName and type - const processedAsset = processAssetWithFallbacks(asset); - processedAssets.push(processedAsset); - checkAllProcessed(); + await Promise.all(assets.map(processAsset)); } - } + + resolve(processedAssets.length > 0 ? processedAssets : undefined); + }; + + processAssets().catch(reject); }); }), [fileLimit, showGeneralAlert, translate, type], diff --git a/src/libs/cropOrRotateImage/index.native.ts b/src/libs/cropOrRotateImage/index.native.ts index 41091c25ba04..7e584576692b 100644 --- a/src/libs/cropOrRotateImage/index.native.ts +++ b/src/libs/cropOrRotateImage/index.native.ts @@ -2,6 +2,7 @@ import {ImageManipulator} from 'expo-image-manipulator'; import {Platform} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; import ImageSize from 'react-native-image-size'; +import getBoundedImageResize from '@libs/getBoundedImageResize'; import Log from '@libs/Log'; import getSaveFormat from './getSaveFormat'; import type {CropOrRotateImage} from './types'; @@ -15,15 +16,25 @@ const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) => new Promise((resolve, reject) => { const format = getSaveFormat(options.type); const context = ImageManipulator.manipulate(uri); + let hasCrop = false; for (const action of actions) { if ('crop' in action) { context.crop(action.crop); + hasCrop = true; } else if ('rotate' in action) { context.rotate(action.rotate); } } - context - .renderAsync() + // Bound large images to keep the full-resolution native decode from OOMing iOS. We skip this when + // cropping, since crop coordinates are in source-pixel space and the cropped output is already bounded. + const prepareResize = hasCrop ? Promise.resolve(undefined) : getBoundedImageResize(uri); + prepareResize + .then((resize) => { + if (resize) { + context.resize(resize); + } + return 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 34a9ad3001d4..49c0a29431f5 100644 --- a/src/libs/fileDownload/heicConverter/index.native.ts +++ b/src/libs/fileDownload/heicConverter/index.native.ts @@ -1,5 +1,6 @@ import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import {verifyFileFormat} from '@libs/fileDownload/FileUtils'; +import getBoundedImageResize from '@libs/getBoundedImageResize'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -26,9 +27,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..925bd539fdfa --- /dev/null +++ b/src/libs/getBoundedImageResize.ts @@ -0,0 +1,38 @@ +import {Platform} from 'react-native'; +import ImageSize from 'react-native-image-size'; +import CONST from '@src/CONST'; + +type BoundedResize = {width: number} | {height: number}; + +/** + * Returns a resize action that bounds the image's longest side to `CONST.MAX_IMAGE_DIMENSION`, + * or `undefined` when the image already fits within that budget (so we never upscale). + * + * This is meant to be applied before `ImageManipulator`'s `renderAsync()`, which otherwise + * materializes the full-resolution source 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 (e.g. 48MP HEIC) decode is exactly the allocation that triggers the + * `std::bad_alloc` OOM crash. Capping the decode dimensions keeps the bitmap within the pixel + * budget the app already defines elsewhere. + * + * Resizing with only one side preserves the aspect ratio, so bounding the longer side is enough. + * If the source dimensions can't be read we fall back to no resize rather than blocking the decode. + */ +function getBoundedImageResize(uri: string): Promise { + // The OOM is specific to iOS HybridApp's shared jetsam budget, so we only cap the decode there + // and leave other platforms untouched (per the issue owner's guidance on #93846). + if (Platform.OS !== 'ios') { + return Promise.resolve(undefined); + } + + return ImageSize.getSize(uri) + .then(({width, height}): BoundedResize | 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}; + }) + .catch(() => undefined); +} + +export default getBoundedImageResize; From 18efe9cc68741f99bd4874f4039de506599860fd Mon Sep 17 00:00:00 2001 From: dilshodmackbook-sketch <279628751+dilshodmackbook-sketch@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:03:18 +0500 Subject: [PATCH 2/7] test: cover getBoundedImageResize iOS-only decode cap Verifies the helper is a no-op off iOS, never upscales images within the pixel budget, clamps the longest side to CONST.MAX_IMAGE_DIMENSION for landscape, portrait, and square images, and falls back to no resize when the source dimensions can't be read. --- tests/unit/getBoundedImageResizeTest.ts | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/unit/getBoundedImageResizeTest.ts diff --git a/tests/unit/getBoundedImageResizeTest.ts b/tests/unit/getBoundedImageResizeTest.ts new file mode 100644 index 000000000000..9c8cd037cf1d --- /dev/null +++ b/tests/unit/getBoundedImageResizeTest.ts @@ -0,0 +1,98 @@ +import {Platform} from 'react-native'; +import ImageSize from 'react-native-image-size'; +import getBoundedImageResize from '@libs/getBoundedImageResize'; +import CONST from '@src/CONST'; + +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 (never upscales)', 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(); + }); + }); +}); From 7f2aad9b01ed7c028c89bd61d04c3099842501d4 Mon Sep 17 00:00:00 2001 From: dilshodmackbook-sketch <279628751+dilshodmackbook-sketch@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:09:02 +0500 Subject: [PATCH 3/7] fix: bound cropped output in cropOrRotateImage to keep large crops off the OOM path A crop taken from a full-resolution photo (e.g. cropImageToAspectRatio builds the crop rect from the full receipt dimensions) can still be thousands of pixels on its longest side, so skipping the cap whenever a crop was present left it on the OOM path. Instead of skipping, replay the crop/rotate pipeline over the source dimensions to compute the output size and append the resize last, which bounds the result without shifting the source-pixel crop coordinates. Extracts the pure dimension math into getBoundedResizeForDimensions and covers it with unit tests. --- src/libs/cropOrRotateImage/index.native.ts | 29 ++++++++----- src/libs/getBoundedImageResize.ts | 47 +++++++++++++--------- tests/unit/getBoundedImageResizeTest.ts | 44 +++++++++++++++++++- 3 files changed, 91 insertions(+), 29 deletions(-) diff --git a/src/libs/cropOrRotateImage/index.native.ts b/src/libs/cropOrRotateImage/index.native.ts index 7e584576692b..be3490d8bb1f 100644 --- a/src/libs/cropOrRotateImage/index.native.ts +++ b/src/libs/cropOrRotateImage/index.native.ts @@ -2,7 +2,7 @@ import {ImageManipulator} from 'expo-image-manipulator'; import {Platform} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; import ImageSize from 'react-native-image-size'; -import getBoundedImageResize from '@libs/getBoundedImageResize'; +import {getBoundedResizeForDimensions} from '@libs/getBoundedImageResize'; import Log from '@libs/Log'; import getSaveFormat from './getSaveFormat'; import type {CropOrRotateImage} from './types'; @@ -16,25 +16,36 @@ const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) => new Promise((resolve, reject) => { const format = getSaveFormat(options.type); const context = ImageManipulator.manipulate(uri); - let hasCrop = false; for (const action of actions) { if ('crop' in action) { context.crop(action.crop); - hasCrop = true; } else if ('rotate' in action) { context.rotate(action.rotate); } } - // Bound large images to keep the full-resolution native decode from OOMing iOS. We skip this when - // cropping, since crop coordinates are in source-pixel space and the cropped output is already bounded. - const prepareResize = hasCrop ? Promise.resolve(undefined) : getBoundedImageResize(uri); - prepareResize - .then((resize) => { + // 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); } - return context.renderAsync(); }) + .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/getBoundedImageResize.ts b/src/libs/getBoundedImageResize.ts index 925bd539fdfa..3fe51435766c 100644 --- a/src/libs/getBoundedImageResize.ts +++ b/src/libs/getBoundedImageResize.ts @@ -5,34 +5,43 @@ import CONST from '@src/CONST'; type BoundedResize = {width: number} | {height: number}; /** - * Returns a resize action that bounds the image's longest side to `CONST.MAX_IMAGE_DIMENSION`, - * or `undefined` when the image already fits within that budget (so we never upscale). + * 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. * - * This is meant to be applied before `ImageManipulator`'s `renderAsync()`, which otherwise - * materializes the full-resolution source 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 (e.g. 48MP HEIC) decode is exactly the allocation that triggers the - * `std::bad_alloc` OOM crash. Capping the decode dimensions keeps the bitmap within the pixel - * budget the app already defines elsewhere. - * - * Resizing with only one side preserves the aspect ratio, so bounding the longer side is enough. - * If the source dimensions can't be read we fall back to no resize rather than blocking the decode. + * 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 { - // The OOM is specific to iOS HybridApp's shared jetsam budget, so we only cap the decode there - // and leave other platforms untouched (per the issue owner's guidance on #93846). if (Platform.OS !== 'ios') { return Promise.resolve(undefined); } return ImageSize.getSize(uri) - .then(({width, height}): BoundedResize | 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}; - }) + .then(({width, height}) => getBoundedResizeForDimensions(width, height)) .catch(() => undefined); } +export {getBoundedResizeForDimensions}; export default getBoundedImageResize; diff --git a/tests/unit/getBoundedImageResizeTest.ts b/tests/unit/getBoundedImageResizeTest.ts index 9c8cd037cf1d..a1e3fc7e76ff 100644 --- a/tests/unit/getBoundedImageResizeTest.ts +++ b/tests/unit/getBoundedImageResizeTest.ts @@ -1,6 +1,6 @@ import {Platform} from 'react-native'; import ImageSize from 'react-native-image-size'; -import getBoundedImageResize from '@libs/getBoundedImageResize'; +import getBoundedImageResize, {getBoundedResizeForDimensions} from '@libs/getBoundedImageResize'; import CONST from '@src/CONST'; jest.mock('react-native-image-size'); @@ -96,3 +96,45 @@ describe('getBoundedImageResize', () => { }); }); }); + +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}); + }); + }); +}); From 36278e81b9dec8604b3f6aeaf469e29b64066409 Mon Sep 17 00:00:00 2001 From: dilshodmackbook-sketch <279628751+dilshodmackbook-sketch@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:18:55 +0500 Subject: [PATCH 4/7] refactor: keep multi-select concurrent; sequencing moves to a follow-up PR Per reviewer request, this PR is scoped to the decode cap only so it can be validated and adopted on its own. The iOS multi-select sequencing is split out into a separate PR. The picker keeps its original concurrent processing and only gains the decode cap on the HEIC conversion. --- .../AttachmentPicker/index.native.tsx | 127 +++++++++--------- 1 file changed, 64 insertions(+), 63 deletions(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 76fcd3c3b3df..daf666155311 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -3,7 +3,7 @@ import {keepLocalCopy, pick, types} from '@react-native-documents/picker'; import {Str} from 'expensify-common'; import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; import React, {useCallback, useMemo, useRef, useState} from 'react'; -import {Alert, Platform, View} from 'react-native'; +import {Alert, View} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; import {launchImageLibrary} from 'react-native-image-picker'; import type {Asset, Callback, CameraOptions, ImageLibraryOptions, ImagePickerResponse} from 'react-native-image-picker'; @@ -215,75 +215,76 @@ function AttachmentPicker({ } const processedAssets: Asset[] = []; + let processedCount = 0; - const processAsset = async (asset: Asset): Promise => { - if (!asset.uri) { - return; + const checkAllProcessed = () => { + processedCount++; + if (processedCount === assets.length) { + resolve(processedAssets.length > 0 ? processedAssets : undefined); } + }; - if (!asset.type?.startsWith('image')) { - // Ensure the asset has proper fileName and type - processedAssets.push(processAssetWithFallbacks(asset)); - return; - } - - try { - const isHEIC = await verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES}); - // 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) { - // Ensure the asset has proper fileName and type for non-HEIC images - processedAssets.push(processAssetWithFallbacks(asset)); - return; - } - - try { - // Bound the decode dimensions before rendering so a full-resolution HEIC bitmap can't OOM iOS (no-op off iOS). - const resize = await getBoundedImageResize(asset.uri); - const imageManipulatorContext = ImageManipulator.manipulate(asset.uri); - if (resize) { - imageManipulatorContext.resize(resize); - } - const manipulatedImage = await imageManipulatorContext.renderAsync(); - const manipulationResult = await manipulatedImage.saveAsync({format: SaveFormat.JPEG}); - const uri = manipulationResult.uri; - const convertedAsset = { - uri, - name: uri - .substring(uri.lastIndexOf('/') + 1) - .split('?') - .at(0), - type: 'image/jpeg', - width: manipulationResult.width, - height: manipulationResult.height, - }; - processedAssets.push(convertedAsset); - } catch (error) { - Log.warn('Failed to convert HEIC image, falling back to original', {error: error instanceof Error ? error.message : String(error)}); - processedAssets.push(processAssetWithFallbacks(asset)); - } - } catch (error) { - showGeneralAlert(error instanceof Error ? error.message : 'An unknown error occurred'); + for (const asset of assets) { + if (!asset.uri) { + checkAllProcessed(); + continue; } - }; - // Each HEIC asset is decoded to a full native bitmap during conversion. On iOS HybridApp the - // process shares a single jetsam budget with OldDot, so converting the whole selection at once can - // allocate N large bitmaps simultaneously and OOM. There we process assets one at a time to keep at - // most one heavy decode alive; other platforms keep the original concurrent behavior (per #93846). - const processAssets = async () => { - if (Platform.OS === 'ios') { - for (const asset of assets) { - // eslint-disable-next-line no-await-in-loop - await processAsset(asset); - } + if (asset.type?.startsWith('image')) { + verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES}) + .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) { + 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; + const convertedAsset = { + uri, + name: uri + .substring(uri.lastIndexOf('/') + 1) + .split('?') + .at(0), + type: 'image/jpeg', + width: manipulationResult.width, + height: manipulationResult.height, + }; + processedAssets.push(convertedAsset); + checkAllProcessed(); + }) + .catch((error: Error) => { + Log.warn('Failed to convert HEIC image, falling back to original', {error: error.message}); + const fallbackAsset = processAssetWithFallbacks(asset); + processedAssets.push(fallbackAsset); + checkAllProcessed(); + }); + } else { + // Ensure the asset has proper fileName and type for non-HEIC images + const processedAsset = processAssetWithFallbacks(asset); + processedAssets.push(processedAsset); + checkAllProcessed(); + } + }) + .catch((error: Error) => { + showGeneralAlert(error.message ?? 'An unknown error occurred'); + checkAllProcessed(); + }); } else { - await Promise.all(assets.map(processAsset)); + // Ensure the asset has proper fileName and type + const processedAsset = processAssetWithFallbacks(asset); + processedAssets.push(processedAsset); + checkAllProcessed(); } - - resolve(processedAssets.length > 0 ? processedAssets : undefined); - }; - - processAssets().catch(reject); + } }); }), [fileLimit, showGeneralAlert, translate, type], From 36bd21777afd445d7554f6d483195be308fc7f02 Mon Sep 17 00:00:00 2001 From: dilshodmackbook-sketch <279628751+dilshodmackbook-sketch@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:22:29 +0500 Subject: [PATCH 5/7] test: reword comment to satisfy cspell (avoid 'upscales') --- tests/unit/getBoundedImageResizeTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/getBoundedImageResizeTest.ts b/tests/unit/getBoundedImageResizeTest.ts index a1e3fc7e76ff..0d6140041def 100644 --- a/tests/unit/getBoundedImageResizeTest.ts +++ b/tests/unit/getBoundedImageResizeTest.ts @@ -47,7 +47,7 @@ describe('getBoundedImageResize', () => { platformReplaceProperty.restore(); }); - it('returns undefined when the image already fits within the budget (never upscales)', async () => { + 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'); From 9d1e819f75f252ee6cb0a01ee7b06c56ee294efa Mon Sep 17 00:00:00 2001 From: dilshodmackbook-sketch <279628751+dilshodmackbook-sketch@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:37:41 +0500 Subject: [PATCH 6/7] test: cover the decode cap in heicConverter and cropOrRotateImage Mocks expo-image-manipulator/react-native-image-size to verify a large source is downscaled to CONST.MAX_IMAGE_DIMENSION before renderAsync (and small ones are left alone), that cropOrRotateImage still applies the crop while bounding an oversized crop, and that a rotate-only image is bounded using the rotated dimensions. Covers the new native lines flagged by Codecov. --- tests/unit/cropOrRotateImageTest.ts | 74 +++++++++++++++++++++++++++++ tests/unit/heicConverterTest.ts | 67 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 tests/unit/cropOrRotateImageTest.ts create mode 100644 tests/unit/heicConverterTest.ts diff --git a/tests/unit/cropOrRotateImageTest.ts b/tests/unit/cropOrRotateImageTest.ts new file mode 100644 index 000000000000..10fe151a44dd --- /dev/null +++ b/tests/unit/cropOrRotateImageTest.ts @@ -0,0 +1,74 @@ +import {ImageManipulator} from 'expo-image-manipulator'; +import {Platform} from 'react-native'; +import ImageSize from 'react-native-image-size'; +import cropOrRotateImage from '@libs/cropOrRotateImage/index.native'; +import CONST from '@src/CONST'; + +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/heicConverterTest.ts b/tests/unit/heicConverterTest.ts new file mode 100644 index 000000000000..f68da049c41d --- /dev/null +++ b/tests/unit/heicConverterTest.ts @@ -0,0 +1,67 @@ +import {ImageManipulator} from 'expo-image-manipulator'; +import {Platform} from 'react-native'; +import ImageSize from 'react-native-image-size'; +import convertHeicImage from '@libs/fileDownload/heicConverter/index.native'; +import CONST from '@src/CONST'; +import type {FileObject} from '@src/types/utils/Attachment'; + +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(); + }); +}); From 911fa12faf092425ef15b1f24923a8c7d95efe3a Mon Sep 17 00:00:00 2001 From: dilshodmackbook-sketch <279628751+dilshodmackbook-sketch@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:05:27 +0500 Subject: [PATCH 7/7] style: run oxfmt 0.55.0 on cropOrRotate/getBoundedImageResize files CI's Oxfmt check runs oxfmt 0.55.0; these files were committed with an older oxfmt version. Reformat to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/libs/cropOrRotateImage/index.native.ts | 9 ++++++--- src/libs/getBoundedImageResize.ts | 3 ++- tests/unit/cropOrRotateImageTest.ts | 6 ++++-- tests/unit/getBoundedImageResizeTest.ts | 6 ++++-- tests/unit/heicConverterTest.ts | 8 +++++--- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/libs/cropOrRotateImage/index.native.ts b/src/libs/cropOrRotateImage/index.native.ts index be3490d8bb1f..2930e9530402 100644 --- a/src/libs/cropOrRotateImage/index.native.ts +++ b/src/libs/cropOrRotateImage/index.native.ts @@ -1,12 +1,15 @@ +import {getBoundedResizeForDimensions} from '@libs/getBoundedImageResize'; +import Log from '@libs/Log'; + import {ImageManipulator} from 'expo-image-manipulator'; import {Platform} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; import ImageSize from 'react-native-image-size'; -import {getBoundedResizeForDimensions} from '@libs/getBoundedImageResize'; -import Log from '@libs/Log'; -import getSaveFormat from './getSaveFormat'; + import type {CropOrRotateImage} from './types'; +import getSaveFormat from './getSaveFormat'; + /** * Crops and rotates the image on ios/android. * On iOS, falls back to the original unprocessed image if manipulation fails diff --git a/src/libs/getBoundedImageResize.ts b/src/libs/getBoundedImageResize.ts index 3fe51435766c..032696a0a234 100644 --- a/src/libs/getBoundedImageResize.ts +++ b/src/libs/getBoundedImageResize.ts @@ -1,6 +1,7 @@ +import CONST from '@src/CONST'; + import {Platform} from 'react-native'; import ImageSize from 'react-native-image-size'; -import CONST from '@src/CONST'; type BoundedResize = {width: number} | {height: number}; diff --git a/tests/unit/cropOrRotateImageTest.ts b/tests/unit/cropOrRotateImageTest.ts index 10fe151a44dd..9277ab063059 100644 --- a/tests/unit/cropOrRotateImageTest.ts +++ b/tests/unit/cropOrRotateImageTest.ts @@ -1,8 +1,10 @@ +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'; -import cropOrRotateImage from '@libs/cropOrRotateImage/index.native'; -import CONST from '@src/CONST'; jest.mock('react-native-image-size'); jest.mock('react-native-blob-util', () => ({ diff --git a/tests/unit/getBoundedImageResizeTest.ts b/tests/unit/getBoundedImageResizeTest.ts index 0d6140041def..63fb1299938f 100644 --- a/tests/unit/getBoundedImageResizeTest.ts +++ b/tests/unit/getBoundedImageResizeTest.ts @@ -1,8 +1,10 @@ -import {Platform} from 'react-native'; -import ImageSize from 'react-native-image-size'; 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); diff --git a/tests/unit/heicConverterTest.ts b/tests/unit/heicConverterTest.ts index f68da049c41d..f127055ac29e 100644 --- a/tests/unit/heicConverterTest.ts +++ b/tests/unit/heicConverterTest.ts @@ -1,10 +1,12 @@ -import {ImageManipulator} from 'expo-image-manipulator'; -import {Platform} from 'react-native'; -import ImageSize from 'react-native-image-size'; 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}));