Fix Android large attachment uploads
This commit is contained in:
1 parent
9ad6aae11d
commit
aba413bc48
7 files changed
+113
-5
No files matched your search
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user