mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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}})",
|
||||
},
|
||||
|
||||
@@ -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}})",
|
||||
},
|
||||
|
||||
@@ -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}})",
|
||||
},
|
||||
|
||||
@@ -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}})",
|
||||
},
|
||||
|
||||
@@ -154,7 +154,8 @@ export const ja: TranslationResources = {
|
||||
initialPromptRequired: "初期プロンプトが必要です",
|
||||
alreadyLoading: "すでに読み込み中です",
|
||||
uploadFailed: "ファイルのアップロードに失敗しました",
|
||||
noClipboardImage: "クリップボードに画像がありません",
|
||||
noClipboardImage:
|
||||
"現在のクリップボードに画像がありません。キーボードから貼り付けてみてください。",
|
||||
pasteImageFailed: "画像を貼り付けられませんでした",
|
||||
fileTooLarge: "{{fileName}}が大きすぎます(最大{{size}})",
|
||||
},
|
||||
|
||||
@@ -154,7 +154,7 @@ export const ko: TranslationResources = {
|
||||
initialPromptRequired: "초기 프롬프트가 필요합니다",
|
||||
alreadyLoading: "이미 불러오는 중입니다",
|
||||
uploadFailed: "파일을 업로드하지 못했습니다",
|
||||
noClipboardImage: "클립보드에 이미지가 없습니다.",
|
||||
noClipboardImage: "현재 클립보드에 이미지가 없습니다. 키보드에서 붙여넣기를 시도해 보세요.",
|
||||
pasteImageFailed: "이미지를 붙여넣지 못했습니다.",
|
||||
fileTooLarge: "{{fileName}}이(가) 너무 큽니다 (최대 {{size}})",
|
||||
},
|
||||
|
||||
@@ -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}})",
|
||||
},
|
||||
|
||||
@@ -154,7 +154,8 @@ export const ru: TranslationResources = {
|
||||
initialPromptRequired: "Необходим инициализирующий промпт",
|
||||
alreadyLoading: "Уже загружается",
|
||||
uploadFailed: "Ошибка загрузки файла",
|
||||
noClipboardImage: "В буфере обмена нет изображения",
|
||||
noClipboardImage:
|
||||
"В текущем буфере обмена нет изображения. Попробуйте вставить его с клавиатуры.",
|
||||
pasteImageFailed: "Не удалось вставить изображение",
|
||||
fileTooLarge: "Файл {{fileName}} слишком большой (максимальный размер: {{size}})",
|
||||
},
|
||||
|
||||
@@ -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}})",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user