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
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.
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