Skip to content

Resolve relative image paths beside the opened document - #220

Open
wachin wants to merge 1 commit into
Renakoni:mainfrom
wachin:feat/local-images-saf
Open

wachin wants to merge 1 commit into
Renakoni:mainfrom
wachin:feat/local-images-saf

Conversation

@wachin

@wachin wachin commented Sep 21, 2026

Copy link
Copy Markdown

Summary

Opening a Markdown file through the document picker grants the app access to that file alone, so a relative destination such as ![](images/cover.png) could not be read: Muya resolved it to file://images/cover.png, which the WebView refuses to load, and the block rendered as "Load image failed". Documents that keep their images in a sibling folder therefore only rendered on desktop.

This resolves those destinations through a persisted SAF folder grant for the document's own folder, offered once with the system picker and remembered for later opens.

Type of change

  • New feature
  • UI/UX change
  • Android native integration

Changes

Native

  • resolveDocumentImages({ sourceUri, sources }) reports whether the document's folder is reachable and resolves the given relative destinations to readable content:// documents. supported is false for providers whose document ids carry no directory component (cloud drives), where a relative local image could never resolve and no offer should be shown.
  • requestDocumentImageFolderAccess({ sourceUri }) opens ACTION_OPEN_DOCUMENT_TREE with EXTRA_INITIAL_URI on the document (API 26+) so the picker lands in the document's own folder, then persists read access.
  • DocumentImagePathPolicy keeps the path arithmetic Android-free and unit-testable. It picks the most specific covering tree, clamps .. inside the granted tree so a crafted Markdown file cannot reach anything the user did not grant, understands volume roots such as primary:, and rejects opaque (cloud) ids instead of guessing.

Web

  • androidImages.ts scans the document for relative destinations, arms the existing MarkTextAndroidImageResolver hook, and also derives sibling URIs synchronously from the granted tree — so an image typed into the document after it opened still resolves without another bridge round trip.
  • LocalImageAccessPrompt.vue offers the grant once, before the document renders, so images appear on first paint instead of after a reopen.
  • appExitDecisions.ts answers the offer with Back.
  • pdfExportHtml.ts rewrites relative destinations to content URIs, because the native print WebView has no Capacitor local server and the live editor's http://localhost/... form would not load.
  • Strings added to all ten locales.

Test plan

  • pnpm build
  • pnpm android:sync
  • Android debug build
  • Physical device install and launch

Verification

  • pnpm typecheck
  • pnpm lint — 0 warnings
  • pnpm test — 83 files, 732 tests
  • Android JVM tests — 134 tests, including 13 new DocumentImagePathPolicyTest cases
  • pnpm android:sync and :app:assembleDebug
  • Installed the debug APK on a Samsung Galaxy A15 (SM-A155M), Android 16 / API 36, opened a document with a sibling images/ folder, accepted the folder offer, and confirmed the images render. Repeated on several other documents and image folders.
  • After the first grant, later documents in the same folder render without asking again.

Screenshots

Images stored beside the document, rendering in the editor (Samsung Galaxy A15, Android 16 / API 36). The document below is a 23-image tutorial whose Markdown only uses ![](images/...):

MarkText-Android-imagenes-locales-captura-corta

Note: To view the Markdown file for the attached image and the "images" folder containing the corresponding image, see the following link:
20240527-Resolucion-Diferencia-entre-Tamaño-en-píxeles-y-ppp

Android notes

  • No new permission is requested. The only new access is the user-granted SAF tree, persisted with takePersistableUriPermission(READ).
  • Resolution stays inside the granted tree: .. is clamped, and every candidate is verified with a metadata query before its URI is handed to the WebView.
  • Images are served through Capacitor's existing _capacitor_content_ path, so no new file copying or caching was added.

Reviewer notes

  • Relative destinations only. An absolute path such as /storage/emulated/0/... still becomes file:// and stays unrendered. Mapping a filesystem path back to a provider document id is provider-specific and is intentionally left out.
  • The folder grant is not tracked in the DocumentGrantPolicy ledger, so cleanup never releases it. That is deliberate — releasing it would break images for a document the user still has — but it does mean user-granted folder grants accumulate outside the existing cap accounting. Happy to fold it into the ledger with a new kind and a reference set if you want it bounded.
  • The Markdown scan is deliberately permissive (inline destinations plus reference definitions). A destination it misses still renders once a folder grant exists, because the resolver derives the URI itself.
  • The offer appears before the editor mounts. Declining hides the images for that open; reopening the document offers again.

- Add folder permission prompt for linked image dirs

- Implement DocumentImagePathPolicy with path traversal safety

- Serve resolved content:// URIs via capacitor_content bridge

- Fix truthy grant check bug in access decision logic

- Verify 716 Vitest and 134 JUnit tests pass cleanly
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant