Repository navigation
Add Android port (eu.io_com.mdview) with folder-based cross-file link navigation - #5
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports the desktop markdown viewer to Android via eframe's
android-game-activitybackend, reusing the parser, renderer, theme, navigation and link logic unchanged. Desktop logic moves into themdviewlibrary crate so the Androidcdyliband 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 grantedACTION_OPEN_DOCUMENT_TREEare addressed by their path relative to the tree, soclassify_link/navigate_to/ history work byte-for-byte unchanged and links between sibling markdown files resolve.Renderer fixes (
c1bbf93) — affect desktop too:ui.max_rect()from inside theFrameclosure, 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.Stringand 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.Android fixes (
1ea21d1):ImageCacheusedstd::fs::read, which cannot reach an image addressed relative to a granted SAF tree. Newsrc/android_io.rsis the single path→bytes resolver; the markdown reader and image loader both go through it.ACTION_SENDfilter was never handled (EXTRA_STREAM, notintent.data).primary%3ADocuments%2Fnotes.mdinstead of the display name.MDVIEW_KEYSTORE*and warns when unset.publishdidn't wait forbuild-android, so releases were usually cut before the APK existed.Cargo.toml.Verification
assembleReleaseproduceseu.io_com.mdview0.1.4 (versionCode 104) with arm64-v8a, armeabi-v7a, x86, x86_64RustBridge/MainActivitymethod, verified withapkanalyzer dex packagesNote on the package name
eu.io_com.mdviewuses an underscore. A hyphen (io-com) is not a legal Kotlin/Java identifier, cannot be a Gradlenamespace, and fails Android'sapplicationIdvalidator, which accepts only[a-zA-Z0-9_]and..