From aba413bc48a602da44a3afccb567b8238caa9cf4 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 8 Oct 2026 13:38:15 +0800 Subject: [PATCH] Fix Android large attachment uploads --- apps/mobile/src/hooks/useMobileRichEditor.ts | 3 +- .../src/lib/mobile-resource-part.test.mjs | 33 +++++++++++++++++++ apps/mobile/src/lib/mobile-resource-part.ts | 24 ++++++++++++++ apps/mobile/src/lib/mobile-resource-upload.ts | 23 +++++++++++++ apps/mobile/src/screens/WorkspaceEditors.tsx | 5 +-- packages/client/src/index.test.mjs | 26 +++++++++++++++ packages/client/src/index.ts | 4 +-- 7 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/lib/mobile-resource-part.test.mjs create mode 100644 apps/mobile/src/lib/mobile-resource-part.ts create mode 100644 apps/mobile/src/lib/mobile-resource-upload.ts diff --git a/apps/mobile/src/hooks/useMobileRichEditor.ts b/apps/mobile/src/hooks/useMobileRichEditor.ts index a5ca6ffe..8a4cef20 100644 --- a/apps/mobile/src/hooks/useMobileRichEditor.ts +++ b/apps/mobile/src/hooks/useMobileRichEditor.ts @@ -36,6 +36,7 @@ import { type ProtectedResourceLoadFailure, } from "../lib/mobile-protected-resources"; import type { MobileResourceTarget } from "../lib/mobile-attachments"; +import { uploadMobileResource } from "../lib/mobile-resource-upload"; export type MobileMemoUpdateMutation = UseMutationResult< MemoDetail, @@ -392,7 +393,7 @@ export const useMobileRichEditor = ({ safeDomCall(() => editorRef.current?.beginImageUpload(uploadId, previewDataUrl)); } const uploadAsset = await prepareUploadAsset(asset, imageCompressionEnabled); - const { resource } = await client.uploadMemoResource(currentMemo.id, new ExpoFile(uploadAsset.uri)); + const { resource } = await uploadMobileResource(client, currentMemo.id, uploadAsset); applyMobileEditorUpload(editorRef, resource, uploadId, uploadAsset.name || (resource.kind === "image" ? "图片" : "附件")); if (resource.kind === "image") sources.push(resource.url); uploadId = null; diff --git a/apps/mobile/src/lib/mobile-resource-part.test.mjs b/apps/mobile/src/lib/mobile-resource-part.test.mjs new file mode 100644 index 00000000..47629ea1 --- /dev/null +++ b/apps/mobile/src/lib/mobile-resource-part.test.mjs @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test"; +import { readMobileResourcePart } from "./mobile-resource-part.ts"; + +test("reads exact binary upload ranges and closes each file handle", () => { + const source = new Uint8Array([0, 255, 37, 80, 68, 70, 13, 10]); + const handles = []; + const open = () => { + const handle = { + offset: 0, + closed: false, + readBytes(length) { + return source.slice(this.offset, this.offset + length); + }, + close() { this.closed = true; }, + }; + handles.push(handle); + return handle; + }; + + expect([...readMobileResourcePart(open, 0, 4)]).toEqual([0, 255, 37, 80]); + expect([...readMobileResourcePart(open, 4, 8)]).toEqual([68, 70, 13, 10]); + expect(handles.map((handle) => handle.closed)).toEqual([true, true]); +}); + +test("rejects truncated file parts and still closes the handle", () => { + let closed = false; + expect(() => readMobileResourcePart(() => ({ + offset: 0, + readBytes: () => new Uint8Array([1]), + close: () => { closed = true; }, + }), 0, 2)).toThrow("附件读取不完整"); + expect(closed).toBe(true); +}); diff --git a/apps/mobile/src/lib/mobile-resource-part.ts b/apps/mobile/src/lib/mobile-resource-part.ts new file mode 100644 index 00000000..13368f01 --- /dev/null +++ b/apps/mobile/src/lib/mobile-resource-part.ts @@ -0,0 +1,24 @@ +type ReadableFileHandle = { + close: () => void; + offset: number | null; + readBytes: (length: number) => Uint8Array; +}; + +/** Read only the requested part without passing bytes through React Native's Blob constructor. */ +export const readMobileResourcePart = ( + open: () => ReadableFileHandle, + start: number, + end: number, +): Uint8Array => { + const handle = open(); + try { + handle.offset = start; + const bytes = handle.readBytes(end - start); + if (bytes.byteLength !== end - start) { + throw new Error("附件读取不完整,请重新选择文件"); + } + return bytes; + } finally { + handle.close(); + } +}; diff --git a/apps/mobile/src/lib/mobile-resource-upload.ts b/apps/mobile/src/lib/mobile-resource-upload.ts new file mode 100644 index 00000000..091875a0 --- /dev/null +++ b/apps/mobile/src/lib/mobile-resource-upload.ts @@ -0,0 +1,23 @@ +import { MAX_SINGLE_REQUEST_UPLOAD_BYTES, type createEdgeEverClient } from "@edgeever/client"; +import { File as ExpoFile, FileMode } from "expo-file-system"; +import { readMobileResourcePart } from "./mobile-resource-part"; + +type ResourceUploadClient = Pick, "uploadMemoResource" | "uploadMemoResourceParts">; + +export const uploadMobileResource = ( + client: ResourceUploadClient, + memoId: string, + asset: { uri: string; name: string; type: string }, +) => { + const file = new ExpoFile(asset.uri); + if (file.size <= MAX_SINGLE_REQUEST_UPLOAD_BYTES) { + return client.uploadMemoResource(memoId, file); + } + return client.uploadMemoResourceParts(memoId, { + filename: asset.name, + mimeType: asset.type === "application/octet-stream" ? file.type || asset.type : asset.type, + byteSize: file.size, + // Expo File.slice() constructs Blob([Uint8Array]), unsupported by React Native. + readPart: async (start, end) => readMobileResourcePart(() => file.open(FileMode.ReadOnly), start, end), + }); +}; diff --git a/apps/mobile/src/screens/WorkspaceEditors.tsx b/apps/mobile/src/screens/WorkspaceEditors.tsx index c28c6d8e..d5f99081 100644 --- a/apps/mobile/src/screens/WorkspaceEditors.tsx +++ b/apps/mobile/src/screens/WorkspaceEditors.tsx @@ -28,6 +28,7 @@ import { queueMobileMemoCreate, queueMobileMemoUpdate } from "../lib/sync-queue" import { createMobileDataScope, upsertLocalMemo } from "../lib/local-mirror"; import { recordEditorStartup } from "../lib/startup-performance"; import { prepareUploadAsset, type MobileImageUploadAsset } from "../lib/mobile-image-upload"; +import { uploadMobileResource } from "../lib/mobile-resource-upload"; import MobileWebClipCapture from "../components/MobileWebClipCapture"; import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/LocalTiptapEditor"; import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom"; @@ -533,7 +534,7 @@ export const CreateMemoModal = ({ const memo = await materializeMemoForImage(); setImageOperation("uploading"); const uploadAsset = await prepareUploadAsset(asset, imageCompressionEnabled); - const { resource } = await client!.uploadMemoResource(memo.id, new ExpoFile(uploadAsset.uri)); + const { resource } = await uploadMobileResource(client!, memo.id, uploadAsset); applyMobileEditorUpload(editorRef, resource, uploadId, uploadAsset.name || (resource.kind === "image" ? "图片" : "附件")); if (resource.kind === "image" && !keepBusy) { safeDomCall(() => editorRef.current?.finishImageBatch([resource.url])); @@ -1029,7 +1030,7 @@ export const RichEditorModal = ({ safeDomCall(() => editorRef.current?.beginImageUpload(uploadId, previewDataUrl)); } const uploadAsset = await prepareUploadAsset(asset, imageCompressionEnabled); - const { resource } = await client.uploadMemoResource(memo.id, new ExpoFile(uploadAsset.uri)); + const { resource } = await uploadMobileResource(client, memo.id, uploadAsset); applyMobileEditorUpload(editorRef, resource, uploadId, uploadAsset.name || (resource.kind === "image" ? "图片" : "附件")); if (resource.kind === "image") sources.push(resource.url); uploadId = null; diff --git a/packages/client/src/index.test.mjs b/packages/client/src/index.test.mjs index 2f6d7a50..0b6b8bf6 100644 --- a/packages/client/src/index.test.mjs +++ b/packages/client/src/index.test.mjs @@ -325,6 +325,32 @@ describe("EdgeEver client HTTP contract", () => { ]); }); + test("uploads binary parts without converting them to Blob", async () => { + const parts = []; + const client = createEdgeEverClient({ + fetch: async (input, init) => { + const path = String(input); + if (path.endsWith("/resource-uploads")) { + return jsonResponse({ upload: { id: "upload_binary", partSize: 4, partCount: 2, byteSize: 6 } }, { status: 201 }); + } + if (path.includes("/parts/")) { + expect(init.body).toBeInstanceOf(Uint8Array); + parts.push([...init.body]); + return jsonResponse({ part: { partNumber: parts.length, byteSize: init.body.byteLength } }); + } + return jsonResponse({ resource: { id: "binary_resource" } }, { status: 201 }); + }, + }); + + await client.uploadMemoResourceParts("memo_1", { + filename: "report.pdf", + mimeType: "application/pdf", + byteSize: 6, + readPart: async (start, end) => new Uint8Array([0, 255, 37, 80, 68, 70].slice(start, end)), + }); + expect(parts).toEqual([[0, 255, 37, 80], [68, 70]]); + }); + test("uploads small images in one request and preserves file metadata", async () => { const calls = []; const client = createEdgeEverClient({ diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ba3dd838..e961520e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -75,7 +75,7 @@ import type { PluginPublicFetchResponse, } from "@edgeever/shared"; -const MAX_SINGLE_REQUEST_UPLOAD_BYTES = 5 * 1024 * 1024; +export const MAX_SINGLE_REQUEST_UPLOAD_BYTES = 5 * 1024 * 1024; async function consumeEventStream(body: ReadableStream, onEvent: (event: T) => void) { const reader = body.getReader(); @@ -125,7 +125,7 @@ export type MultipartResourceUploadSource = { filename: string; mimeType: string; byteSize: number; - readPart: (start: number, end: number) => Promise; + readPart: (start: number, end: number) => Promise>; }; export type InstanceHealth = {