diff --git a/package-lock.json b/package-lock.json index 7863f284c..8fab84da7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app/package.json b/packages/app/package.json index 86bb1910c..4284af22f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/hooks/image-attachment-picker.native.test.ts b/packages/app/src/hooks/image-attachment-picker.native.test.ts new file mode 100644 index 000000000..fd1fa52a1 --- /dev/null +++ b/packages/app/src/hooks/image-attachment-picker.native.test.ts @@ -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", + }, + ]); + }); +}); diff --git a/packages/app/src/hooks/image-attachment-picker.native.ts b/packages/app/src/hooks/image-attachment-picker.native.ts new file mode 100644 index 000000000..23d9bb351 --- /dev/null +++ b/packages/app/src/hooks/image-attachment-picker.native.ts @@ -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 { + const context = ImageManipulator.manipulate(uri); + let image: Awaited> | 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 { + 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 { + throw new Error("Desktop dialog API is not available on native."); +}