diff --git a/apps/mobile/src/lib/mobile-note-image-export.test.ts b/apps/mobile/src/lib/mobile-note-image-export.test.ts index b8e3f62b..d63f6b1a 100644 --- a/apps/mobile/src/lib/mobile-note-image-export.test.ts +++ b/apps/mobile/src/lib/mobile-note-image-export.test.ts @@ -1,5 +1,25 @@ import { describe, expect, test } from "bun:test"; -import { planMobileNoteImageRender } from "./mobile-note-image-export"; +import { PathUtilities } from "../../node_modules/expo-file-system/src/pathUtilities"; +import { buildImageExportBasename } from "@edgeever/shared/note-image-card"; +import { encodeMobileNoteImageFileUri, planMobileNoteImageRender } from "./mobile-note-image-export"; + +describe("mobile note image file URI", () => { + test.each(["png", "jpg"])("preserves bracketed titles for %s exports through Expo path joining", (extension) => { + const filename = `${buildImageExportBasename("[Serv00.com] Account activation tuuki", "Untitled")}.${extension}`; + const uri = encodeMobileNoteImageFileUri(PathUtilities.join("file:///cache/exports/request-1/", filename)); + expect(uri).toBe(`file:///cache/exports/request-1/%5BServ00.com%5D%20Account%20activation%20tuuki.${extension}`); + // File's constructor joins the URI once more before handing it to Android. + expect(PathUtilities.join(uri)).toBe(uri); + expect(decodeURIComponent(new URL(uri).pathname.split("/").at(-1)!)).toBe(filename); + }); + + test.each(["中文 [标题] 100% #1.png", "literal %5Bname%5D.png", "普通笔记.jpg"])("preserves the filename %s without double encoding", (filename) => { + const uri = encodeMobileNoteImageFileUri(PathUtilities.join("file:///cache/exports/", filename)); + expect(uri).not.toMatch(/[\[\]]/); + expect(decodeURIComponent(new URL(uri).pathname.split("/").at(-1)!)).toBe(filename); + expect(encodeMobileNoteImageFileUri(uri)).toBe(uri); + }); +}); describe("planMobileNoteImageRender", () => { test("keeps a short card at full quality", () => { diff --git a/apps/mobile/src/lib/mobile-note-image-export.ts b/apps/mobile/src/lib/mobile-note-image-export.ts index c159167a..c3a01abf 100644 --- a/apps/mobile/src/lib/mobile-note-image-export.ts +++ b/apps/mobile/src/lib/mobile-note-image-export.ts @@ -1 +1,7 @@ export { planNativeNoteImageRender as planMobileNoteImageRender } from "@edgeever/shared/note-image-card"; + +// Expo's path joiner escapes spaces and percent signs, but leaves brackets raw. +// Android's java.net.URI rejects those in a path. Escape only brackets here so +// existing percent escapes stay intact and the actual filename is preserved. +export const encodeMobileNoteImageFileUri = (uri: string): string => + uri.replace(/\[/g, "%5B").replace(/\]/g, "%5D"); diff --git a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx index df45ffd6..fb7feefd 100644 --- a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx +++ b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx @@ -26,6 +26,7 @@ import { getMobileNoteImageFontLabel } from "../lib/note-image-font-options"; import { MobileResourceActions } from "../components/MobileResourceActions"; import { ZoomableImagePreview } from "../components/ZoomableImagePreview"; import type { ImageSize } from "../lib/mobile-image-zoom"; +import { encodeMobileNoteImageFileUri } from "../lib/mobile-note-image-export"; import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom"; import { getNextMobileNoteSearchIndex } from "../lib/mobile-note-search"; import { hasMobilePoster, hasMobileInfographic, hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "../lib/mobile-diagram"; @@ -972,7 +973,7 @@ export const MemoDetailModal = ({ // earlier export when different notes share the same download filename. const directory = new Directory(Paths.cache, "edgeever-note-exports", event.requestId); if (!directory.exists) directory.create({ idempotent: true, intermediates: true }); - const file = new File(directory, event.filename); + const file = new File(encodeMobileNoteImageFileUri(Paths.join(directory, event.filename))); if (file.exists) file.delete(); file.create({ overwrite: true, intermediates: true }); file.write(decodeBase64Chunks(imageExportChunksRef.current));