Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/components/AttachmentPicker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 6 additions & 5 deletions src/hooks/useFilesValidation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,13 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer
});
};

const convertHeicImageToJpegPromise = (file: FileObject): Promise<FileObject> => {
return new Promise((resolve, reject) => {
const convertHeicToProcessableImagePromise = (file: FileObject): Promise<FileObject> => {
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);
},
});
});
Expand Down Expand Up @@ -218,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);
}
Expand Down
87 changes: 73 additions & 14 deletions src/libs/cropOrRotateImage/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,85 @@
import {manipulateAsync} from 'expo-image-manipulator';
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
* Crops and rotates the image on ios/android.
* 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) => {
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',
});
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://', ''))
.then(({size}) => {
resolve({
...result,
size,
type: options.type || 'image/jpeg',
name: options.name || 'fileName.jpg',
});
})
.catch(reject);
})
.catch((error) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Cross-Platform Compatibility & Android Side-Effects - Unintended Fallback on Android

Issue: As noted in the issue affected devices and PR description - this logic fix targets an issue existent on iOS mWeb / Native. However, we modified .native.ts files, which executes on both Android and iOS.

Why it matters: If an Android device fails to manipulate an image (e.g., encountering an OutOfMemoryError typical of lower-end Android devices loading large bit-maps), this .catch() block will now swallow the error and pass the massive original image forward.

Uploading heavily uncompressed local images on Android will cause immediate "Network Request Failed" errors due to payload size limits, or further OOM crashes in the network layer.

Suggested Fix: Isolate this fallback logic platform-specifically, using platform specific files as per our guidelines. Pseudocode:

import {Platform} from 'react-native';

// Inside the catch block...
.catch((error) => {
    if (Platform.OS === 'android') {
        Log.warn('Image manipulation failed on Android', {error: error instanceof Error ? error.message : String(error)});
        // Allow Android to fail fast and maintain its current lifecycle
        return reject(error);
    }
    
    // Proceed with iOS fallback logic...
})

if (Platform.OS !== 'ios') {
reject(error);
return;
}
Comment on lines +44 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you split this up to ios and android files to remove this check?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should I create new PR with that quick fix?


Log.warn('Error cropping/rotating image, falling back to original', {error: error instanceof Error ? error.message : String(error)});
const filePath = uri.replace('file://', '');
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(() => {
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;
35 changes: 24 additions & 11 deletions src/libs/cropOrRotateImage/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
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) => {
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);
});
});
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())
.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;
5 changes: 3 additions & 2 deletions src/libs/fileDownload/heicConverter/index.native.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading