Skip to content
Merged
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
5 changes: 3 additions & 2 deletions apps/mobile/src/host/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
},
);
Expand Down
20 changes: 18 additions & 2 deletions apps/mobile/src/host/pick-files.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +20,20 @@ function readUri(uri: string): () => Promise<Blob> {
return async () => (await fetch(uri)).blob();
}

function readDataUri(uri: string, name: string): () => Promise<Blob> {
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}`;
Expand Down Expand Up @@ -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),
};
}

Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/test/clipboard-image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
});
});
16 changes: 16 additions & 0 deletions apps/mobile/test/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/test/fake-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/test/incoming-share.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));

Expand Down
11 changes: 11 additions & 0 deletions changes/2444-mobile-attachment-upload.md
Original file line number Diff line number Diff line change
@@ -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))。
Loading