fix(app): restore Android clipboard image pasting (#4758)

Decode clipboard image data URLs locally because native fetch rejects them. Add native-runtime and byte-integrity regression coverage, and clarify empty-clipboard guidance.
This commit is contained in:
Mohamed Boudra
2026-09-12 09:50:25 +02:00
committed by GitHub
parent 0d7760e68b
commit d1b705a0cd
12 changed files with 180 additions and 20 deletions
@@ -0,0 +1,96 @@
// Run against a native dev app loaded from this checkout:
// node packages/app/e2e/mobile/clipboard-image.mjs <metro-port> <app-id>
// Exercises real native attachment persistence; Node's fetch(data:) hides this regression.
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { setTimeout as delay } from "node:timers/promises";
import { WebSocket } from "ws";
const [, , metroPort, appId] = process.argv;
assert(metroPort && appId, "Pass the Metro port and native app ID");
const targets = await fetch(`http://127.0.0.1:${Number(metroPort)}/json/list`).then((r) =>
r.json(),
);
const target = targets.find((candidate) => candidate.appId === appId);
assert(target, `Missing Hermes target for ${appId}`);
const png = await readFile(new URL("../../assets/images/icon.png", import.meta.url));
const base64 = png.toString("base64");
const socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
let nextId = 0;
function evaluate(expression) {
return new Promise((resolve, reject) => {
const id = ++nextId;
const timeout = setTimeout(() => {
socket.off("message", onMessage);
reject(new Error("Hermes evaluation timed out"));
}, 10000);
function onMessage(message) {
const response = JSON.parse(message.toString());
if (response.id !== id) return;
clearTimeout(timeout);
socket.off("message", onMessage);
if (response.error || response.result.exceptionDetails) {
reject(new Error(JSON.stringify(response.error ?? response.result.exceptionDetails)));
} else {
resolve(response.result.result.value);
}
}
socket.on("message", onMessage);
socket.send(
JSON.stringify({
id,
method: "Runtime.evaluate",
params: { expression, returnByValue: true },
}),
);
});
}
async function waitForNativeClipboardImage() {
const deadline = Date.now() + 15000;
do {
const result = await evaluate("globalThis.__clipboardImageTest");
if (result) return result;
await delay(100);
} while (Date.now() < deadline);
assert.fail("Native image persistence timed out");
}
try {
// Metro's debugger module table locates the same public service used by the composer.
// Hermes does not await promises through Runtime.evaluate, so collect the async result.
await evaluate(`(() => {
const entry = Array.from(__r.getModules().entries()).find(
([, module]) => module.verboseName === "src/attachments/service.ts"
);
if (!entry) throw new Error("Open the composer before running this test");
const service = __r(entry[0]);
globalThis.__clipboardImageTest = null;
service.persistAttachmentFromDataUrl({
dataUrl: ${JSON.stringify(`data:image/png;base64,${base64}`)},
fileName: "clipboard.png"
}).then(attachment => {
return Promise.all([
service.encodeAttachmentsForSend([attachment]),
service.resolveAttachmentPreviewUrl(attachment)
]).then(([encoded, preview]) => ({ attachment, encoded, preview }))
.finally(() => service.deleteAttachments([attachment]));
}).then(result => { globalThis.__clipboardImageTest = result; })
.catch(error => { globalThis.__clipboardImageTest = { error: String(error) }; });
})()`);
const result = await waitForNativeClipboardImage();
assert.equal(result.error, undefined, result.error);
assert.equal(result.attachment.storageType, "native-file");
assert.equal(result.attachment.byteSize, png.length);
assert.equal(result.attachment.fileName, "clipboard.png");
assert.match(result.preview, /^file:\/\//);
assert.deepEqual(result.encoded, [{ data: base64, mimeType: "image/png" }]);
console.log(`PASS: native clipboard PNG persisted, previewed, and encoded (${png.length} bytes)`);
} finally {
await evaluate("delete globalThis.__clipboardImageTest").finally(() => socket.close());
}
@@ -2,16 +2,75 @@ import { describe, expect, it } from "vitest";
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
import { createTestAttachmentFileSystem } from "./test-attachment-file-system";
function createStore() {
const fileSystem = createTestAttachmentFileSystem();
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
fileSystem,
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
});
return { fileSystem, store };
}
describe("local file attachment store", () => {
it("writes raw byte sources directly to the managed file path", async () => {
const fileSystem = createTestAttachmentFileSystem();
const store = createLocalFileAttachmentStore({
storageType: "native-file",
baseDirectoryName: "preview-assets",
fileSystem,
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
it.each([
"data:image/png;base64,AAECAwQF+/8=",
"data:image/png;charset=utf-8;base64,AAEC\nAwQF+/8=",
])("preserves binary image bytes from %s", async (dataUrl) => {
const { fileSystem, store } = createStore();
const attachment = await store.save({
id: "clipboard",
fileName: "clipboard.png",
source: { kind: "data_url", dataUrl },
});
expect(attachment).toMatchObject({
mimeType: "image/png",
fileName: "clipboard.png",
byteSize: 8,
storageType: "native-file",
storageKey: "/cache/preview-assets/clipboard.png",
});
expect(fileSystem.files.get("file:///cache/preview-assets/clipboard.png")).toEqual(
new Uint8Array([0, 1, 2, 3, 4, 5, 251, 255]),
);
await expect(store.encodeBase64({ attachment })).resolves.toBe("AAECAwQF+/8=");
await expect(store.resolvePreviewUrl({ attachment })).resolves.toBe(
"file:///cache/preview-assets/clipboard.png",
);
});
it.each([
"not-a-data-url",
"data:image/png,hello",
"data:image/png;base64,",
"data:image/png;base64,%%%%",
"data:image/png;base64,A",
])("rejects malformed image data without writing a file: %s", async (dataUrl) => {
const { fileSystem, store } = createStore();
await expect(store.save({ source: { kind: "data_url", dataUrl } })).rejects.toThrow();
expect(fileSystem.files.size).toBe(0);
});
it("copies file URI images without altering the source", async () => {
const { fileSystem, store } = createStore();
const bytes = new Uint8Array([0, 1, 254, 255]);
fileSystem.setFile("file:///keyboard/image.png", bytes);
const attachment = await store.save({
id: "keyboard",
mimeType: "image/png",
source: { kind: "file_uri", uri: "file:///keyboard/image.png" },
});
expect(fileSystem.files.get("file:///cache/preview-assets/keyboard.png")).toEqual(bytes);
expect(fileSystem.files.get("file:///keyboard/image.png")).toEqual(bytes);
expect(attachment.byteSize).toBe(bytes.length);
});
it("writes raw byte sources directly to the managed file path", async () => {
const { fileSystem, store } = createStore();
const attachment = await store.save({
id: "preview_8_test",
mimeType: "image/png",
@@ -44,9 +44,10 @@ async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): P
await fileSystem.makeDirectory(uri, { intermediates: true });
}
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
const response = await fetch(dataUrl);
return new Uint8Array(await response.arrayBuffer());
function dataUrlToBytes(dataUrl: string): Uint8Array {
// React Native's fetch cannot read data URLs. Decode the embedded bytes locally.
const { base64 } = parseDataUrl(dataUrl);
return Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
}
async function blobToBytes(blob: Blob): Promise<Uint8Array> {
@@ -70,7 +71,7 @@ async function writeFromSource(input: {
let bytes: Uint8Array;
if (input.source.kind === "data_url") {
bytes = await dataUrlToBytes(input.source.dataUrl);
bytes = dataUrlToBytes(input.source.dataUrl);
} else if (input.source.kind === "blob") {
bytes = await blobToBytes(input.source.blob);
} else {
+1 -1
View File
@@ -154,7 +154,7 @@ export const ar: TranslationResources = {
initialPromptRequired: "مطلوب موجه الأولي",
alreadyLoading: "جارٍ التحميل بالفعل",
uploadFailed: "Failed to upload file",
noClipboardImage: "لا توجد صورة في الحافظة",
noClipboardImage: "لا توجد صورة في الحافظة الحالية. جرّب اللصق من لوحة المفاتيح.",
pasteImageFailed: "تعذر لصق الصورة",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
+1 -1
View File
@@ -151,7 +151,7 @@ export const en = {
initialPromptRequired: "Initial prompt is required",
alreadyLoading: "Already loading",
uploadFailed: "Failed to upload file",
noClipboardImage: "No image in clipboard",
noClipboardImage: "No image in the current clipboard. Try pasting from your keyboard.",
pasteImageFailed: "Failed to paste image",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
+2 -1
View File
@@ -154,7 +154,8 @@ export const es: TranslationResources = {
initialPromptRequired: "Se requiere aviso inicial",
alreadyLoading: "Ya cargando",
uploadFailed: "Failed to upload file",
noClipboardImage: "No hay ninguna imagen en el portapapeles",
noClipboardImage:
"No hay ninguna imagen en el portapapeles actual. Prueba a pegar desde el teclado.",
pasteImageFailed: "No se pudo pegar la imagen",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
+2 -1
View File
@@ -156,7 +156,8 @@ export const fr: TranslationResources = {
initialPromptRequired: "Une invite initiale est requise",
alreadyLoading: "Déjà en cours de chargement",
uploadFailed: "Failed to upload file",
noClipboardImage: "Aucune image dans le presse-papiers",
noClipboardImage:
"Aucune image dans le presse-papiers actuel. Essayez de coller depuis votre clavier.",
pasteImageFailed: "Impossible de coller l’image",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
+2 -1
View File
@@ -154,7 +154,8 @@ export const ja: TranslationResources = {
initialPromptRequired: "初期プロンプトが必要です",
alreadyLoading: "すでに読み込み中です",
uploadFailed: "ファイルのアップロードに失敗しました",
noClipboardImage: "クリップボードに画像がありません",
noClipboardImage:
"現在のクリップボードに画像がありません。キーボードから貼り付けてみてください。",
pasteImageFailed: "画像を貼り付けられませんでした",
fileTooLarge: "{{fileName}}が大きすぎます(最大{{size}})",
},
+1 -1
View File
@@ -154,7 +154,7 @@ export const ko: TranslationResources = {
initialPromptRequired: "초기 프롬프트가 필요합니다",
alreadyLoading: "이미 불러오는 중입니다",
uploadFailed: "파일을 업로드하지 못했습니다",
noClipboardImage: "클립보드에 이미지가 없습니다.",
noClipboardImage: "현재 클립보드에 이미지가 없습니다. 키보드에서 붙여넣기를 시도해 보세요.",
pasteImageFailed: "이미지를 붙여넣지 못했습니다.",
fileTooLarge: "{{fileName}}이(가) 너무 큽니다 (최대 {{size}})",
},
+1 -1
View File
@@ -154,7 +154,7 @@ export const ptBR: TranslationResources = {
initialPromptRequired: "O prompt inicial é obrigatório",
alreadyLoading: "Já está carregando",
uploadFailed: "Falha ao enviar arquivo",
noClipboardImage: "Não há imagem na área de transferência",
noClipboardImage: "Não há imagem na área de transferência atual. Tente colar pelo teclado.",
pasteImageFailed: "Falha ao colar a imagem",
fileTooLarge: "{{fileName}} é grande demais (máximo {{size}})",
},
+2 -1
View File
@@ -154,7 +154,8 @@ export const ru: TranslationResources = {
initialPromptRequired: "Необходим инициализирующий промпт",
alreadyLoading: "Уже загружается",
uploadFailed: "Ошибка загрузки файла",
noClipboardImage: "В буфере обмена нет изображения",
noClipboardImage:
"В текущем буфере обмена нет изображения. Попробуйте вставить его с клавиатуры.",
pasteImageFailed: "Не удалось вставить изображение",
fileTooLarge: "Файл {{fileName}} слишком большой (максимальный размер: {{size}})",
},
+1 -1
View File
@@ -154,7 +154,7 @@ export const zhCN: TranslationResources = {
initialPromptRequired: "初始 prompt 必填",
alreadyLoading: "正在加载",
uploadFailed: "Failed to upload file",
noClipboardImage: "剪贴板中没有图片",
noClipboardImage: "当前剪贴板中没有图片。请尝试通过键盘粘贴。",
pasteImageFailed: "无法粘贴图片",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},