Skip to content
Closed
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 apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"expo-glass-effect": "~56.0.4",
"expo-haptics": "~56.0.3",
"expo-image": "~56.0.11",
"expo-image-manipulator": "~56.0.24",
"expo-image-picker": "~56.0.18",
"expo-linking": "~56.0.14",
"expo-network": "~56.0.5",
Expand Down
11 changes: 10 additions & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
</View>
) : null}
{!isExpanded ? (
<Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(100)}>
<Animated.View
className="flex-row items-center gap-1.5"
entering={FadeIn.duration(180)}
exiting={FadeOut.duration(100)}
>
<ControlPill
accessibilityLabel="Add attachment"
icon="plus"
onPress={() => void props.onPickDraftImages()}
/>
{showStopAction ? (
<ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
) : (
Expand Down
83 changes: 83 additions & 0 deletions apps/mobile/src/lib/composerImages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";

const files = new Map<string, { base64: string; deleted: boolean }>();
const launchImageLibraryAsync = vi.fn();
const saveAsync = vi.fn();
const renderedRelease = vi.fn();
const contextRelease = vi.fn();
const renderAsync = vi.fn(async () => ({ release: renderedRelease, saveAsync }));
const manipulate = vi.fn(() => ({ release: contextRelease, renderAsync }));

vi.mock("expo-image-picker", () => ({
launchImageLibraryAsync,
}));

vi.mock("expo-image-manipulator", () => ({
ImageManipulator: { manipulate },
SaveFormat: { JPEG: "jpeg" },
}));

vi.mock("expo-file-system", () => ({
File: class {
Expand Down Expand Up @@ -39,6 +54,7 @@ vi.mock("./uuid", () => ({
import {
convertPastedImagesToAttachments,
isOwnedPastedImageUri,
pickComposerImages,
toUploadChatImageAttachments,
} from "./composerImages";

Expand Down Expand Up @@ -71,6 +87,12 @@ describe("toUploadChatImageAttachments", () => {
describe("native pasted image cleanup", () => {
beforeEach(() => {
files.clear();
launchImageLibraryAsync.mockReset();
saveAsync.mockReset();
renderedRelease.mockClear();
contextRelease.mockClear();
renderAsync.mockClear();
manipulate.mockClear();
});

it("recognizes only files created in the native composer paste directory", () => {
Expand All @@ -83,6 +105,67 @@ describe("native pasted image cleanup", () => {
expect(isOwnedPastedImageUri("https://example.com/t3-composer-paste/id.png")).toBe(false);
});

it("reads a selected image URI when Android omits picker base64", async () => {
const uri = "file:///data/user/0/app/cache/selected.png";
files.set(uri, { base64: "aGVsbG8=", deleted: false });
launchImageLibraryAsync.mockResolvedValue({
canceled: false,
assets: [{ fileName: "selected.png", fileSize: 5, mimeType: "image/png", uri }],
});

await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
error: null,
images: [
expect.objectContaining({
dataUrl: "data:image/png;base64,aGVsbG8=",
previewUri: uri,
}),
],
});
});

it("converts selected HEIF images to provider-supported JPEG data", async () => {
const uri = "file:///data/user/0/app/cache/selected.heif";
launchImageLibraryAsync.mockResolvedValue({
canceled: false,
assets: [{ fileName: "selected.heif", fileSize: 2, mimeType: "image/heif", uri }],
});
saveAsync.mockResolvedValue({ base64: "aGVsbG8=", uri: `${uri}.jpg` });

await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
error: null,
images: [
expect.objectContaining({
dataUrl: "data:image/jpeg;base64,aGVsbG8=",
mimeType: "image/jpeg",
name: "selected.jpg",
previewUri: `${uri}.jpg`,
sizeBytes: 5,
}),
],
});
expect(manipulate).toHaveBeenCalledWith(uri);
expect(saveAsync).toHaveBeenCalledWith({ base64: true, compress: 0.9, format: "jpeg" });
expect(renderedRelease).toHaveBeenCalledOnce();
expect(contextRelease).toHaveBeenCalledOnce();
});

it("reports failed HEIF conversion and releases native image resources", async () => {
const uri = "file:///data/user/0/app/cache/selected.heic";
launchImageLibraryAsync.mockResolvedValue({
canceled: false,
assets: [{ fileName: "selected.heic", mimeType: "image/heic", uri }],
});
saveAsync.mockRejectedValue(new Error("conversion failed"));

await expect(pickComposerImages({ existingCount: 0 })).resolves.toEqual({
error: "Failed to convert 'selected.heic' to JPEG.",
images: [],
});
expect(renderedRelease).toHaveBeenCalledOnce();
expect(contextRelease).toHaveBeenCalledOnce();
});

it("converts owned files to data-backed previews and deletes the source", async () => {
const uri =
"file:///private/var/mobile/Containers/Data/Application/app/tmp/t3-composer-paste/id.png";
Expand Down
95 changes: 86 additions & 9 deletions apps/mobile/src/lib/composerImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ export function toUploadChatImageAttachments(
}

const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";
const HEIF_IMAGE_MIME_TYPES = new Set([
"image/heic",
"image/heic-sequence",
"image/heif",
"image/heif-sequence",
"image/vnd.android.heic",
"image/x-heic",
"image/x-heif",
]);

async function loadImagePicker() {
try {
Expand All @@ -44,6 +53,42 @@ async function loadClipboard() {
}
}

function isHeifImage(mimeType: string | undefined, fileName: string | null | undefined): boolean {
return (
(mimeType !== undefined && HEIF_IMAGE_MIME_TYPES.has(mimeType)) ||
/\.(?:heic|heif)$/i.test(fileName ?? "")
);
}

function jpegFileName(fileName: string | null | undefined): string {
const stem = (fileName ?? "image").replace(/\.(?:heic|heif)$/i, "");
return `${stem.slice(0, 251)}.jpg`;
}

async function convertHeifImage(
uri: string,
): Promise<{ readonly base64: string; readonly uri: string }> {
const { ImageManipulator, SaveFormat } = await import("expo-image-manipulator");
const context = ImageManipulator.manipulate(uri);
let rendered: Awaited<ReturnType<typeof context.renderAsync>> | null = null;

try {
rendered = await context.renderAsync();
const result = await rendered.saveAsync({
base64: true,
compress: 0.9,
format: SaveFormat.JPEG,
});
if (!result.base64) {
throw new Error("HEIF conversion did not return image data.");
}
return { base64: result.base64, uri: result.uri };
} finally {
rendered?.release();
context.release();
}
}

export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{
readonly images: ReadonlyArray<DraftComposerImageAttachment>;
readonly error: string | null;
Expand Down Expand Up @@ -94,36 +139,68 @@ export async function pickComposerImages(input: { readonly existingCount: number
let error: string | null = null;

for (const asset of result.assets) {
const mimeType = asset.mimeType?.toLowerCase();
const originalMimeType = asset.mimeType?.toLowerCase();
let mimeType = originalMimeType;
let fileName = asset.fileName ?? "image";
let base64 = asset.base64;
let previewUri = asset.uri;
let sizeBytes = asset.fileSize;
let convertedFromHeif = false;

if (isHeifImage(originalMimeType, asset.fileName)) {
try {
const converted = await convertHeifImage(asset.uri);
mimeType = "image/jpeg";
fileName = jpegFileName(asset.fileName);
base64 = converted.base64;
previewUri = converted.uri;
sizeBytes = undefined;
convertedFromHeif = true;
} catch {
error = `Failed to convert '${asset.fileName ?? "image"}' to JPEG.`;
continue;
}
}

if (!mimeType?.startsWith("image/")) {
error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
error = `Unsupported file type for '${fileName}'.`;
continue;
}
if (!isProviderSendTurnSupportedImageMimeType(mimeType)) {
error = `'${asset.fileName ?? "image"}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`;
error = `'${fileName}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`;
continue;
}

const base64 = asset.base64;
if (!base64) {
error = `Failed to read '${asset.fileName ?? "image"}'.`;
try {
const { File } = await import("expo-file-system");
base64 = await new File(asset.uri).base64();
} catch {
// Keep the existing user-facing error below when the picker URI cannot
// be read on a particular Android media provider.
}
}
if (!base64) {
error = `Failed to read '${fileName}'.`;
continue;
}

const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64);
sizeBytes ??= estimateBase64ByteSize(base64);
if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`;
error = convertedFromHeif
? `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit after JPEG conversion.`
: `'${fileName}' exceeds the 10 MB attachment limit.`;
continue;
}

nextImages.push({
id: uuidv4(),
type: "image",
name: asset.fileName ?? "image",
name: fileName,
mimeType,
sizeBytes,
dataUrl: `data:${mimeType};base64,${base64}`,
previewUri: asset.uri,
previewUri,
});
}

Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading