hue on Android — requirements & architecture
Status: shipped v0 — every requirement confirmed on a physical arm64 device (Xiaomi 11T Pro), the emulator having been the development target. The one exception is pinch-zoom (AND6), which is host-tested but not yet exercised on hardware. · Date: 2026-08-02 · Scope: the Android port of the GUI sink — apps/hue (android_glue.d, android_paths.d, gui_touch.d, the version (Android) gates), libs/raylib-text (FontSources), libs/syntax (GrammarRegistry.fromSonames), nix/packages/android/, apps/hue/android/.
hue runs on Android as a pure NativeActivity APK — no Java, no DEX (hasCode="false"). raylib's PLATFORM_ANDROID backend (android_native_app_glue + EGL/GLES2) owns android_main() and calls the library's main(), so the whole desktop GUI — the sparkles:ui widget pipeline, RaylibCanvas, FontSet, tree-sitter highlighting, the markdown preview, ghostty ansi fences, the explorer, twoslash — carries over. The APK, and every native dependency in it, is built by nix (no Gradle): see nix/packages/android/.
Architecture decisions
| Decision | Choice | Where |
|---|---|---|
| Rendering backend | raylib cross-built for PLATFORM_ANDROID (static, per ABI); RaylibCanvas unchanged | nix/packages/android/raylib.nix |
| ABIs | arm64-v8a (devices) + x86_64 (emulator); the dual-ABI ldc-android (dlang.nix) carries one ldc2.conf section per target. API level 26 throughout — ndk.minSdk is the single source for the clang wrappers, every native dep's platform flag, and aapt2's --min-sdk-version | nix/packages/android/ndk.nix, dlang.nix feat/ldc-wasm |
| Entry / dispatch | main() runs inside the activity: pickBackend answers gui unconditionally; TUI modules stay out of the module graph | apps/hue/src/app.d |
| Font resolution | fontconfig-free: FontSet.FontSources scans the extracted fonts/ dir + /system/fonts; coverage from build-time fc-query .charset sidecars. The font set itself is shared with the desktop (nix build .#sparkles-fonts, also reachable from terminal --font-dir): Maple Mono NF CN primary + Uiua386 (codepoint-mapped) + FiraCode/DejaVu fallbacks | libs/raylib-text/…/font_set.d, nix/packages/fonts.nix |
| Grammars | parsers ship as lib/<abi>/libtree_sitter_<lang_>.so, dlopen'd by bare soname from the app's linker namespace (GrammarRegistry.fromSonames); queries are extracted assets | libs/syntax/…/ts/registry.d, ts-grammars.nix |
| Assets | one assets/ bundle (fonts + sidecars, grammar queries, sample docs), extracted by D over AAssetManager on first run, keyed by bundle-hash, driven by asset-manifest.txt (AAssetDir cannot list subdirectories) | android_glue.d, nix/packages/android/hue.nix |
| Lifecycle | rotation resizes IN PLACE (the nix raylib build carries raylib-android-in-place-resize.patch — stock raylib — verified through the released 6.0 tag — never resizes on Android), and main() exits the process when the activity is destroyed — a statically linked druntime cannot rt_init twice in a reused process | raylib.nix patch, manifests, app.d |
| Input | TouchScroller (pure, host-tested): tap = click, drag = kinetic scroll + fling, long-press = selection, pinch = font zoom; a five-segment bottom toolbar covers the keyboard essentials; hardware keyboards keep every desktop binding | apps/hue/src/gui_touch.d, gui.d |
| Logging | a CoreLogger subclass writing to logcat tag hue (stderr goes nowhere in an activity), at warning — hue emits only degradation warnings | android_glue.d |
| Debug hooks | <dataDir>/hue-debug.env (KEY=VALUE) loads into the environment at boot, re-enabling every HUE_GUI_* golden/screenshot hook on-device | android_paths.d, android_glue.d |
Requirements
| ID | Requirement | Status | Where |
|---|---|---|---|
| AND1 | nix build .#hue-apk produces an installable dual-ABI NativeActivity APK, fully nix-built (aapt2 + zipalign + apksigner) and bit-reproducible; debug vs release is a build knob, and a release build refuses the checked-in key. Distribution adds a third state — an unsigned release variant, because nix must never hold the signing key (FDR1) | full | nix/packages/android/{hue,build-apk}.nix |
| AND2 | The GUI sink boots on-device with no CLI: built-in document, asset bundle extracted idempotently, logcat logging | full (device) | app.d, android_glue.d |
| AND3 | Fonts resolve without fontconfig: Maple Mono NF CN primary (4 styled faces), FiraCode Nerd Font Mono + DejaVu fallbacks, /system/fonts last; styled faces by the sibling naming convention, fallbacks ranked so a Regular face wins | full | font_set.d FontSources |
| AND4 | Syntax highlighting on-device: all bundled grammars as soname-dlopen'd native libs, queries from assets, plain-text degrade intact (totality) | full (device) | registry.d fromSonames |
| AND5 | The markdown preview incl. ```ansi fences (libghostty-vt cross-built, -Dsimd=false) renders on-device | full (device) | libghostty-vt.nix, gui_ansi.d |
| AND6 | Touch: drag scroll + fling, tap = click, long-press selection, pinch zoom, toolbar for theme/view/tree/line-numbers (and copy while a selection is live), system back closes tree → exits | full (pinch: host-tested, on-device untested) | gui_touch.d, gui.d |
| AND7 | Lifecycle: pause/resume identical frames; rotation resizes in place — same process, scroll/document/theme preserved, reflow at the new width | full (device) | raylib patch, manifests, app.d |
| AND8 | The explorer browses the extracted sample docs (markdown, D, twoslash) with file-type icons | full (device) | app.d (Android tree root), hue.nix docs |
| AND9 | On-device goldens: hue-debug.env + HUE_GUI_SCREENSHOT (data-dir-anchored) round-trip via adb shell run-as | full | gui.d, android_paths.d |
| AND10 | hue-apk-repo: the whole tracked repository (sources, markdown, text aux; minus docs/.vitepress and binaries) embedded as the explorer browse surface — ~1.5k files, ~1 s first-run extraction | full (device) | hue.nix repoDocsTree |
| AND11 | CLI-less diff/PR entry: Android has no command line, so diff and PR sessions must be reachable through the UI — the explorer opens a worktree diff from a GitStatus-badged file, a forge PR picker lists PRs over the forge seam (DPR7), and an intent/share handler opens a forge PR URL from the browser/share sheet directly into hue. Forge tokens come from the config file (CFG12) — plain-text storage, documented. | not started (planned) | DPR7; CFG12; proposed intent filter |
| AND12 | Touch review vocabulary: tap expands/collapses a hunk; long-press opens the stage / discard / comment menu (the existing gui_touch.d tap / long-press machinery); next/prev-hunk and next-conflict as toolbar buttons; destructive ops (DST4) confirm through notifier-style popups with touch-sized targets. Inline editing (DST5) is deferred until the UIA9 editor component has soft-keyboard support. | not started (planned) | gui_touch.d; DST*; UIA9 |
| AND13 | No type overlay on device: the diff type overlay (DVT) needs twoslash-extract, a DMD frontend, dub describe and a git worktree — none of which exist inside the APK. Android renders the diff without types and without verdict badges, and must not present the affordance at all (no greyed-out control, no notice on every file) — a capability the platform lacks is absent, not broken. A PR session viewed on Android is a text-and-comments review. | not started (planned) | DVT7 degradation; AND11 |
| AND14 | The leader menu is the command surface. Android has no keyboard, so every command reachable only by a key is unreachable there. The lantern panel already enumerates them and already carries hit ids, so making its rows tappable — tap activates, tap a group drills down, the system Back key pops one level — turns the whole keymap into a touch menu from one definition rather than a parallel toolbar that would drift from it (LTN13). | not started (planned) | LTN13; lantern_view hit ids; Key.back |
Desktop-parity checklist
Interaction parity is honest, not aspirational: touch covers the reading workflows; full parity needs a (BT) keyboard, whose events flow through raylib's Android input into every existing binding.
| Desktop feature | On Android |
|---|---|
| Scroll (wheel / j k / PgUp…) | touch drag + fling; keyboard works |
| Theme cycling (← →) | toolbar ◀ thm / thm ▶ |
Raw ↔ preview (v) | toolbar view |
| Explorer (e) | toolbar tree; back button closes |
| Line numbers (l) | toolbar ln № |
| Font size (Ctrl-±) | pinch zoom |
| Click (fold chevrons, tree, buttons) | tap |
| Text/table selection (mouse drag) | long-press, then drag |
Copy (y / copy buttons) | works — the JNI ClipboardManager bridge (sparkles.android.clipboard over an ImportC'd <jni.h>; raylib's own Android clipboard is a no-op) |
Search /, goto g, copy-modes y/t | keyboard-only (no soft-keyboard IME through raylib) |
Set navigation [ ] i | keyboard-only (explorer covers browsing) |
| Fullscreen F11 | n/a — the surface is the screen |
| Hover popups (twoslash) | tap a token (the pointer rests where the last tap landed) |
| Window title | n/a |
Build & run
Every Android output exists on the build hosts the NDK ships a toolchain for — x86_64-linux and aarch64-darwin — and nowhere else: aarch64-linux has no NDK, so these attributes do not exist there (nix/packages/android/host.nix). Both hosts build both ABIs. The emulator is the exception that follows the host: hue-emulator boots a system image of the host's own architecture (x86_64 on Linux, arm64 on Apple Silicon).
The two hosts' APKs are equivalent but not bit-identical. D bakes __FILE__ into template instances, and those paths name the host's own ldc store path (43 strings in the stripped terminal library) and, on an unsandboxed Mac, the random build directory of druntime (10 more); D has no -ffile-prefix-map to normalise them. Reproducibility (AND1) therefore holds per host, and the artifact that is rebuilt and compared — the F-Droid release (fdroid.md) — is built on x86_64-linux.
$ nix build .#hue-apk # both ABIs + assets, signed, reproducible
$ nix build .#hue-apk-repo # …with the whole repo embedded (separate package id)
$ nix build .#hue-apk-unsigned # the release artifact; signed outside nix (fdroid.md)
$ nix develop .#android # adb/aapt2 on PATH + helpers
$ hue-emulator & # host-ABI AVD of the SDK platform (created on first use)
$ hue-adb-install result/hue.apk
$ hue-logcat # tags: hue, raylib + crash channelsOn-device goldens: write HUE_GUI_* lines to a file, push via adb push … /data/local/tmp/ && adb shell run-as dev.petar_kirov.sparkles.hue cp … files/hue-debug.env (direct run-as sh -c 'cat > …' writes are SELinux-denied), relaunch, pull the PNG the same way.
Traps (each cost a debugging round)
InitWindow(w, h)is not ignored on Android: a non-zero size letterboxes onto the surface.InitWindow(0, 0)= native resolution.- Final links need
-Wl,-u,ANativeActivity_onCreate(glue object otherwise unreferenced) and-Wl,--wrap=fopen(raylib's asset-manager fopen routing + its__real_fopen), plus-Wl,-z,max-page-size=16384on every.so(16 KB-page devices). - ImportC vs bionic:
-P-U__SIZEOF_INT128__(kernel headers typedef__int128);android_native_app_glue.hcannot be ImportC'd at all — the glue structs are hand-mirrored insparkles.android.activity.<jni.h>can be, and is (libs/android/c/jni_c.c), so the clipboard bridge is D rather than C — ImportC's preprocessor is the host cc, so the cross build points it at the NDK sysroot with-P-I${ndk.sysrootInclude}. - JNI method calls go through the
…A(jvalue[]) forms. This is a legibility choice, not an ABI requirement: LDC'sextern(C)variadic support on AArch64 was measured correct on both ABIs — verified by running a mixed pointer/int/double/stack-spill matrix underqemu-aarch64against anaarch64-linux-androidC control (identical results), and by diffing LDC's against clang's codegen, which is instruction-identical foraarch64-linux-gnu(no shuffling; AAPCS64 passes variadic and fixed args alike) and forarm64-apple-macos(stp x3, x4, [sp]— Apple's stack-passing rule, which LDC implements). Thejvalue[]forms are simply explicit about each argument's JNI type. - A statically linked druntime cannot
rt_inittwice: Android reuses the process across activity recreations → exit the process whenmainreturns. - Stock raylib (verified through the released 6.0 tag) never resizes on Android (empty
CONFIG_CHANGEDstub, noWINDOW_RESIZEDcase, resume path pins the old buffer geometry) — an in-place rotation keeps rendering the stale orientation, compositor-scaled.raylib-android-in-place-resize.patchfixes it — confirmed on hardware, not just the emulator (AND7); without the patch, keep orientation OUT ofconfigChangesso a restart re-inits at the new size. dub test's silence is not coverage:version (Android)code only compiles under the cross triple — the nixlibhue-androidbuild is the type-check (e.g. sparkles' loggers are IES-only; plain-string calls hid in gated code).builtinThemeshas alias keys (catppuccinmocha≍catppuccin-mocha): theme-cycling one step can land on an identically-colored twin.run-asneedsandroid:debuggable="true"— debug-signed is not enough. aapt2's debug-mode flag injects it for debug builds, so a release APK does not carry it (and cannot be signed with the checked-in key).- The APK is bit-reproducible, but only for a fixed font input: the Maple Mono build is not itself reproducible (measured; see nix/packages/maple-mono).
- Targeting API 36 makes edge-to-edge mandatory — Android 15 still honoured
windowOptOutEdgeToEdgeEnforcement, Android 16 ignores it — and nothing here reads window insets, so the bottom toolbar is expected to sit under the gesture navigation bar. Recorded asHUE-O4; it feeds the mobile UI redesign rather than getting a per-surface fudge.