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
46 changes: 46 additions & 0 deletions src/libs/fileDownload/FileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,51 @@ const getConfirmModalPrompt = (attachmentInvalidReason: TranslationPaths | undef
return translateLocal(attachmentInvalidReason);
};

const MAX_CANVAS_SIZE = 4096;
const JPEG_QUALITY = 0.85;

/**
* Canvas fallback for converting HEIC to JPEG in web browsers
*/
const canvasFallback = (blob: Blob, fileName: string): Promise<File> => {
if (typeof createImageBitmap === 'undefined') {
return Promise.reject(new Error('Canvas fallback not supported in this browser'));
}

return createImageBitmap(blob).then((imageBitmap) => {
const canvas = document.createElement('canvas');

const scale = Math.min(1, MAX_CANVAS_SIZE / Math.max(imageBitmap.width, imageBitmap.height));

canvas.width = Math.floor(imageBitmap.width * scale);
canvas.height = Math.floor(imageBitmap.height * scale);

const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get canvas context');
}

ctx.drawImage(imageBitmap, 0, 0, canvas.width, canvas.height);

return new Promise<File>((resolve, reject) => {
canvas.toBlob(
(convertedBlob) => {
if (!convertedBlob) {
reject(new Error('Canvas conversion failed - returned null blob'));
return;
}

const jpegFileName = fileName.replace(/\.(heic|heif)$/i, '.jpg');
const jpegFile = Object.assign(new File([convertedBlob], jpegFileName, {type: CONST.IMAGE_FILE_FORMAT.JPEG}), {uri: URL.createObjectURL(convertedBlob)});
resolve(jpegFile);
},
CONST.IMAGE_FILE_FORMAT.JPEG,
JPEG_QUALITY,
);
});
});
};

export {
showGeneralErrorAlert,
showSuccessAlert,
Expand Down Expand Up @@ -682,4 +727,5 @@ export {
getFileValidationErrorText,
hasHeicOrHeifExtension,
getConfirmModalPrompt,
canvasFallback,
};
47 changes: 1 addition & 46 deletions src/libs/fileDownload/heicConverter/index.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,13 @@
import {hasHeicOrHeifExtension} from '@libs/fileDownload/FileUtils';
import {canvasFallback, hasHeicOrHeifExtension} from '@libs/fileDownload/FileUtils';
import type {FileObject} from '@pages/media/AttachmentModalScreen/types';
import CONST from '@src/CONST';
import type {HeicConverterFunction} from './types';

const MAX_CANVAS_SIZE = 4096;
const JPEG_QUALITY = 0.85;

type HeicConverter = {
heicTo: (options: {blob: Blob; type: string}) => Promise<Blob>;
isHeic: (file: File) => Promise<boolean>;
};

/**
* Canvas fallback for converting HEIC to JPEG in web browsers
*/
const canvasFallback = (blob: Blob, fileName: string): Promise<File> => {
if (typeof createImageBitmap === 'undefined') {
return Promise.reject(new Error('Canvas fallback not supported in this browser'));
}

return createImageBitmap(blob).then((imageBitmap) => {
const canvas = document.createElement('canvas');

const scale = Math.min(1, MAX_CANVAS_SIZE / Math.max(imageBitmap.width, imageBitmap.height));

canvas.width = Math.floor(imageBitmap.width * scale);
canvas.height = Math.floor(imageBitmap.height * scale);

const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Could not get canvas context');
}

ctx.drawImage(imageBitmap, 0, 0, canvas.width, canvas.height);

return new Promise<File>((resolve, reject) => {
canvas.toBlob(
(convertedBlob) => {
if (!convertedBlob) {
reject(new Error('Canvas conversion failed - returned null blob'));
return;
}

const jpegFileName = fileName.replace(/\.(heic|heif)$/i, '.jpg');
const jpegFile = Object.assign(new File([convertedBlob], jpegFileName, {type: CONST.IMAGE_FILE_FORMAT.JPEG}), {uri: URL.createObjectURL(convertedBlob)});
resolve(jpegFile);
},
CONST.IMAGE_FILE_FORMAT.JPEG,
JPEG_QUALITY,
);
});
});
};

const getHeicConverter = () => {
// Use the CSP variant to ensure the library is loaded in a secure context. See https://github.com/hoppergee/heic-to?tab=readme-ov-file#cotent-security-policy
// Use webpackMode: "eager" to ensure the library is loaded immediately without evaluating the code. See https://github.com/Expensify/App/pull/68727#issuecomment-3227196372
Expand Down
98 changes: 98 additions & 0 deletions tests/unit/FileUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,102 @@ describe('FileUtils', () => {
expect(error).toBe('');
});
});

describe('canvasFallback', () => {
const mockCreateImageBitmap = jest.fn();
const mockCanvas = {
width: 0,
height: 0,
getContext: jest.fn(),
toBlob: jest.fn(),
};
const mockCtx = {
drawImage: jest.fn(),
};
const mockCreateElement = jest.fn();
const mockURL = {
createObjectURL: jest.fn(() => 'blob:mock-url'),
};

beforeEach(() => {
jest.clearAllMocks();

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(global as any).createImageBitmap = mockCreateImageBitmap;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(global as any).document = {
createElement: mockCreateElement,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(global as any).URL = mockURL;
Comment on lines +102 to +111
Copy link

Copilot AI Sep 23, 2025

Choose a reason for hiding this comment

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

[nitpick] The repeated eslint-disable comments create visual clutter. Consider extracting the global object assignments to helper functions or using a more concise approach to reduce the duplication of these long disable comments.

Copilot uses AI. Check for mistakes.

mockCreateElement.mockReturnValue(mockCanvas);
mockCanvas.getContext.mockReturnValue(mockCtx);
mockCreateImageBitmap.mockResolvedValue({
width: 1000,
height: 800,
close: jest.fn(),
});
});

afterEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
delete (global as any).createImageBitmap;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
delete (global as any).document;
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
delete (global as any).URL;
});

it('should reject when createImageBitmap is undefined', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
delete (global as any).createImageBitmap;

const blob = new Blob(['test'], {type: 'image/heic'});

await expect(FileUtils.canvasFallback(blob, 'test.heic')).rejects.toThrow('Canvas fallback not supported in this browser');
});

it('should successfully convert HEIC to JPEG', async () => {
const blob = new Blob(['test'], {type: 'image/heic'});
const mockBlob = new Blob(['converted'], {type: 'image/jpeg'});
mockCanvas.toBlob.mockImplementation((callback: (blob: Blob | null) => void) => callback(mockBlob));

const result = await FileUtils.canvasFallback(blob, 'expense.heic');

expect(result).toBeInstanceOf(File);
expect(result.type).toBe(CONST.IMAGE_FILE_FORMAT.JPEG);
expect(result.name).toBe('expense.jpg');
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
expect((result as any).uri).toBe('blob:mock-url');
Comment on lines +150 to +151
Copy link

Copilot AI Sep 23, 2025

Choose a reason for hiding this comment

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

[nitpick] Casting to any to access the uri property suggests a type definition issue. Consider creating a proper type interface that extends File with the uri property, or use a more type-safe assertion method.

Copilot uses AI. Check for mistakes.
});

it('should scale down large images', async () => {
const blob = new Blob(['test'], {type: 'image/heic'});
const mockImageBitmap = {width: 8192, height: 4000, close: jest.fn()};
mockCreateImageBitmap.mockResolvedValue(mockImageBitmap);

const mockBlob = new Blob(['converted'], {type: 'image/jpeg'});
mockCanvas.toBlob.mockImplementation((callback: (blob: Blob | null) => void) => callback(mockBlob));

await FileUtils.canvasFallback(blob, 'test.heic');

expect(mockCanvas.width).toBe(4096);
expect(mockCanvas.height).toBe(2000);
});

it('should reject when canvas context is null', async () => {
const blob = new Blob(['test'], {type: 'image/heic'});
mockCanvas.getContext.mockReturnValue(null);

await expect(FileUtils.canvasFallback(blob, 'test.heic')).rejects.toThrow('Could not get canvas context');
});

it('should reject when toBlob returns null', async () => {
const blob = new Blob(['test'], {type: 'image/heic'});
mockCanvas.toBlob.mockImplementation((callback: (blob: Blob | null) => void) => callback(null));

await expect(FileUtils.canvasFallback(blob, 'test.heic')).rejects.toThrow('Canvas conversion failed - returned null blob');
});
});
});
Loading