Skip to content

Add Android port (eu.io_com.mdview) with folder-based cross-file link navigation - #5

Merged
adaasch merged 10 commits into
masterfrom
feat/android-port
Aug 3, 2026
Merged

adaasch merged 10 commits into
masterfrom
feat/android-port

Conversation

@adaasch

@adaasch adaasch commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Ports the desktop markdown viewer to Android via eframe's android-game-activity backend, reusing the parser, renderer, theme, navigation and link logic unchanged. Desktop logic moves into the mdview library crate so the Android cdylib and the desktop binary share one codebase.

What's here

The port (d02fc36) — JNI bridge, Gradle project, SAF integration. The headline feature is folder-based navigation: documents opened from a granted ACTION_OPEN_DOCUMENT_TREE are addressed by their path relative to the tree, so classify_link / navigate_to / history work byte-for-byte unchanged and links between sibling markdown files resolve.

Renderer fixes (c1bbf93) — affect desktop too:

  • Blockquote accent bar was painted with ui.max_rect() from inside the Frame closure, i.e. the space available to the frame rather than what it occupies, so it ran past the bottom of the quote into the following paragraph.
  • Table columns were measured by flattening each cell to a String and laying it out in one 14px proportional font. Inline code renders monospace, which is wider, so code cells were handed columns too narrow to hold them and wrapped even with hundreds of free pixels to the right. Cells are now split into (text, monospace) runs and measured in the family they actually render in.
  • Column gutter 8px → 16px, plus 6px of stripe padding.

Android fixes (1ea21d1):

  • Images never rendered. ImageCache used std::fs::read, which cannot reach an image addressed relative to a granted SAF tree. New src/android_io.rs is the single path→bytes resolver; the markdown reader and image loader both go through it.
  • Opening a single file after opening a folder failed — the absolute cache path was being run through the tree resolver.
  • Cancelling a picker left it permanently in-flight (button disabled, 200ms repaint timer running for the rest of the session).
  • The launching document was loaded twice, leaving a bogus back-history entry.
  • The advertised ACTION_SEND filter was never handled (EXTRA_STREAM, not intent.data).
  • Titles showed primary%3ADocuments%2Fnotes.md instead of the display name.
  • Release APKs were signed with the per-machine debug keystore, so every CI release had a different signing identity and Android would refuse to upgrade across it. Now uses MDVIEW_KEYSTORE* and warns when unset.
  • Gradle shipped an APK with no native code if cargo-ndk hadn't run; now fails with the command to run.
  • publish didn't wait for build-android, so releases were usually cut before the APK existed.
  • versionName/versionCode derive from Cargo.toml.

Verification

  • 155 desktop tests green
  • Rust cross-compiles clean for all four Android ABIs
  • assembleRelease produces eu.io_com.mdview 0.1.4 (versionCode 104) with arm64-v8a, armeabi-v7a, x86, x86_64
  • R8 keeps every JNI-reachable RustBridge/MainActivity method, verified with apkanalyzer dex packages
  • Both renderer fixes confirmed by before/after screenshots

Note on the package name

eu.io_com.mdview uses an underscore. A hyphen (io-com) is not a legal Kotlin/Java identifier, cannot be a Gradle namespace, and fails Android's applicationId validator, which accepts only [a-zA-Z0-9_] and ..

Andreas Daasch and others added 10 commits June 20, 2026 23:06
Port the desktop markdown viewer (eframe/egui) to Android via eframe's
android-game-activity backend, reusing 100% of the parser, renderer, theme,
navigation and link logic. Desktop logic moves into the `mdview` library crate
so the Android `cdylib` (`android_main` in src/lib.rs) and the desktop binary
share one codebase.

JNI bridge (src/android_shim.rs + RustBridge.kt/MainActivity.kt):
- Capture the application ClassLoader from the Activity at init and resolve the
  bridge class through it. android_main runs on a GameActivity-spawned thread
  where JNI FindClass uses the system class loader and cannot see app classes,
  which previously made the file picker and "open with" fail silently.

