Fix Android large attachment uploads

This commit is contained in:
tianma-if committed 2026-10-08 13:38:15 +08:00
1 parent 9ad6aae11d
commit aba413bc48
7 files changed
+113 -5

No files matched your search

+2 -1
View File
@@ -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;
@@ -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);
});
@@ -0,0 +1,24 @@
type ReadableFileHandle = {
close: () => void;
offset: number | null;
readBytes: (length: number) => Uint8Array<ArrayBuffer>;
};
/** Read only the requested part without passing bytes through React Native's Blob constructor. */
export const readMobileResourcePart = (
open: () => ReadableFileHandle,
start: number,
end: number,
): Uint8Array<ArrayBuffer> => {
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();
}
};
@@ -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<ReturnType<typeof createEdgeEverClient>, "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),
});
};
+3 -2
View File
@@ -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;
+26
View File
@@ -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({
+2 -2
View File
@@ -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<T>(body: ReadableStream<Uint8Array>, 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<Blob>;
readPart: (start: number, end: number) => Promise<Blob | Uint8Array<ArrayBuffer>>;
};
export type InstanceHealth = {