diff --git a/apps/mobile/src/host/client.ts b/apps/mobile/src/host/client.ts index 97689f8235..3e4652d1b4 100644 --- a/apps/mobile/src/host/client.ts +++ b/apps/mobile/src/host/client.ts @@ -174,6 +174,7 @@ class Unreachable extends Error {} const UPLOAD_TOO_LARGE = 'The file is larger than the Host accepts.'; const uploadTimeoutMs = 120_000; +const OCTET_STREAM = 'application/octet-stream'; export class MethodRefused extends Error { constructor( @@ -432,8 +433,8 @@ export function createHostClient(options: HostClientOptions): HostClient { `${route}${DEVICE_PREFIX}/api/botharness/attachment/upload?${new URLSearchParams({ name })}`, { method: 'POST', - body: data, - headers: { ...authorization, 'content-type': 'application/octet-stream' }, + body: data.type === OCTET_STREAM ? data : new Blob([data], { type: OCTET_STREAM }), + headers: { ...authorization, 'content-type': OCTET_STREAM }, signal, }, ); diff --git a/apps/mobile/src/host/pick-files.ts b/apps/mobile/src/host/pick-files.ts index 6474378bf3..e67fc1bf86 100644 --- a/apps/mobile/src/host/pick-files.ts +++ b/apps/mobile/src/host/pick-files.ts @@ -1,4 +1,5 @@ import * as DocumentPicker from 'expo-document-picker'; +import { File, Paths } from 'expo-file-system'; import * as ImagePicker from 'expo-image-picker'; import type { OutgoingFile } from './conversation'; @@ -19,6 +20,20 @@ function readUri(uri: string): () => Promise { return async () => (await fetch(uri)).blob(); } +function readDataUri(uri: string, name: string): () => Promise { + return async () => { + const file = new File(Paths.cache, `${Date.now()}-${name}`); + await file.write(uri.slice(uri.indexOf(',') + 1), { encoding: 'base64' }); + try { + return await (await fetch(file.uri)).blob(); + } finally { + try { + file.delete(); + } catch {} + } + }; +} + function extension(mime: string | undefined): string { const subtype = mime?.split('/')[1]?.replace(/[^a-z0-9]/gu, ''); return subtype === undefined || subtype === '' ? '' : `.${subtype === 'jpeg' ? 'jpg' : subtype}`; @@ -50,12 +65,13 @@ function fromDocument(asset: DocumentPicker.DocumentPickerAsset): PickedFile { export function fromClipboardImage(data: string): PickedFile { picked += 1; const mime = /^data:([^;,]+)/u.exec(data)?.[1]; + const name = `pasted-${picked}${extension(mime)}`; return { id: `pick-${picked}`, kind: 'image', uri: data, - name: `pasted-${picked}${extension(mime)}`, - read: readUri(data), + name, + read: readDataUri(data, name), }; } diff --git a/apps/mobile/test/clipboard-image.test.tsx b/apps/mobile/test/clipboard-image.test.tsx index 5cdd408f6f..d99f6858f1 100644 --- a/apps/mobile/test/clipboard-image.test.tsx +++ b/apps/mobile/test/clipboard-image.test.tsx @@ -21,6 +21,27 @@ vi.mock('react-native', () => ({ })); vi.mock('expo-image-picker', () => ({})); vi.mock('expo-document-picker', () => ({})); +const written: Array<{ uri: string; content: string; encoding?: string; deleted: boolean }> = []; +vi.mock('expo-file-system', () => ({ + Paths: { cache: 'file:///cache' }, + File: class { + readonly entry: (typeof written)[number]; + constructor(directory: string, name: string) { + this.entry = { uri: `${directory}/${name}`, content: '', deleted: false }; + written.push(this.entry); + } + get uri() { + return this.entry.uri; + } + async write(content: string, options?: { encoding?: string }) { + this.entry.content = content; + if (options?.encoding !== undefined) this.entry.encoding = options.encoding; + } + delete() { + this.entry.deleted = true; + } + }, +})); vi.mock('expo-clipboard', () => ({ ContentType: { IMAGE: 'image', PLAIN_TEXT: 'plain-text' }, isPasteButtonAvailable: true, @@ -138,4 +159,22 @@ describe('clipboard image offer', () => { expect(file).toMatchObject({ kind: 'image', uri: 'data:image/jpeg;base64,AAAA' }); expect(file.name).toMatch(/^pasted-\d+\.jpg$/u); }); + + it('reads a pasted image from a cache file because Android fetch cannot open data: URIs', async () => { + const fetched: string[] = []; + const image = new Blob(['jpeg'], { type: 'image/jpeg' }); + vi.stubGlobal('fetch', async (uri: string) => { + fetched.push(uri); + return { blob: async () => image }; + }); + try { + const file = fromClipboardImage('data:image/jpeg;base64,AAAA'); + expect(await file.read()).toBe(image); + expect(written.at(-1)).toMatchObject({ content: 'AAAA', encoding: 'base64', deleted: true }); + expect(fetched).toEqual([written.at(-1)!.uri]); + expect(fetched[0]).toMatch(/^file:\/\/\/cache\/\d+-pasted-\d+\.jpg$/u); + } finally { + vi.unstubAllGlobals(); + } + }); }); diff --git a/apps/mobile/test/conversation.test.ts b/apps/mobile/test/conversation.test.ts index 9e1acb7a0e..093836c94e 100644 --- a/apps/mobile/test/conversation.test.ts +++ b/apps/mobile/test/conversation.test.ts @@ -525,6 +525,22 @@ describe('DM conversation', () => { ]); }); + it('uploads a typed photo as octet-stream so the Host accepts it', async () => { + const host = new FakeHost(); + host.channel(DM); + const target = open(host); + await until(target, (s) => s.status === 'live'); + const photo = async () => new Blob(['png'], { type: 'image/png' }); + const id = target.send('', [{ name: 'IMG_0001.PNG', kind: 'image', read: photo }]); + const sent = (s: ConversationSnapshot) => s.messages.find((message) => message.id === id); + await until(target, (s) => sent(s)?.delivery !== 'pending'); + expect(sent(target.getSnapshot())?.delivery).toBe('committed'); + expect(host.uploads).toEqual([ + { name: 'IMG_0001.PNG', type: 'application/octet-stream', text: 'png' }, + ]); + expect(host.calls.filter((call) => call.method === 'channelSend')).toHaveLength(1); + }); + it('shows picked files at once and keeps their local copies after the Host commits them', async () => { const host = new FakeHost(); host.channel(DM); diff --git a/apps/mobile/test/fake-host.ts b/apps/mobile/test/fake-host.ts index 86c4e87cb0..9119be5515 100644 --- a/apps/mobile/test/fake-host.ts +++ b/apps/mobile/test/fake-host.ts @@ -144,8 +144,11 @@ export class FakeHost { if (parsed.pathname === '/botharness-device/api/botharness/attachment/upload') { if (this.failing.has('upload')) throw new TypeError('Network request failed'); const name = parsed.searchParams.get('name') ?? ''; - const text = await (init.body as Blob).text(); - this.uploads.push({ name, type: init.headers?.['content-type'] ?? '', text }); + const body = init.body as Blob; + const text = await body.text(); + this.uploads.push({ name, type: body.type, text }); + if (body.type !== 'application/octet-stream') + return response(502, '{"code":"host-session-unavailable"}'); return response( 200, JSON.stringify({ diff --git a/apps/mobile/test/incoming-share.test.ts b/apps/mobile/test/incoming-share.test.ts index 6bc46f0e08..6f08f9b2a3 100644 --- a/apps/mobile/test/incoming-share.test.ts +++ b/apps/mobile/test/incoming-share.test.ts @@ -14,6 +14,7 @@ const drafts = vi.hoisted(() => { }); vi.mock('expo-document-picker', () => ({})); +vi.mock('expo-file-system', () => ({})); vi.mock('expo-image-picker', () => ({})); vi.mock('../src/host/native-source-drafts', () => ({ sourceDrafts: drafts.sourceDrafts })); diff --git a/changes/2444-mobile-attachment-upload.md b/changes/2444-mobile-attachment-upload.md new file mode 100644 index 0000000000..1dfe41b9df --- /dev/null +++ b/changes/2444-mobile-attachment-upload.md @@ -0,0 +1,11 @@ +--- +section: Fixed +--- + +## en + +- The phone app sends photos, files and pasted images again. They stayed on "Not sent · Tap to retry" because the upload went out with the image's own type, which the Host does not accept, and on Android a pasted image could not be read at all ([#2444](https://github.com/BotHarness/DeepSeekBot/pull/2444)). + +## zh + +- 手机端又能发送照片、文件和粘贴的图片了。之前上传时带的是图片自身的类型,Host 不接受,所以一直显示「未发送 · 轻点重试」;Android 上粘贴的图片还会读取失败([#2444](https://github.com/BotHarness/DeepSeekBot/pull/2444))。