Cross-file links (the main feature):
- Open a folder via ACTION_OPEN_DOCUMENT_TREE; documents are addressed by their
  path relative to the granted tree, so classify_link / navigate_to / history
  work unchanged and links between sibling markdown files resolve.
- read_path resolves a tree-relative path to a document URI by walking the tree
  by display name (provider-independent, works on Android 13+).
- In-app file browser lists markdown/text files in the granted folder.

Keep desktop behavior unchanged:
- All Android UI/behavior is `#[cfg(target_os = "android")]`. The compact
  symbol-only top bar, file-name-in-TOC, and folder browser apply only to
  Android; desktop keeps its labelled toolbar and file-path display.

Modularization + tests:
- Extract pure path helpers (normalize_rel, is_markdown_path) into
  src/android_paths.rs, compiled and unit-tested on all platforms.

Also adds the Gradle project, AndroidManifest, signing/proguard config, app
icons, the Android release-build CI job, README instructions, and an
android/.gitignore so build artifacts stay out of git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Blockquote accent bar:
The bar was painted from inside the Frame closure using `ui.max_rect()`,
which is the space still *available* to the frame rather than the space it
ends up occupying. The bar therefore started at the top of the quote and ran
well past its bottom, bleeding into the following paragraph. Paint it after
`Frame::show` returns instead, using the frame's own response rect, so it
spans exactly the quote box. Nested quotes each get a correctly sized bar.

Table column widths:
`compute_column_widths` measured every cell by flattening it to a String and
laying that out in a single 14px proportional font. Inline code renders in
the monospace family, which is materially wider at the same point size, so
code cells were handed columns too narrow to hold them and wrapped even when
the table had hundreds of free pixels to its right. Cells are now split into
`(text, monospace)` runs and each run measured in the family it is actually
rendered in.

Column spacing: the inter-column gutter goes 8px -> 16px and the row stripe
gains 6px of horizontal padding, so cell text no longer sits flush against
the stripe edge or its neighbour.

Adds 4 tests covering monospace measurement, mixed-run accumulation and the
hard-break line reset.
…gaps

Images never rendered on Android. `ImageCache` called `std::fs::read`, but an
image referenced from a markdown file opened through the Storage Access
Framework is addressed by a path relative to the granted folder tree, not by a
filesystem path — every image fell back to its `[Image: alt]` placeholder. Adds
`src/android_io.rs` as the one place that turns an app path into bytes, and
routes both the markdown reader and the image loader through it.

That also fixes a clash in the old resolver: once a folder was granted, *every*
non-`content://` path was treated as tree-relative, including the absolute
cache path the single-file picker writes to. Opening a single file after
opening a folder failed with "not found in the opened folder". The resolver now
tries a readable absolute path before the tree lookup.

Also in this change:

- Cancelling either picker left `in_flight` set forever, so the toolbar button
  stayed disabled and a 200ms repaint timer ran for the rest of the session.
  Cancellation is now reported as a distinct `PICKER_CANCELLED` sentinel
  instead of being indistinguishable from "no answer yet".
- The launching document was loaded twice — `android_main` read the intent and
  `onCreate` queued the same URI for the first frame to consume — leaving a
  spurious second entry in the back history. Only `onNewIntent` queues now.
- The manifest advertised an ACTION_SEND filter that was never handled:
  ACTION_SEND delivers via EXTRA_STREAM, not `intent.data`, so sharing a file
  to mdview opened an empty window.
- Documents were titled with the tail of their URI, i.e.
  `primary%3ADocuments%2Fnotes.md`. Queries OpenableColumns.DISPLAY_NAME, with
  a tested URI-decoding fallback.
