Skip to content

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 ​

DecisionChoiceWhere
Rendering backendraylib cross-built for PLATFORM_ANDROID (static, per ABI); RaylibCanvas unchangednix/packages/android/raylib.nix
ABIsarm64-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-versionnix/packages/android/ndk.nix, dlang.nix feat/ldc-wasm
Entry / dispatchmain() runs inside the activity: pickBackend answers gui unconditionally; TUI modules stay out of the module graphapps/hue/src/app.d
Font resolutionfontconfig-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 fallbackslibs/raylib-text/…/font_set.d, nix/packages/fonts.nix
Grammarsparsers ship as lib/<abi>/libtree_sitter_<lang_>.so, dlopen'd by bare soname from the app's linker namespace (GrammarRegistry.fromSonames); queries are extracted assetslibs/syntax/…/ts/registry.d, ts-grammars.nix
Assetsone 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
Lifecyclerotation 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 processraylib.nix patch, manifests, app.d
InputTouchScroller (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 bindingapps/hue/src/gui_touch.d, gui.d
Logginga CoreLogger subclass writing to logcat tag hue (stderr goes nowhere in an activity), at warning — hue emits only degradation warningsandroid_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-deviceandroid_paths.d, android_glue.d

Requirements ​

IDRequirementStatusWhere
AND1nix 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)fullnix/packages/android/{hue,build-apk}.nix
AND2The GUI sink boots on-device with no CLI: built-in document, asset bundle extracted idempotently, logcat loggingfull (device)app.d, android_glue.d
AND3Fonts 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 winsfullfont_set.d FontSources
AND4Syntax 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
AND5The markdown preview incl. ```ansi fences (libghostty-vt cross-built, -Dsimd=false) renders on-devicefull (device)libghostty-vt.nix, gui_ansi.d
AND6Touch: 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 → exitsfull (pinch: host-tested, on-device untested)gui_touch.d, gui.d
AND7Lifecycle: pause/resume identical frames; rotation resizes in place — same process, scroll/document/theme preserved, reflow at the new widthfull (device)raylib patch, manifests, app.d
AND8The explorer browses the extracted sample docs (markdown, D, twoslash) with file-type iconsfull (device)app.d (Android tree root), hue.nix docs
AND9On-device goldens: hue-debug.env + HUE_GUI_SCREENSHOT (data-dir-anchored) round-trip via adb shell run-asfullgui.d, android_paths.d
AND10hue-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 extractionfull (device)hue.nix repoDocsTree
AND11CLI-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
AND12Touch 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
AND13No 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
AND14The 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 featureOn 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/tkeyboard-only (no soft-keyboard IME through raylib)
Set navigation [ ] ikeyboard-only (explorer covers browsing)
Fullscreen F11n/a — the surface is the screen
Hover popups (twoslash)tap a token (the pointer rests where the last tap landed)
Window titlen/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.

console
$ 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 channels

On-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=16384 on every .so (16 KB-page devices).
  • ImportC vs bionic: -P-U__SIZEOF_INT128__ (kernel headers typedef __int128); android_native_app_glue.h cannot be ImportC'd at all — the glue structs are hand-mirrored in sparkles.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's extern(C) variadic support on AArch64 was measured correct on both ABIs — verified by running a mixed pointer/int/double/stack-spill matrix under qemu-aarch64 against an aarch64-linux-android C control (identical results), and by diffing LDC's against clang's codegen, which is instruction-identical for aarch64-linux-gnu (no shuffling; AAPCS64 passes variadic and fixed args alike) and for arm64-apple-macos (stp x3, x4, [sp] — Apple's stack-passing rule, which LDC implements). The jvalue[] forms are simply explicit about each argument's JNI type.
  • A statically linked druntime cannot rt_init twice: Android reuses the process across activity recreations → exit the process when main returns.
  • Stock raylib (verified through the released 6.0 tag) never resizes on Android (empty CONFIG_CHANGED stub, no WINDOW_RESIZED case, resume path pins the old buffer geometry) — an in-place rotation keeps rendering the stale orientation, compositor-scaled. raylib-android-in-place-resize.patch fixes it — confirmed on hardware, not just the emulator (AND7); without the patch, keep orientation OUT of configChanges so 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 nix libhue-android build is the type-check (e.g. sparkles' loggers are IES-only; plain-string calls hid in gated code).
  • builtinThemes has alias keys (catppuccinmocha ≍ catppuccin-mocha): theme-cycling one step can land on an identically-colored twin.
  • run-as needs android: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 as HUE-O4; it feeds the mobile UI redesign rather than getting a per-surface fudge.