Skip to content
144 changes: 82 additions & 62 deletions src/components/AttachmentPicker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,85 @@ const processAssetWithFallbacks = (asset: Asset): Asset => {
};
};

const getErrorMessage = (error: unknown, fallback: string): string => (error instanceof Error && error.message ? error.message : fallback);

/**
* Convert the picked assets one at a time, transcoding any HEIC images to JPEG.
*
* The conversion is deliberately sequential: `ImageManipulator` decodes each image into a full-size
* bitmap in native memory, so converting a whole selection at once (the picker allows up to
* `CONST.API_ATTACHMENT_VALIDATIONS.MAX_FILE_LIMIT` files) holds every bitmap simultaneously and the
* OS terminates the app for exceeding its memory limit. Processing one image at a time keeps the peak
* at a single bitmap regardless of how many files were picked.
*/
const processPickedAssetsSequentially = async (assets: Asset[], showGeneralAlert: (message?: string) => void, translate: (key: TranslationPaths) => string): Promise<Asset[] | undefined> => {
const processedAssets: Asset[] = [];
// Collected instead of alerted inline: a whole selection can fail the same way, and one native
// alert per file would leave the user dismissing a stack of identical modals.
const failureMessages = new Set<string>();

for (const asset of assets) {
if (!asset.uri) {
continue;
}

if (!asset.type?.startsWith('image')) {
// Ensure the asset has proper fileName and type
processedAssets.push(processAssetWithFallbacks(asset));
continue;
}

try {
// eslint-disable-next-line no-await-in-loop -- converting one image at a time is the point, see the doc comment above
const isHEIC = await verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES});

if (!isHEIC) {
// Ensure the asset has proper fileName and type for non-HEIC images
processedAssets.push(processAssetWithFallbacks(asset));
continue;
}

// react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature
const imageManipulatorContext = ImageManipulator.manipulate(asset.uri);
try {
// eslint-disable-next-line no-await-in-loop -- converting one image at a time is the point, see the doc comment above
const manipulatedImage = await imageManipulatorContext.renderAsync();
try {
// eslint-disable-next-line no-await-in-loop -- converting one image at a time is the point, see the doc comment above
const manipulationResult = await manipulatedImage.saveAsync({format: SaveFormat.JPEG});
const uri = manipulationResult.uri;
const convertedAsset: Asset = {
uri,
fileName: uri
.substring(uri.lastIndexOf('/') + 1)
.split('?')
.at(0),
type: 'image/jpeg',
width: manipulationResult.width,
height: manipulationResult.height,
};
processedAssets.push(convertedAsset);
} finally {
manipulatedImage.release();
}
} catch (error) {
Log.warn('Failed to convert HEIC image, skipping asset', {error: getErrorMessage(error, 'An unknown error occurred')});
failureMessages.add(translate('attachmentPicker.errorWhileConvertingHeic'));
} finally {
imageManipulatorContext.release();
}
} catch (error) {
failureMessages.add(getErrorMessage(error, translate('attachmentPicker.errorWhileSelectingAttachment')));
}
}

for (const message of failureMessages) {
showGeneralAlert(message);
}

return processedAssets.length > 0 ? processedAssets : undefined;
};

/**
* Return imagePickerOptions based on the type
*/
Expand Down Expand Up @@ -223,68 +302,7 @@ function AttachmentPicker({
return resolve();
}

const processedAssets: Asset[] = [];
let processedCount = 0;

const checkAllProcessed = () => {
processedCount++;
if (processedCount === assets.length) {
resolve(processedAssets.length > 0 ? processedAssets : undefined);
}
};

for (const asset of assets) {
if (!asset.uri) {
checkAllProcessed();
continue;
}

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, skipping asset', {error: error.message});
showGeneralAlert(translate('attachmentPicker.errorWhileConvertingHeic'));
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 {
// Ensure the asset has proper fileName and type
const processedAsset = processAssetWithFallbacks(asset);
processedAssets.push(processedAsset);
checkAllProcessed();
}
}
processPickedAssetsSequentially(assets, showGeneralAlert, translate).then(resolve).catch(reject);
});
}),
[fileLimit, showGeneralAlert, translate, type],
Expand Down Expand Up @@ -605,3 +623,5 @@ function AttachmentPicker({
}

export default AttachmentPicker;
// Exported for tests, which assert that only one image is decoded at a time.
export {processPickedAssetsSequentially};
126 changes: 126 additions & 0 deletions tests/unit/AttachmentPickerAssetProcessingTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {processPickedAssetsSequentially} from '@components/AttachmentPicker/index.native';

import type {Asset} from 'react-native-image-picker';

const mockVerifyFileFormat = jest.fn();
const mockRenderAsync = jest.fn();
const mockSaveAsync = jest.fn();
const mockRelease = jest.fn();
const mockImageRelease = jest.fn();

jest.mock('@libs/fileDownload/FileUtils', () => ({
cleanFileName: (name: string) => name,
showCameraPermissionsAlert: jest.fn(),
verifyFileFormat: () => mockVerifyFileFormat() as unknown,
}));

jest.mock('expo-image-manipulator', () => ({
ImageManipulator: {
manipulate: () => ({
renderAsync: () => mockRenderAsync() as unknown,
release: () => {
mockRelease();
},
}),
},
SaveFormat: {JPEG: 'jpeg'},
}));

jest.mock('@libs/Log', () => ({
info: jest.fn(),
warn: jest.fn(),
}));

const buildHeicAssets = (count: number): Asset[] =>
Array.from({length: count}, (value, index) => ({
uri: `file:///photo-${index}.heic`,
fileName: `photo-${index}.heic`,
type: 'image/heic',
}));

const showGeneralAlert = jest.fn();
const translate = jest.fn(() => 'conversion failed');

describe('processPickedAssetsSequentially', () => {
beforeEach(() => {
jest.clearAllMocks();
mockVerifyFileFormat.mockResolvedValue(true);
mockSaveAsync.mockResolvedValue({uri: 'file:///photo.jpg', width: 100, height: 200});
mockRenderAsync.mockImplementation(() =>
Promise.resolve({
saveAsync: () => mockSaveAsync() as unknown,
release: () => {
mockImageRelease();
},
}),
);
});

it('decodes only one image at a time', async () => {
let inFlight = 0;
let peakInFlight = 0;

// Hold each decode open long enough that any overlap would be observable in `peakInFlight`.
mockRenderAsync.mockImplementation(() => {
inFlight++;
peakInFlight = Math.max(peakInFlight, inFlight);
return new Promise((resolve) => {
setImmediate(() => {
inFlight--;
resolve({
saveAsync: () => mockSaveAsync() as unknown,
release: () => {
mockImageRelease();
},
});
});
});
});

const result = await processPickedAssetsSequentially(buildHeicAssets(30), showGeneralAlert, translate);

expect(peakInFlight).toBe(1);
expect(mockRenderAsync).toHaveBeenCalledTimes(30);
expect(result).toHaveLength(30);
});

it('releases the native image resources for every converted asset', async () => {
await processPickedAssetsSequentially(buildHeicAssets(5), showGeneralAlert, translate);

expect(mockRelease).toHaveBeenCalledTimes(5);
expect(mockImageRelease).toHaveBeenCalledTimes(5);
});

it('releases the manipulator context even when the conversion fails', async () => {
mockRenderAsync.mockRejectedValue(new Error('decode failed'));

await processPickedAssetsSequentially(buildHeicAssets(3), showGeneralAlert, translate);

expect(mockRelease).toHaveBeenCalledTimes(3);
});

it('skips assets that fail to convert instead of uploading the raw HEIC', async () => {
mockRenderAsync.mockRejectedValue(new Error('decode failed'));

const result = await processPickedAssetsSequentially(buildHeicAssets(3), showGeneralAlert, translate);

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

it('shows a single alert when the whole selection fails the same way', async () => {
mockRenderAsync.mockRejectedValue(new Error('decode failed'));

await processPickedAssetsSequentially(buildHeicAssets(30), showGeneralAlert, translate);

expect(showGeneralAlert).toHaveBeenCalledTimes(1);
});

it('passes non-HEIC images through without decoding them', async () => {
mockVerifyFileFormat.mockResolvedValue(false);

const result = await processPickedAssetsSequentially([{uri: 'file:///photo.jpg', fileName: 'photo.jpg', type: 'image/jpeg'}], showGeneralAlert, translate);

expect(mockRenderAsync).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
});
});
Loading