- `assembleRelease` signed with the debug keystore, which differs per machine —
  every CI release got a different signing identity and Android refuses to
  upgrade across a signature change. Uses MDVIEW_KEYSTORE* when configured and
  warns loudly when it is not.
- Gradle packaged an APK with no native code when cargo-ndk had not run,
  producing an APK that installed and then died on UnsatisfiedLinkError. It now
  fails with the command to run.
- `publish` did not wait for `build-android`, so the release was usually cut
  before the APK artifact existed. The APK is also named after the version now.
- versionName/versionCode come from Cargo.toml instead of being hardcoded.

Verified: desktop 155 tests green; Rust cross-compiles for all four Android
ABIs; `assembleRelease` produces eu.io_com.mdview 0.1.4 (versionCode 104) with
all four ABIs, and R8 keeps every JNI-reachable RustBridge/MainActivity method.
… polling

Release APK 49.0 MB -> 41.4 MB.

Dependencies: the Android build pulled 196 crates, 33 of which it could not
use. `clap` parses a command line Android never has, `notify` watches via
inotify (which does not work on the SAF-backed storage every Android document
lives on), and `env_logger` was dead on every platform — nothing ever called
`init`. They were in the shared `[dependencies]` section, so they compiled into
the APK to sit unused.

`eframe` was in that shared section too, which quietly defeated the existing
`default-features = false` on the Android entry: Cargo unifies features across
dependency edges, so the plain `eframe = "0.33"` re-enabled the defaults and the
Android feature list was decorative. Each target now declares its own eframe.
That also drops `accesskit`, which has no Android platform adapter and was pure
weight there. Desktop's dependency graph is byte-for-byte unchanged — verified
with `cargo tree` on both targets.

`src/watcher.rs` grows an Android stub whose constructor always fails, which is
what lets `notify` go. Callers already treated a failed watcher as "no live
reload", so `app.rs` needed no changes.

Icons: all 10 mipmaps were byte-identical 479 KB copies of the 570x570 source,
4.7 MB of it, with the same file serving mdpi and xxxhdpi. Now resized per
density (48/72/96/144/192) with circle-masked round variants, 152 KB total.

Manifest: dropped READ/WRITE_EXTERNAL_STORAGE and requestLegacyExternalStorage.
Everything arrives as a URI carrying its own grant, so these only widened the
app's reach and invited a Play sensitive-permission review.

The VIEW filter mixed mimeType with pathPattern and host="*". A content URI's
path is an opaque document ID, so the extension patterns rejected exactly the
URIs the filter existed to catch, and file:// URIs have no host to match. Split
into a typed filter (content/file + specific MIME types) and an untyped file://
filter matched on extension. Dropped the blanket `text/*` so mdview stops
offering itself for HTML, CSS and CSV; kept application/octet-stream because
that is how many file managers report .md.

JNI: all three drain functions ran a bridge call — thread attach plus dispatch —
on every frame, roughly 180 round trips per second while idle. The pickers now
only poll while one is actually in flight, and the intent slot is polled on a
250 ms timer. Also fixed the repaint request, which kept frames coming for the
file picker but not the folder picker.

Plus: vendored gradlew.bat so the project builds on Windows, removed the
write-only `cached_uri_path` field, and restored trailing newlines.

Verified: 155 desktop tests green; all four Android ABIs compile without
warnings; assembleRelease produces eu.io_com.mdview 0.1.4 (versionCode 104)
with no storage permissions, the four intended intent filters, all ten icon
densities and all four ABIs packaged.
`parse_blocks_until` — which parses the body of a blockquote and of a list
item — handled headings, paragraphs, code blocks, lists, nested quotes, rules
and HTML, but had no arm for `Start(Table)`. The table's events fell through to
the catch-all and were skipped, after which the cell text was swept up by the
bare-inline collector. A table inside a quote rendered as a single
run-together paragraph:

    CmdWhat`ls -la`List all files

