From 99faf34f9ee5d1da5505532445b34c49f20878b5 Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Fri, 13 Mar 2026 13:01:23 +0100 Subject: [PATCH 1/4] fix silent promise hang and raw error on heic conversion failure --- .../AttachmentPicker/index.native.tsx | 5 +++- src/hooks/useFilesValidation.tsx | 7 +++--- src/libs/cropOrRotateImage/index.ts | 23 +++++++++++-------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index f0e618a661246..076b44e5feb82 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -19,6 +19,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {cleanFileName, resizeImageIfNeeded, showCameraPermissionsAlert, verifyFileFormat} from '@libs/fileDownload/FileUtils'; +import Log from '@libs/Log'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {FileObject, ImagePickerResponse as FileResponse} from '@src/types/utils/Attachment'; @@ -219,7 +220,9 @@ function AttachmentPicker({ checkAllProcessed(); }) .catch((error: Error) => { - showGeneralAlert(error.message ?? 'An unknown error occurred'); + Log.warn('Failed to convert HEIC image, falling back to original', {error: error.message}); + const fallbackAsset = processAssetWithFallbacks(asset); + processedAssets.push(fallbackAsset); checkAllProcessed(); }); } else { diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index fc3506f970088..0520da66a0885 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -147,11 +147,12 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer }; const convertHeicImageToJpegPromise = (file: FileObject): Promise => { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { convertHeicImage(file, { onSuccess: (convertedFile) => resolve(convertedFile), - onError: (nonConvertedFile) => { - reject(nonConvertedFile); + onError: (_error, originalFile) => { + Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name}); + resolve(originalFile); }, }); }); diff --git a/src/libs/cropOrRotateImage/index.ts b/src/libs/cropOrRotateImage/index.ts index 1b669999a1eef..428a9a1a6411c 100644 --- a/src/libs/cropOrRotateImage/index.ts +++ b/src/libs/cropOrRotateImage/index.ts @@ -3,17 +3,20 @@ import getSaveFormat from './getSaveFormat'; import type {CropOrRotateImage} from './types'; const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) => - new Promise((resolve) => { + new Promise((resolve, reject) => { const format = getSaveFormat(options.type); - manipulateAsync(uri, actions, {compress: options.compress, format}).then((result) => { - fetch(result.uri) - .then((res) => res.blob()) - .then((blob) => { - const file = new File([blob], options.name || 'fileName.jpeg', {type: options.type || 'image/jpeg'}); - file.uri = URL.createObjectURL(file); - resolve(file); - }); - }); + manipulateAsync(uri, actions, {compress: options.compress, format}) + .then((result) => + fetch(result.uri) + .then((res) => res.blob()) + .then((blob) => { + const file = new File([blob], options.name || 'fileName.jpeg', {type: options.type || 'image/jpeg'}); + file.uri = URL.createObjectURL(file); + resolve(file); + }) + .catch(reject), + ) + .catch(reject); }); export default cropOrRotateImage; From 55abec5024849427a9056a37f110bac5bb9d898e Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Fri, 13 Mar 2026 13:02:12 +0100 Subject: [PATCH 2/4] cover heicConverter and useFilesValidation for 48MP heic failure --- src/libs/cropOrRotateImage/index.native.ts | 27 +++++++++++-------- .../heicConverter/index.native.ts | 5 ++-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/libs/cropOrRotateImage/index.native.ts b/src/libs/cropOrRotateImage/index.native.ts index f418d4d3358b7..b95b9d70dc9fd 100644 --- a/src/libs/cropOrRotateImage/index.native.ts +++ b/src/libs/cropOrRotateImage/index.native.ts @@ -7,20 +7,25 @@ import type {CropOrRotateImage} from './types'; * Crops and rotates the image on ios/android */ const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) => - new Promise((resolve) => { + new Promise((resolve, reject) => { const format = getSaveFormat(options.type); // 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 - manipulateAsync(uri, actions, {compress: options.compress, format}).then(({base64, ...result}) => { - RNFetchBlob.fs.stat(result.uri.replace('file://', '')).then(({size}) => { - resolve({ - ...result, - size, - type: options.type || 'image/jpeg', - name: options.name || 'fileName.jpg', - }); - }); - }); + manipulateAsync(uri, actions, {compress: options.compress, format}) + .then(({base64, ...result}) => { + RNFetchBlob.fs + .stat(result.uri.replace('file://', '')) + .then(({size}) => { + resolve({ + ...result, + size, + type: options.type || 'image/jpeg', + name: options.name || 'fileName.jpg', + }); + }) + .catch(reject); + }) + .catch(reject); }); export default cropOrRotateImage; diff --git a/src/libs/fileDownload/heicConverter/index.native.ts b/src/libs/fileDownload/heicConverter/index.native.ts index eaa6776c5f071..34a9ad3001d4f 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 Log from '@libs/Log'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; import type {HeicConverterFunction} from './types'; @@ -41,7 +42,7 @@ const convertImageWithManipulator = ( onSuccess(convertedFile); }) .catch((err) => { - console.error('Error converting HEIC/HEIF to JPEG:', err); + Log.warn('Error converting HEIC/HEIF to JPEG', {error: err instanceof Error ? err.message : String(err)}); onError(err, file); }) .finally(() => { @@ -103,7 +104,7 @@ const convertHeicImage: HeicConverterFunction = (file, {onSuccess = () => {}, on onSuccess(file); }) .catch((err) => { - console.error('Error processing the file:', err); + Log.warn('Error processing the file', {error: err instanceof Error ? err.message : String(err)}); onError(err, file); }) .finally(() => { From 2757212136c9dbb9ed3372be524ddff15de5b728 Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Mon, 16 Mar 2026 14:58:37 +0100 Subject: [PATCH 3/4] migrate to ImageManipulator to fix depracation error --- src/libs/cropOrRotateImage/index.native.ts | 41 ++++++++++++++++++---- src/libs/cropOrRotateImage/index.ts | 14 ++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/libs/cropOrRotateImage/index.native.ts b/src/libs/cropOrRotateImage/index.native.ts index b95b9d70dc9fd..25e0ceed69020 100644 --- a/src/libs/cropOrRotateImage/index.native.ts +++ b/src/libs/cropOrRotateImage/index.native.ts @@ -1,17 +1,30 @@ -import {manipulateAsync} from 'expo-image-manipulator'; +import {ImageManipulator} from 'expo-image-manipulator'; import RNFetchBlob from 'react-native-blob-util'; +import Log from '@libs/Log'; import getSaveFormat from './getSaveFormat'; import type {CropOrRotateImage} from './types'; /** - * Crops and rotates the image on ios/android + * Crops and rotates the image on ios/android. + * Falls back to the original unprocessed image if manipulation fails + * (e.g. iOS CGContext allocation failure on 48MP photos). */ const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) => new Promise((resolve, reject) => { const format = getSaveFormat(options.type); - // 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 - manipulateAsync(uri, actions, {compress: options.compress, format}) + const context = ImageManipulator.manipulate(uri); + for (const action of actions) { + if ('crop' in action) { + context.crop(action.crop); + } else if ('rotate' in action) { + context.rotate(action.rotate); + } + } + 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 .then(({base64, ...result}) => { RNFetchBlob.fs .stat(result.uri.replace('file://', '')) @@ -25,7 +38,23 @@ const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) => }) .catch(reject); }) - .catch(reject); + .catch((error) => { + Log.warn('Error cropping/rotating image, falling back to original', {error: error instanceof Error ? error.message : String(error)}); + const filePath = uri.replace('file://', ''); + RNFetchBlob.fs + .stat(filePath) + .then(({size}) => { + resolve({ + uri, + width: 0, + height: 0, + size, + type: options.type || 'image/jpeg', + name: options.name || 'fileName.jpg', + }); + }) + .catch(reject); + }); }); export default cropOrRotateImage; diff --git a/src/libs/cropOrRotateImage/index.ts b/src/libs/cropOrRotateImage/index.ts index 428a9a1a6411c..4a7a6c76b55ad 100644 --- a/src/libs/cropOrRotateImage/index.ts +++ b/src/libs/cropOrRotateImage/index.ts @@ -1,11 +1,21 @@ -import {manipulateAsync} from 'expo-image-manipulator'; +import {ImageManipulator} from 'expo-image-manipulator'; import getSaveFormat from './getSaveFormat'; import type {CropOrRotateImage} from './types'; const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) => new Promise((resolve, reject) => { const format = getSaveFormat(options.type); - manipulateAsync(uri, actions, {compress: options.compress, format}) + const context = ImageManipulator.manipulate(uri); + for (const action of actions) { + if ('crop' in action) { + context.crop(action.crop); + } else if ('rotate' in action) { + context.rotate(action.rotate); + } + } + context + .renderAsync() + .then((imageRef) => imageRef.saveAsync({compress: options.compress, format})) .then((result) => fetch(result.uri) .then((res) => res.blob()) From f635b6e37f9837e05133f4c24babc54f2b93065b Mon Sep 17 00:00:00 2001 From: Tomasz Lesniakiewicz Date: Tue, 17 Mar 2026 16:13:21 +0100 Subject: [PATCH 4/4] adjust heic conversion after pr comments --- src/hooks/useFilesValidation.tsx | 4 +- src/libs/cropOrRotateImage/index.native.ts | 53 ++++++++++++++++------ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/hooks/useFilesValidation.tsx b/src/hooks/useFilesValidation.tsx index 0520da66a0885..b55e59acc470e 100644 --- a/src/hooks/useFilesValidation.tsx +++ b/src/hooks/useFilesValidation.tsx @@ -146,7 +146,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer }); }; - const convertHeicImageToJpegPromise = (file: FileObject): Promise => { + const convertHeicToProcessableImagePromise = (file: FileObject): Promise => { return new Promise((resolve) => { convertHeicImage(file, { onSuccess: (convertedFile) => resolve(convertedFile), @@ -219,7 +219,7 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer if (otherFiles.some((file) => hasHeicOrHeifExtension(file))) { setIsLoaderVisible(true); - return Promise.all(otherFiles.map((file) => convertHeicImageToJpegPromise(file))).then((convertedImages) => { + return Promise.all(otherFiles.map((file) => convertHeicToProcessableImagePromise(file))).then((convertedImages) => { for (const [index, convertedFile] of convertedImages.entries()) { updateFileOrderMapping(otherFiles.at(index), convertedFile); } diff --git a/src/libs/cropOrRotateImage/index.native.ts b/src/libs/cropOrRotateImage/index.native.ts index 25e0ceed69020..41091c25ba048 100644 --- a/src/libs/cropOrRotateImage/index.native.ts +++ b/src/libs/cropOrRotateImage/index.native.ts @@ -1,13 +1,15 @@ 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 Log from '@libs/Log'; import getSaveFormat from './getSaveFormat'; import type {CropOrRotateImage} from './types'; /** * Crops and rotates the image on ios/android. - * Falls back to the original unprocessed image if manipulation fails - * (e.g. iOS CGContext allocation failure on 48MP photos). + * On iOS, falls back to the original unprocessed image if manipulation fails + * (e.g. CGContext allocation failure on 48MP photos). */ const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) => new Promise((resolve, reject) => { @@ -39,21 +41,44 @@ const cropOrRotateImage: CropOrRotateImage = (uri, actions, options) => .catch(reject); }) .catch((error) => { + if (Platform.OS !== 'ios') { + reject(error); + return; + } + Log.warn('Error cropping/rotating image, falling back to original', {error: error instanceof Error ? error.message : String(error)}); const filePath = uri.replace('file://', ''); - RNFetchBlob.fs - .stat(filePath) - .then(({size}) => { - resolve({ - uri, - width: 0, - height: 0, - size, - type: options.type || 'image/jpeg', - name: options.name || 'fileName.jpg', - }); + ImageSize.getSize(uri) + .then(({width, height}) => { + RNFetchBlob.fs + .stat(filePath) + .then(({size}) => { + resolve({ + uri, + width: width ?? 0, + height: height ?? 0, + size, + type: options.type || 'image/jpeg', + name: options.name || 'fileName.jpg', + }); + }) + .catch(reject); }) - .catch(reject); + .catch(() => { + RNFetchBlob.fs + .stat(filePath) + .then(({size}) => { + resolve({ + uri, + width: 0, + height: 0, + size, + type: options.type || 'image/jpeg', + name: options.name || 'fileName.jpg', + }); + }) + .catch(reject); + }); }); });