Skip to content
Merged
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2817,6 +2817,7 @@ const CONST = {
FILE_TOO_LARGE: 'fileTooLarge',
FILE_TOO_SMALL: 'fileTooSmall',
FILE_CORRUPTED: 'fileCorrupted',
HEIC_CONVERSION_FAILED: 'heicConversionFailed',
PROTECTED_FILE: 'protectedFile',
HEIC_OR_HEIF_IMAGE: 'heicOrHeifImage',
IMAGE_DIMENSIONS_TOO_LARGE: 'imageDimensionsTooLarge',
Expand Down
5 changes: 2 additions & 3 deletions src/components/AttachmentPicker/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,8 @@ function AttachmentPicker({
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);
Log.warn('Failed to convert HEIC image, skipping asset', {error: error.message});
showGeneralAlert(translate('attachmentPicker.errorWhileConvertingHeic'));
checkAllProcessed();
});
} else {
Expand Down
72 changes: 36 additions & 36 deletions src/hooks/useFilesValidation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,46 +218,46 @@ function useFilesValidation(onFilesValidated: (files: FileObject[], dataTransfer

const convertedFilesToResize: FileObject[] = [];
const convertedFiles: FileObject[] = [];
await Promise.all(
filesToConvert.map(
(file) =>
new Promise<void>((resolve) => {
convertHeicImage(file, {
onSuccess: (convertedFile) => {
if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
convertedFilesToResize.push(convertedFile);
resolve();
return;
}

if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
collectedErrors.current.push({
error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
});
resolve();
return;
}

convertedFiles.push(convertedFile);
resolve();
},
onError: () => {
Log.warn('HEIC conversion failed, falling back to original file', {fileName: file.name});
convertedFiles.push(file);
resolve();
},

for (const file of filesToConvert) {
// Convert sequentially to avoid the native memory pressure caused by processing multiple HEIC images in parallel.
// eslint-disable-next-line no-await-in-loop
Comment thread
mountiny marked this conversation as resolved.
await new Promise<void>((resolve) => {
convertHeicImage(file, {
onSuccess: (convertedFile) => {
if (validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) {
convertedFilesToResize.push(convertedFile);
resolve();
return;
}

if (!validationState.isValidatingReceipts && convertedFile.size && convertedFile.size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
collectedErrors.current.push({
error: CONST.FILE_VALIDATION_ERRORS.FILE_TOO_LARGE,
isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
});
resolve();
return;
}

convertedFiles.push(convertedFile);
updateFileOrderMapping(file, convertedFile);
resolve();
},
onError: () => {
Log.warn('HEIC conversion failed, blocking file', {fileName: file.name});
collectedErrors.current.push({
error: CONST.FILE_VALIDATION_ERRORS.HEIC_CONVERSION_FAILED,
isValidatingMultipleFiles: validationState.isValidatingMultipleFiles,
});
}),
),
);
resolve();
},
});
});
}

filesToResize.push(...convertedFilesToResize);
validNonPdfFiles.push(...convertedFiles);

for (const [index, convertedFile] of convertedFiles.entries()) {
updateFileOrderMapping(filesToConvert.at(index), convertedFile);
}
}

if (filesToResize.length > 0) {
Expand Down
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: 'Anlagenfehler',
errorWhileSelectingAttachment: 'Beim Auswählen eines Anhangs ist ein Fehler aufgetreten. Bitte versuche es erneut.',
errorWhileSelectingCorruptedAttachment: 'Beim Auswählen eines beschädigten Anhangs ist ein Fehler aufgetreten. Bitte versuchen Sie es mit einer anderen Datei.',
errorWhileConvertingHeic: 'Wir konnten dieses Bild nicht verarbeiten. Bitte versuchen Sie es erneut oder laden Sie das Foto in einem anderen Format hoch.',
takePhoto: 'Foto aufnehmen',
chooseFromGallery: 'Aus Galerie wählen',
chooseDocument: 'Datei auswählen',
Expand Down
1 change: 1 addition & 0 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: 'Σφάλμα συνημμένου',
errorWhileSelectingAttachment: 'Παρουσιάστηκε σφάλμα κατά την επιλογή συνημμένου. Παρακαλούμε δοκιμάστε ξανά.',
errorWhileSelectingCorruptedAttachment: 'Προέκυψε σφάλμα κατά την επιλογή κατεστραμμένου συνημμένου. Παρακαλούμε δοκιμάστε άλλο αρχείο.',
errorWhileConvertingHeic: 'Δεν μπορέσαμε να επεξεργαστούμε αυτήν την εικόνα. Δοκιμάστε ξανά ή ανεβάστε τη φωτογραφία σε διαφορετική μορφή.',
takePhoto: 'Βγάλτε φωτογραφία',
chooseFromGallery: 'Επιλογή από τη συλλογή',
chooseDocument: 'Επιλέξτε αρχείο',
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@ const translations = {
attachmentError: 'Attachment error',
errorWhileSelectingAttachment: 'An error occurred while selecting an attachment. Please try again.',
errorWhileSelectingCorruptedAttachment: 'An error occurred while selecting a corrupted attachment. Please try another file.',
errorWhileConvertingHeic: "We couldn't process this image. Please try again, or upload the photo in a different format.",
takePhoto: 'Take photo',
chooseFromGallery: 'Choose from gallery',
chooseDocument: 'Choose file',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: 'Error al adjuntar archivo',
errorWhileSelectingAttachment: 'Se ha producido un error al seleccionar un archivo adjunto. Por favor, inténtalo de nuevo.',
errorWhileSelectingCorruptedAttachment: 'Se ha producido un error al seleccionar un archivo adjunto corrupto. Por favor, inténtalo con otro archivo.',
errorWhileConvertingHeic: 'No hemos podido procesar esta imagen. Inténtalo de nuevo o sube la foto en un formato diferente.',
takePhoto: 'Hacer una foto',
chooseFromGallery: 'Elegir de la galería',
chooseDocument: 'Elegir un archivo',
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: 'Erreur de pièce jointe',
errorWhileSelectingAttachment: 'Une erreur s’est produite lors de la sélection d’une pièce jointe. Veuillez réessayer.',
errorWhileSelectingCorruptedAttachment: 'Une erreur s’est produite lors de la sélection d’une pièce jointe corrompue. Veuillez essayer un autre fichier.',
errorWhileConvertingHeic: 'Nous n’avons pas pu traiter cette image. Veuillez réessayer ou téléverser la photo dans un format différent.',
takePhoto: 'Prendre une photo',
chooseFromGallery: 'Choisir depuis la galerie',
chooseDocument: 'Choisir un fichier',
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: 'Errore allegato',
errorWhileSelectingAttachment: 'Si è verificato un errore durante la selezione di un allegato. Riprova.',
errorWhileSelectingCorruptedAttachment: 'Si è verificato un errore durante la selezione di un allegato danneggiato. Riprova con un altro file.',
errorWhileConvertingHeic: 'Non è stato possibile elaborare questa immagine. Riprova o carica la foto in un altro formato.',
takePhoto: 'Scatta foto',
chooseFromGallery: 'Scegli dalla galleria',
chooseDocument: 'Scegli file',
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: '添付ファイルエラー',
errorWhileSelectingAttachment: '添付ファイルの選択中にエラーが発生しました。もう一度お試しください。',
errorWhileSelectingCorruptedAttachment: '破損した添付ファイルを選択した際にエラーが発生しました。別のファイルをお試しください。',
errorWhileConvertingHeic: 'この画像を処理できませんでした。もう一度お試しいただくか、別の形式で写真をアップロードしてください。',
takePhoto: '写真を撮る',
chooseFromGallery: 'ギャラリーから選択',
chooseDocument: 'ファイルを選択',
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: 'Bijlagefout',
errorWhileSelectingAttachment: 'Er is een fout opgetreden bij het selecteren van een bijlage. Probeer het opnieuw.',
errorWhileSelectingCorruptedAttachment: 'Er is een fout opgetreden bij het selecteren van een beschadigde bijlage. Probeer een ander bestand.',
errorWhileConvertingHeic: 'We konden deze afbeelding niet verwerken. Probeer het opnieuw of upload de foto in een ander formaat.',
takePhoto: 'Maak foto',
chooseFromGallery: 'Kies uit galerij',
chooseDocument: 'Bestand kiezen',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: 'Błąd załącznika',
errorWhileSelectingAttachment: 'Wystąpił błąd podczas wybierania załącznika. Spróbuj ponownie.',
errorWhileSelectingCorruptedAttachment: 'Wystąpił błąd podczas wybierania uszkodzonego załącznika. Spróbuj użyć innego pliku.',
errorWhileConvertingHeic: 'Nie udało nam się przetworzyć tego obrazu. Spróbuj ponownie lub prześlij zdjęcie w innym formacie.',
takePhoto: 'Zrób zdjęcie',
chooseFromGallery: 'Wybierz z galerii',
chooseDocument: 'Wybierz plik',
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: 'Erro de anexo',
errorWhileSelectingAttachment: 'Ocorreu um erro ao selecionar um anexo. Tente novamente.',
errorWhileSelectingCorruptedAttachment: 'Ocorreu um erro ao selecionar um anexo corrompido. Tente outro arquivo.',
errorWhileConvertingHeic: 'Não foi possível processar esta imagem. Tente novamente ou envie a foto em um formato diferente.',
takePhoto: 'Tirar foto',
chooseFromGallery: 'Escolher da galeria',
chooseDocument: 'Escolher arquivo',
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ const translations: TranslationDeepObject<typeof en> = {
attachmentError: '附件错误',
errorWhileSelectingAttachment: '选择附件时发生错误。请重试。',
errorWhileSelectingCorruptedAttachment: '选择已损坏的附件时发生错误。请尝试另一个文件。',
errorWhileConvertingHeic: '我们无法处理此图片。请重试,或以其他格式上传照片。',
takePhoto: '拍照',
chooseFromGallery: '从相册中选择',
chooseDocument: '选择文件',
Expand Down
5 changes: 5 additions & 0 deletions src/libs/fileDownload/FileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,11 @@ const getFileValidationErrorText = (
title: translate('attachmentPicker.attachmentError'),
reason: translate('attachmentPicker.errorWhileSelectingCorruptedAttachment'),
};
case CONST.FILE_VALIDATION_ERRORS.HEIC_CONVERSION_FAILED:
return {
title: translate('attachmentPicker.attachmentError'),
reason: translate('attachmentPicker.errorWhileConvertingHeic'),
};
case CONST.FILE_VALIDATION_ERRORS.PROTECTED_FILE:
return {
title: translate('attachmentPicker.attachmentError'),
Expand Down
50 changes: 5 additions & 45 deletions src/libs/fileDownload/heicConverter/index.native.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
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 {ImageManipulator, SaveFormat} from 'expo-image-manipulator';
Expand Down Expand Up @@ -69,50 +67,12 @@ const convertHeicImage: HeicConverterFunction = (file, {onSuccess = () => {}, on

onStart();

if (!file.uri) {
onError(new Error('File URI is undefined'), file);
onFinish();
return;
}

// Conversion based on extension
if (needsConversion) {
const fileUri = file.uri;
convertImageWithManipulator(file, fileUri, /\.(heic|heif)$/i, {
onSuccess,
onError,
onFinish,
});
return;
}

// If not detected by extension, check using file signatures
verifyFileFormat({fileUri: file.uri, formatSignatures: CONST.HEIC_SIGNATURES})
.then((isHEIC) => {
if (isHEIC) {
const fileUri = file.uri;
if (!fileUri) {
onError(new Error('File URI is undefined'), file);
onFinish();
return;
}
convertImageWithManipulator(file, fileUri, /\.heic$/i, {
onSuccess,
onError,
onFinish,
});
return;
}

onSuccess(file);
})
.catch((err) => {
Log.warn('Error processing the file', {error: err instanceof Error ? err.message : String(err)});
onError(err, file);
})
.finally(() => {
onFinish();
});
convertImageWithManipulator(file, file.uri, /\.(heic|heif)$/i, {
onSuccess,
onError,
onFinish,
});
};

export default convertHeicImage;
75 changes: 75 additions & 0 deletions tests/unit/HeicConverterTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import convertHeicImage from '@libs/fileDownload/heicConverter/index.native';

const mockSaveAsync = jest.fn();
const mockRenderAsync = jest.fn();

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

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

const heicFile = {uri: 'file:///photo.heic', name: 'photo.heic', type: 'image/heic', size: 1024};

const mockRenderSuccess = () => mockRenderAsync.mockResolvedValueOnce({saveAsync: () => mockSaveAsync() as unknown});

describe('convertHeicImage (native)', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockSaveAsync.mockResolvedValue({uri: 'file:///photo.jpg', width: 100, height: 200});
});

afterEach(() => {
jest.useRealTimers();
});

it('converts a HEIC file to JPEG and reports success', async () => {
mockRenderSuccess();
const onSuccess = jest.fn();
const onError = jest.fn();
const onFinish = jest.fn();

convertHeicImage(heicFile, {onSuccess, onError, onFinish});
await jest.runAllTimersAsync();

expect(onError).not.toHaveBeenCalled();
expect(onFinish).toHaveBeenCalledTimes(1);
expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: 'file:///photo.jpg', name: 'photo.jpg', type: 'image/jpeg'}));
});

it('reports an error on conversion failure, and never falls back to the original HEIC', async () => {
mockRenderAsync.mockRejectedValueOnce(new Error('Image context has been lost'));
const onSuccess = jest.fn();
const onError = jest.fn();
const onFinish = jest.fn();

convertHeicImage(heicFile, {onSuccess, onError, onFinish});
await jest.runAllTimersAsync();

expect(mockRenderAsync).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledTimes(1);
expect(onFinish).toHaveBeenCalledTimes(1);
// The raw HEIC must never be surfaced as a success: the server rejects image/heic.
expect(onSuccess).not.toHaveBeenCalled();
});

it('passes non-HEIC files straight through without conversion', async () => {
const onSuccess = jest.fn();
const jpegFile = {uri: 'file:///photo.jpg', name: 'photo.jpg', type: 'image/jpeg', size: 1024};

convertHeicImage(jpegFile, {onSuccess});
await jest.runAllTimersAsync();

expect(mockRenderAsync).not.toHaveBeenCalled();
expect(onSuccess).toHaveBeenCalledWith(jpegFile);
});
});
Loading