Both containers route through this one function, so tables in list items were
broken the same way. `collect_bare_inlines` already stopped at `Start(Table)`,
so the new arm is all that was missing.

Adds tests for a table in a quote (including that the cells do not also leak
into a paragraph), in a list item, and two quotes deep.
Version 0.1.4 -> 0.2.0: Android is a new supported platform, so this is a
minor bump rather than a patch. The Gradle build derives versionName and
versionCode from Cargo.toml, so the APK follows automatically (0.2.0 / 200).

Release pipeline:
- `build-android` never installed the Android Rust targets. cargo-ndk does not
  install them itself, so the job would have failed with "target may not be
  installed" the first time a release was cut with an Android job present.
- Dropped the GTK/xcb apt install from `build-android`. Nothing in the Android
  dependency graph links against them or needs them from a build script — rfd
  is desktop-only now — so it was several minutes of setup per run for nothing.
- Added an APK verification step between build and upload: checks the signature,
  asserts native code is present for all four ABIs, and asserts the APK's
  versionName matches the release being cut. The ABI assertion guards the exact
  failure the copyJniLibs change was about — an APK that installs and then dies
  on UnsatisfiedLinkError.

New CI workflow: the release workflow was the only thing that ever built the
project, so a break surfaced while cutting a release. `ci.yml` runs tests and
an arm64 Android build (native lib + APK) on every push and pull request.
Clippy runs but does not gate: the tree has 15 pre-existing warnings, and
turning on `-D warnings` now would fail every build.

README: documents Android usage — folder access vs. single-file access and why
the folder route is the one that makes cross-file links and images resolve —
and notes that live reload is desktop-only because SAF exposes no watchable
path.
The new CI workflow caught this on its first run, and `release.yml` had the
same ordering, so the first Android release would have failed the same way.

`android-actions/setup-android` shells out to `sdkmanager`, which is compiled
for Java 17. Running it before `actions/setup-java` leaves it on the runner's
default JDK, and it dies while accepting licences:

    UnsupportedClassVersionError: com/android/sdklib/tool/sdkmanager/SdkManagerCli
    has been compiled by a more recent version of the Java Runtime (class file
    version 61.0), this version of the Java Runtime only recognizes class file
    versions up to 55.0

Locally this never showed because JAVA_HOME already pointed at a JDK 21.
`android-actions/setup-android` forwards the `packages` input straight to
sdkmanager, so a YAML block scalar arrives as one argument with newlines
embedded in it:

    Warning: Failed to find package 'platforms;android-34
    build-tools;34.0.0
    ndk;26.3.11579264'
    Error: The process '.../sdkmanager' failed with exit code 1

release.yml carried the same block-scalar form from the original port, so the
Android release job would have failed here too, immediately after the Java
ordering problem.
`inputs.prerelease` is only populated on workflow_dispatch, so a release cut by
pushing a tag — the documented path in the README — always came out as a full
release regardless. Now a tag with a semver pre-release suffix (v1.2.3-rc1,
-beta, -alpha) is marked as a prerelease too, and the dispatch input still
forces it.

Also documents the existing-release branch: pre-creating a release with the
notes and flags you want, then letting this job upload into it, is a supported
way to control the result.
CI builds one ABI to keep the check cheap, but `abiFilters` and `copyJniLibs`
each hardcoded all four, so the guard failed over libraries the build had
never been asked to produce:

    No libmdview.so for ABI(s): armeabi-v7a, x86_64, x86

Both now read one `targetAbis` list, overridable with `-Pmdview.abis`, so the
set that gets packaged and the set that must exist cannot drift apart. CI
passes `-Pmdview.abis=arm64-v8a` to match its `cargo ndk -t` flag.

The guard is unchanged for full builds: a four-ABI build with a missing
library still fails, verified locally alongside the single-ABI path and the
four-ABI release.
@adaasch
adaasch merged commit 1358756 into master Aug 3, 2026
2 checks passed
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