From 99faf34f9ee5d1da5505532445b34c49f20878b5 Mon Sep 17 00:00:00 2001
From: Tomasz Lesniakiewicz <tlesniakiewicz@gmail.com>
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 f0e618a66124..076b44e5feb8 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 fc3506f97008..0520da66a088 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<FileObject> => {
-        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 1b669999a1ee..428a9a1a6411 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 <tlesniakiewicz@gmail.com>
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 f418d4d3358b..b95b9d70dc9f 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 eaa6776c5f07..34a9ad3001d4 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 <tlesniakiewicz@gmail.com>
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 b95b9d70dc9f..25e0ceed6902 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 428a9a1a6411..4a7a6c76b55a 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 <tlesniakiewicz@gmail.com>
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 0520da66a088..b55e59acc470 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<FileObject> => {
+    const convertHeicToProcessableImagePromise = (file: FileObject): Promise<FileObject> => {
         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 25e0ceed6902..41091c25ba04 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);
+                    });
             });
     });
 
