Normalize HEIC image attachments on the client (#934)

* Normalize HEIC attachments before persistence

* Preserve native JPEG and PNG picks
This commit is contained in:
Mohamed Boudra
2026-05-12 13:14:25 +08:00
committed by GitHub
parent 0759932dad
commit 3acc71b8ad
4 changed files with 218 additions and 0 deletions
+13
View File
@@ -21769,6 +21769,18 @@
"expo": "*"
}
},
"node_modules/expo-image-manipulator": {
"version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz",
"integrity": "sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~6.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz",
@@ -38886,6 +38898,7 @@
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
+1
View File
@@ -63,6 +63,7 @@
"expo-file-system": "~19.0.17",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "^17.0.8",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("expo-image-manipulator", () => ({
SaveFormat: { PNG: "png" },
ImageManipulator: {
manipulate: (_source: string) => ({
async renderAsync() {
return {
release() {},
async saveAsync(options: { format?: string }) {
return {
uri:
options.format === "png"
? "file:///cache/ImageManipulator/safe-picked.png"
: "file:///cache/ImageManipulator/unsafe-picked.jpg",
width: 100,
height: 100,
};
},
};
},
release() {},
}),
},
}));
import { normalizePickedImageAssets } from "./image-attachment-picker.native";
describe("native image attachment picker", () => {
it("preserves native picked JPEG and PNG attachment inputs", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.JPG",
mimeType: "image/jpeg",
fileName: "picked.jpeg",
},
{
uri: "file:///photos/screenshot.png",
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
mimeType: "image/jpeg",
fileName: "picked.jpg",
},
{
source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
mimeType: "image/png",
fileName: "screenshot.png",
},
]);
});
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
const result = await normalizePickedImageAssets([
{
uri: "file:///photos/IMG_0001.HEIC",
mimeType: "image/png",
fileName: "picked.png",
},
]);
expect(result).toEqual([
{
source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
mimeType: "image/png",
fileName: "picked.png",
},
]);
});
});
@@ -0,0 +1,129 @@
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
export interface PickedImageAttachmentInput {
source: PickedImageSource;
mimeType?: string | null;
fileName?: string | null;
}
export interface ExpoImagePickerAssetLike {
uri: string;
mimeType?: string | null;
fileName?: string | null;
file?: File | null;
}
interface SupportedPickedImageFormat {
mimeType: "image/jpeg" | "image/png";
extension: "jpg" | "png";
}
const JPEG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/jpeg",
extension: "jpg",
};
const PNG_FORMAT: SupportedPickedImageFormat = {
mimeType: "image/png",
extension: "png",
};
function extensionFromPath(path: string | null | undefined): string | null {
const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
return match?.[1]?.toLowerCase() ?? null;
}
function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
if (extension === "jpg" || extension === "jpeg") {
return JPEG_FORMAT;
}
if (extension === "png") {
return PNG_FORMAT;
}
return null;
}
function supportedFormatForMimeType(
mimeType: string | null | undefined,
): SupportedPickedImageFormat | null {
const normalizedMimeType = mimeType?.toLowerCase();
if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
return JPEG_FORMAT;
}
if (normalizedMimeType === "image/png") {
return PNG_FORMAT;
}
return null;
}
function pickedAssetSupportedFormat(
asset: ExpoImagePickerAssetLike,
): SupportedPickedImageFormat | null {
const uriExtension = extensionFromPath(asset.uri);
if (uriExtension) {
return supportedFormatForExtension(uriExtension);
}
return (
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
supportedFormatForMimeType(asset.mimeType)
);
}
function replaceFileExtension(
fileName: string | null | undefined,
extension: SupportedPickedImageFormat["extension"],
): string | null {
if (!fileName) {
return null;
}
return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
}
async function exportPickedImageAsPng(uri: string): Promise<string> {
const context = ImageManipulator.manipulate(uri);
let image: Awaited<ReturnType<typeof context.renderAsync>> | null = null;
try {
image = await context.renderAsync();
const result = await image.saveAsync({
format: SaveFormat.PNG,
});
return result.uri;
} finally {
image?.release();
context.release();
}
}
export async function normalizePickedImageAssets(
assets: readonly ExpoImagePickerAssetLike[],
): Promise<PickedImageAttachmentInput[]> {
return await Promise.all(
assets.map(async (asset) => {
const supportedFormat = pickedAssetSupportedFormat(asset);
if (supportedFormat) {
return {
source: { kind: "file_uri", uri: asset.uri },
mimeType: supportedFormat.mimeType,
fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
};
}
const convertedUri = await exportPickedImageAsPng(asset.uri);
return {
source: { kind: "file_uri", uri: convertedUri },
mimeType: PNG_FORMAT.mimeType,
fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
};
}),
);
}
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
throw new Error("Desktop dialog API is not available on native.");
}