From d1b705a0cd91617a5707fae25d80cb0be3057950 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 12 Sep 2026 09:50:25 +0200 Subject: [PATCH] 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. --- packages/app/e2e/mobile/clipboard-image.mjs | 96 +++++++++++++++++++ .../local-file-attachment-store.test.ts | 73 ++++++++++++-- .../local-file-attachment-store.ts | 9 +- packages/app/src/i18n/resources/ar.ts | 2 +- packages/app/src/i18n/resources/en.ts | 2 +- packages/app/src/i18n/resources/es.ts | 3 +- packages/app/src/i18n/resources/fr.ts | 3 +- packages/app/src/i18n/resources/ja.ts | 3 +- packages/app/src/i18n/resources/ko.ts | 2 +- packages/app/src/i18n/resources/pt-BR.ts | 2 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 2 +- 12 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 packages/app/e2e/mobile/clipboard-image.mjs diff --git a/packages/app/e2e/mobile/clipboard-image.mjs b/packages/app/e2e/mobile/clipboard-image.mjs new file mode 100644 index 0000000000..2a93f5ce20 --- /dev/null +++ b/packages/app/e2e/mobile/clipboard-image.mjs @@ -0,0 +1,96 @@ +// Run against a native dev app loaded from this checkout: +// node packages/app/e2e/mobile/clipboard-image.mjs +// 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()); +} diff --git a/packages/app/src/attachments/local-file-attachment-store.test.ts b/packages/app/src/attachments/local-file-attachment-store.test.ts index 48f974fa1a..82774aa723 100644 --- a/packages/app/src/attachments/local-file-attachment-store.test.ts +++ b/packages/app/src/attachments/local-file-attachment-store.test.ts @@ -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", diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts index b9e76a86e6..54b6347fd4 100644 --- a/packages/app/src/attachments/local-file-attachment-store.ts +++ b/packages/app/src/attachments/local-file-attachment-store.ts @@ -44,9 +44,10 @@ async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): P await fileSystem.makeDirectory(uri, { intermediates: true }); } -async function dataUrlToBytes(dataUrl: string): Promise { - 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 { @@ -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 { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 90683d5ae9..f0e080ea66 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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}})", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3ebc7e5b4b..b186b013e0 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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}})", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 83955b2ed7..35ae22fb11 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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}})", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9eedd70af4..da62e2d85e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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}})", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f70f133412..f07938d044 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -154,7 +154,8 @@ export const ja: TranslationResources = { initialPromptRequired: "初期プロンプトが必要です", alreadyLoading: "すでに読み込み中です", uploadFailed: "ファイルのアップロードに失敗しました", - noClipboardImage: "クリップボードに画像がありません", + noClipboardImage: + "現在のクリップボードに画像がありません。キーボードから貼り付けてみてください。", pasteImageFailed: "画像を貼り付けられませんでした", fileTooLarge: "{{fileName}}が大きすぎます(最大{{size}})", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 78ec33c3b4..b45f44a63b 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -154,7 +154,7 @@ export const ko: TranslationResources = { initialPromptRequired: "초기 프롬프트가 필요합니다", alreadyLoading: "이미 불러오는 중입니다", uploadFailed: "파일을 업로드하지 못했습니다", - noClipboardImage: "클립보드에 이미지가 없습니다.", + noClipboardImage: "현재 클립보드에 이미지가 없습니다. 키보드에서 붙여넣기를 시도해 보세요.", pasteImageFailed: "이미지를 붙여넣지 못했습니다.", fileTooLarge: "{{fileName}}이(가) 너무 큽니다 (최대 {{size}})", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 84c4352e11..f9e1f030f7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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}})", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9fdefa4a1e..370a2b95d5 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -154,7 +154,8 @@ export const ru: TranslationResources = { initialPromptRequired: "Необходим инициализирующий промпт", alreadyLoading: "Уже загружается", uploadFailed: "Ошибка загрузки файла", - noClipboardImage: "В буфере обмена нет изображения", + noClipboardImage: + "В текущем буфере обмена нет изображения. Попробуйте вставить его с клавиатуры.", pasteImageFailed: "Не удалось вставить изображение", fileTooLarge: "Файл {{fileName}} слишком большой (максимальный размер: {{size}})", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 299aec11fc..210e8724d0 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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}})", },