Skip to content

Commit 49acc46

Browse files
bang9claude
andcommitted
fix: preserve original HEIC/HEIF files and treat them as file type
- Prevent HEIC/HEIF to JPEG conversion in react-native-image-picker by setting quality: 1 - Detect HEIC files by reading magic bytes (ftyp box) and correct misidentified MIME type/extension - Treat HEIC/HEIF as file type instead of image (not universally supported for inline display) - Add test cases for HEIC/HEIF file type detection Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 67bec6d commit 49acc46

3 files changed

Lines changed: 64 additions & 6 deletions

File tree

packages/uikit-react-native/src/platform/createFileService.native.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,33 @@ function getAndroidStoragePermissionsByAPILevel(permissionModule: typeof Permiss
4444
];
4545
}
4646

47+
// HEIC/HEIF brands defined in ISO Base Media File Format
48+
const HEIC_BRANDS = ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1'];
49+
50+
/**
51+
* Detects if a file is HEIC/HEIF by reading its magic bytes.
52+
* HEIC/HEIF files use ISOBMFF container format with 'ftyp' box and specific brand codes.
53+
*/
54+
async function detectHeicFromUri(uri: string): Promise<boolean> {
55+
try {
56+
const response = await fetch(uri);
57+
const buffer = await response.arrayBuffer();
58+
const bytes = new Uint8Array(buffer.slice(0, 24));
59+
60+
// Check for 'ftyp' at bytes 4-7
61+
if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
62+
// Read major brand at bytes 8-11
63+
const majorBrand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]).toLowerCase();
64+
if (HEIC_BRANDS.includes(majorBrand)) {
65+
return true;
66+
}
67+
}
68+
return false;
69+
} catch {
70+
return false;
71+
}
72+
}
73+
4774
const createNativeFileService = ({
4875
imagePickerModule,
4976
documentPickerModule,
@@ -151,6 +178,11 @@ const createNativeFileService = ({
151178
const response = await imagePickerModule.launchImageLibrary({
152179
presentationStyle: 'fullScreen',
153180
selectionLimit,
181+
assetRepresentationMode: 'current',
182+
// NOTE: quality must be set to 1 to prevent HEIC/HEIF to JPEG conversion.
183+
// Without this, the native code's condition (quality >= 0 && quality < 1) becomes true
184+
// when quality is undefined (floatValue returns 0.0), triggering unwanted JPEG conversion.
185+
quality: 1,
154186
mediaType: (() => {
155187
switch (options?.mediaType) {
156188
case 'photo':
@@ -170,11 +202,28 @@ const createNativeFileService = ({
170202
return null;
171203
}
172204

173-
return Promise.all(
174-
(response.assets || [])
175-
.slice(0, selectionLimit)
176-
.map(({ fileName: name, fileSize: size, type, uri }) => normalizeFile({ uri, size, name, type })),
205+
// Correct HEIC/HEIF file extension and MIME type that react-native-image-picker misidentifies as JPEG
206+
const correctedAssets = await Promise.all(
207+
(response.assets || []).slice(0, selectionLimit).map(async (asset) => {
208+
const { fileName, fileSize: size, type: originalType, uri } = asset;
209+
let name = fileName;
210+
let type = originalType;
211+
212+
if (uri && (type === 'image/jpeg' || type === 'image/jpg' || name?.toLowerCase().endsWith('.jpg'))) {
213+
const isHeic = await detectHeicFromUri(uri);
214+
if (isHeic) {
215+
type = 'image/heic';
216+
if (name) {
217+
name = name.replace(/\.jpe?g$/i, '.heic');
218+
}
219+
}
220+
}
221+
222+
return { uri, size, name, type };
223+
}),
177224
);
225+
226+
return Promise.all(correctedAssets.map(({ uri, size, name, type }) => normalizeFile({ uri, size, name, type })));
178227
}
179228

180229
async openDocument(options?: OpenDocumentOptions): Promise<FilePickerResponse> {

packages/uikit-utils/src/__tests__/shared/file.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ describe('getFileType', function () {
2525
expect(getFileType('application/zip')).toBe('file');
2626
expect(getFileType('application/x-gzip')).toBe('file');
2727
expect(getFileType('text/plain')).toBe('file');
28+
// HEIC/HEIF should be treated as file (not universally supported for inline display)
29+
expect(getFileType('image/heic')).toBe('file');
30+
expect(getFileType('image/heif')).toBe('file');
2831
});
2932

3033
it('should return the proper file type with file extension', () => {

packages/uikit-utils/src/shared/file.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,21 @@ const EXTENSION_MIME_MAP = {
4444
export const imageExtRegex = /jpeg|jpg|png|webp|gif/i;
4545
export const audioExtRegex = /3gp|aac|aax|act|aiff|flac|gsm|m4a|m4b|m4p|tta|wma|mp3|webm|wav|ogg/i;
4646
export const videoExtRegex = /mov|vod|mp4|avi|mpeg|ogv/i;
47+
// HEIC/HEIF should be treated as file, not image (not universally supported for inline display)
48+
export const nonImageMimeSubtypes = /heic|heif/i;
4749
export const getFileType = (extensionOrType: string) => {
4850
const lowerCased = extensionOrType.toLowerCase();
4951

5052
// mime type
5153
if (lowerCased.indexOf('/') > -1) {
52-
const type = lowerCased.split('/')[0];
54+
const [type, subtype] = lowerCased.split('/');
5355
if (type === 'video') return 'video';
5456
if (type === 'audio') return 'audio';
55-
if (type === 'image') return 'image';
57+
if (type === 'image') {
58+
// HEIC/HEIF are not universally supported, treat as file
59+
if (subtype?.match(nonImageMimeSubtypes)) return 'file';
60+
return 'image';
61+
}
5662
return 'file';
5763
}
5864

0 commit comments

Comments
 (0)