The Android client adopts the Skia console — one gamepad UI on three platforms, the Compose mirror deleted #301

Merged
enricobuehler merged 5 commits from worktree-android-skia-console into main 2026-08-18 15:01:53 +00:00
Owner

Implements punktfunk-planning/design/android-skia-console-port.md (WP1–WP7 + hosting), reversing the 2026-08-10 "port rejected" verdict — its key premise (no Android Skia prebuilts) was stale at skia-safe 0.99, and the refactor constraint was lifted. Five commits, net −152 LOC while adding the entire native host: the third gamepad-UI implementation is gone.

What this is

The pf-console-ui shell the Vulkan session binary shows on Windows/Linux now fronts Android's gamepad/console mode too, drawn by the native crate through Skia's GL backend on an EGL surface over the SurfaceView. Kotlin keeps every service (trust store, discovery, mTLS library fetch + cache, pairing, WoL, settings) and feeds the console's existing model seam (ConsoleShared / LibraryShared / ConsoleBus) over JNI — the desktop's 1,100-line service was bridged, not ported.

By commit

  • 84db02ef — the shell becomes portable (pf-client-core, pf-console-ui, native host). Portable modules split out (menu_nav — with the stick hysteresis Apple and Android each had to add on glass, now one implementation with tests; audio_format, decoder_pref, console), all re-exported under their old paths. pf-console-ui gains the vulkan-overlay default feature (the session binary is unchanged), a Key enum for SDL-free input, a SettingsStore seam, Viewport{insets,scale}, Platform-filtered settings rows, and the portable Console driver both hosts hold. skia-safe features are target-specific, so the desktop's prebuilt key — and the flatpak's pinned archive — cannot drift.
  • 6d1c7187 — Android hosts it. The Kotlin bridge (SkiaConsole / SkiaConsoleShell / ConsoleJson), raw pad samples through the shared MenuNav, touch/keyboard/remote input, deep links, session edges; the Android-only settings rows ride Settings::extra under android.* keys so the desktop struct grows no one-platform fields; Controllers/Licences stay Compose, opened via the new ConsoleCmd::OpenPlatformScreen. Also a test-only fixed-step clock for the screenshot dump — the aurora's phase was wall-clock time, so the dump could never be a pixel gate under load; with it, this branch renders byte-identical PNGs to main on all 37 desktop scenes.
  • f3a69d5b — armeabi-v7a joins. rust-skia publishes no armv7 archive; building it through skia-bindings itself (FORCE_SKIA_BUILD=1) takes ~80 s on an M-series Mac. The console host compiles on all three shipping ABIs.
  • 428fef2b — the Compose console is deleted (~6.5 kLOC + its tests + its Roborazzi store scenes). Kept: the Gamepad chrome files (the D7 platform screens and the touch UI's pad nav draw with them), the touch library, GamepadUi.kt. debug.punktfunk.console_backend=none forces the touch UI for on-glass triage.
  • ba227057 — the archives are hosted where git assets live: public release unom/skia-binaries @ 0.99.0 (R2-backed), mirroring rust-skia's release layout so the {tag}/{key} template fits verbatim. kit/build.gradle.kts bakes the URL in as the default — zero config for dev and CI, GitHub out of the Android build path; property/env/CI-var remain as staging overrides. Per skia-safe bump: rebuild the armv7 archive (recipe beside the sha256 table), cut a new release, mirror the 64-bit assets.

Desktop non-regression (the plan's §5 contract)

  • clients/session keeps default features; the Vulkan overlay, SDL path and in-stream chrome are the same code reached through the Console façade.
  • The resolved desktop skia key stays jpegd-jpege-pdf-textlayout-vulkan (flatpak pin untouched).
  • Tests only grew: pf-console-ui 180 → 181, pf-client-core 221.
  • Pixel-identical: with the fixed-clock dump, main vs this branch = AE 0 / identical md5 on all 37 scenes.
  • Additive desktop changes only: stick hysteresis in MenuNav (pinned by test), the settings rows' platform filter (desktop shows exactly the set it did — pinned by test), serde derives on the model types.

Verified

  • Container gate (fmt, clippy -D warnings --all-targets, plain build, tests) green; cargo ndk clippy -D warnings green on arm64-v8a / armeabi-v7a / x86_64; every ABI's skia-bindings log shows DOWNLOAD AND INSTALL SUCCEEDED (the silent-source-build trap).
  • :app:testDebugUnitTest + :kit:testDebugUnitTest green; assembleDebug with wiped skia-bindings dirs re-fetches all three ABIs from the baked default.
  • On glass (NP3, user + post-deletion re-check): the console comes up on EGL 1.5 / GLES 3, renders the desktop-identical home (carousel, OS marks, presence pips, aurora, legend), input and navigation work as expected.

Known costs / follow-ups (named in the plan)

  • TalkBack does not reach the console screens (SurfaceView is opaque to a11y); the touch UI remains fully accessible. Mitigation sketched in the plan (an AccessibilityNodeProvider over the shell's published hit boxes).
  • The Compose console's marketing shot scenes are gone — console store shots come from the desktop screenshot dump (same pixels by construction) or device capture.
  • Console TV/32-bit box pass and a pad-heavy session are still worth an on-glass round; a Deck --browse + one Windows session before merge is the desktop's final spot check.
  • The phone's design-unit scale floor (density × 0.6 under the couch formula) is a tuning knob for glass.
Implements `punktfunk-planning/design/android-skia-console-port.md` (WP1–WP7 + hosting), reversing the 2026-08-10 "port rejected" verdict — its key premise (no Android Skia prebuilts) was stale at skia-safe 0.99, and the refactor constraint was lifted. Five commits, **net −152 LOC** while adding the entire native host: the third gamepad-UI implementation is gone. ## What this is The `pf-console-ui` shell the Vulkan session binary shows on Windows/Linux now fronts Android's gamepad/console mode too, drawn by the native crate through **Skia's GL backend on an EGL surface** over the `SurfaceView`. Kotlin keeps every service (trust store, discovery, mTLS library fetch + cache, pairing, WoL, settings) and feeds the console's existing model seam (`ConsoleShared` / `LibraryShared` / `ConsoleBus`) over JNI — the desktop's 1,100-line service was bridged, not ported. ## By commit - **`84db02ef` — the shell becomes portable (pf-client-core, pf-console-ui, native host).** Portable modules split out (`menu_nav` — with the stick hysteresis Apple and Android each had to add on glass, now one implementation with tests; `audio_format`, `decoder_pref`, `console`), all re-exported under their old paths. `pf-console-ui` gains the `vulkan-overlay` default feature (the session binary is unchanged), a `Key` enum for SDL-free input, a `SettingsStore` seam, `Viewport{insets,scale}`, `Platform`-filtered settings rows, and the portable `Console` driver both hosts hold. skia-safe features are target-specific, so the desktop's prebuilt key — and the flatpak's pinned archive — cannot drift. - **`6d1c7187` — Android hosts it.** The Kotlin bridge (`SkiaConsole` / `SkiaConsoleShell` / `ConsoleJson`), raw pad samples through the shared `MenuNav`, touch/keyboard/remote input, deep links, session edges; the Android-only settings rows ride `Settings::extra` under `android.*` keys so the desktop struct grows no one-platform fields; Controllers/Licences stay Compose, opened via the new `ConsoleCmd::OpenPlatformScreen`. Also a **test-only fixed-step clock** for the screenshot dump — the aurora's phase was wall-clock time, so the dump could never be a pixel gate under load; with it, this branch renders **byte-identical PNGs to `main` on all 37 desktop scenes**. - **`f3a69d5b` — armeabi-v7a joins.** rust-skia publishes no armv7 archive; building it through skia-bindings itself (`FORCE_SKIA_BUILD=1`) takes ~80 s on an M-series Mac. The console host compiles on all three shipping ABIs. - **`428fef2b` — the Compose console is deleted** (~6.5 kLOC + its tests + its Roborazzi store scenes). Kept: the Gamepad chrome files (the D7 platform screens and the touch UI's pad nav draw with them), the touch library, `GamepadUi.kt`. `debug.punktfunk.console_backend=none` forces the touch UI for on-glass triage. - **`ba227057` — the archives are hosted where git assets live**: public release **[`unom/skia-binaries` @ 0.99.0](https://git.unom.io/unom/skia-binaries/releases/tag/0.99.0)** (R2-backed), mirroring rust-skia's release layout so the `{tag}/{key}` template fits verbatim. `kit/build.gradle.kts` bakes the URL in as the default — zero config for dev and CI, GitHub out of the Android build path; property/env/CI-var remain as staging overrides. Per skia-safe bump: rebuild the armv7 archive (recipe beside the sha256 table), cut a new release, mirror the 64-bit assets. ## Desktop non-regression (the plan's §5 contract) - `clients/session` keeps default features; the Vulkan overlay, SDL path and in-stream chrome are the same code reached through the `Console` façade. - The resolved desktop skia key stays `jpegd-jpege-pdf-textlayout-vulkan` (flatpak pin untouched). - Tests only grew: pf-console-ui 180 → **181**, pf-client-core **221**. - **Pixel-identical**: with the fixed-clock dump, `main` vs this branch = AE 0 / identical md5 on all 37 scenes. - Additive desktop changes only: stick hysteresis in `MenuNav` (pinned by test), the settings rows' platform filter (desktop shows exactly the set it did — pinned by test), serde derives on the model types. ## Verified - Container gate (fmt, clippy `-D warnings --all-targets`, plain build, tests) green; `cargo ndk clippy -D warnings` green on arm64-v8a / armeabi-v7a / x86_64; every ABI's skia-bindings log shows `DOWNLOAD AND INSTALL SUCCEEDED` (the silent-source-build trap). - `:app:testDebugUnitTest` + `:kit:testDebugUnitTest` green; `assembleDebug` with wiped skia-bindings dirs re-fetches all three ABIs from the baked default. - **On glass (NP3, user + post-deletion re-check):** the console comes up on EGL 1.5 / GLES 3, renders the desktop-identical home (carousel, OS marks, presence pips, aurora, legend), input and navigation work as expected. ## Known costs / follow-ups (named in the plan) - TalkBack does not reach the console screens (`SurfaceView` is opaque to a11y); the touch UI remains fully accessible. Mitigation sketched in the plan (an `AccessibilityNodeProvider` over the shell's published hit boxes). - The Compose console's marketing shot scenes are gone — console store shots come from the desktop screenshot dump (same pixels by construction) or device capture. - Console TV/32-bit box pass and a pad-heavy session are still worth an on-glass round; a Deck `--browse` + one Windows session before merge is the desktop's final spot check. - The phone's design-unit scale floor (`density × 0.6` under the couch formula) is a tuning knob for glass.
enricobuehler added 5 commits 2026-08-18 14:54:19 +00:00
The shell that pf-console-ui draws for the Vulkan session binary is the console UI
the Android client should have been showing all along (design/android-skia-console-port.md).
This is WP1–WP3 of that plan: nothing the desktop does changes, and the same crate now
compiles for aarch64/x86_64-linux-android against rust-skia's prebuilt GL archives.

pf-client-core: the data the shell speaks is un-gated for android — trust::Settings and
the known-hosts store, the profiles model, deep links, the library MODEL (the ureq
fetches stay desktop), and three modules split out so the platform-bound ones can stay
platform-bound: audio_format (the AUDIO_FORMAT_* table, re-exported by session),
decoder_pref (migrate_decoder_pref, re-exported by video), menu_nav (MenuEvent /
MenuNav / PadInfo, re-exported by gamepad) and console (OverlayAction, PointerInput,
SessionPhase, re-exported by pf-presenter — pf-console-ui sits above pf-presenter, so the
types could not live there). MenuNav gains the stick hysteresis both the Apple and the
Android console had to grow on glass: an engaged direction holds until its own axis
falls below MENU_RELEASE (0.3), so a diagonal flick is one move, not two.

pf-console-ui: the Vulkan overlay + SDL event path move behind the default
`vulkan-overlay` feature (clients/session is unchanged); a `Key` enum replaces SDL
scancodes; a `SettingsStore` seam replaces the six direct settings-file sites (desktop =
the file, exactly as before; SnapshotStore for hosts across a language boundary);
`Viewport{width,height,insets,scale}` replaces bare w×h (zero insets + the couch formula
on the desktop = byte-identical layout); `Platform` filters the settings rows (the
desktop shows every row it did); ConsoleOptions carries the store, the platform and the
GPU cache budget; the portable `Console` driver is what both hosts hold. skia-safe's
features are target-specific so the desktop key stays jpegd-jpege-pdf-textlayout-vulkan
(the flatpak pin) and Android resolves gl-jpegd-jpege-pdf-textlayout. The model types
derive serde: the wire IS the model.

clients/android/native: src/console/ — a hand-declared EGL binding, Skia's GL
DirectContext over FBO 0, one render thread paced by eglSwapBuffers that owns the
console and applies Kotlin's input from a command queue, and ~28 nativeConsole* JNI
seams (surface lifecycle, pad samples through the shared MenuNav, pointer, keys, text,
session phases, model pushers, a blocking event poll, the command-bus drain). Gated to
the 64-bit ABIs: rust-skia publishes no armv7 archive and skia-bindings would silently
build Skia from source; nativeConsoleAvailable() tells Kotlin which.

Gates: container fmt + clippy -D warnings (all targets) + build + tests green
(pf-console-ui 180, pf-client-core 221); cargo ndk clippy -D warnings for
pf-client-core / pf-console-ui / punktfunk-client-android on arm64-v8a, x86_64 and
armeabi-v7a; skia-bindings logged DOWNLOAD AND INSTALL SUCCEEDED for both 64-bit keys.
WP4 + WP5 of design/android-skia-console-port.md. The Android client now hosts the same
console shell the Linux/Windows session binary shows — drawn by native over EGL/GLES on a
SurfaceView (the WP3 host) — and Kotlin keeps every service behind it.

Kotlin (clients/android/app/.../console/):
- SkiaConsole — the process-lifetime holder (the console's stack survives a stream, exactly
  as the desktop's does): host rows from KnownHostStore + discovery + the reachability sweep,
  the library pipeline (cache first, wake + retry across the boot window, catalog, running
  set, posters over mTLS as bytes), pairing via nativePair, the wake-and-wait loop, the
  settings round-trip (Settings ↔ trust::Settings JSON; console-owned keys persisted whole),
  OverlayAction::Launch → connectToHost with the host's profile / a pinned card's one-off /
  the request-access budget, CancelConnect, Quit → task to back, CopyText → clipboard, and
  punktfunk:// links (known-and-pinned dials; anything needing trust is a notice — a link
  never establishes trust).
- SkiaConsoleShell — the composable: the SurfaceView + its lifecycle, safe-area insets
  (systemBars ∪ displayCutout, in surface px), a density floor under the couch scale for
  phones, MainActivity's pad probes feeding raw MenuSamples (buttons, stick, HAT) into the
  shared MenuNav, remote D-pad keys as discrete events, hardware keys as Keys + typed text,
  the system Back as B, touch as pointer input, haptic pulses, and the two Compose overlays
  the console can open (Controllers, Licences).
- ConsoleJson — the wire, in the model types' own serde shapes.
- App.kt picks SkiaConsoleShell over GamepadShell when SkiaConsole.wanted(): the native host
  exists (64-bit ABIs) and sysprop debug.punktfunk.console_backend != compose. The Compose
  console stays until the on-glass matrix and the armv7 archive land (WP7).
- NativeBridge: the nativeConsole* externs. GameEntry.platform parsed + cached (Collections).

pf-console-ui (WP5): the Android-only settings rows over Settings::extra `android.*` keys
(low-latency decode, phone rumble/gyro, SC2 passthrough, DualSense capture, controller-UI
mode) and two platform-screen action rows (Controllers, Licences) that raise the new
ConsoleCmd::OpenPlatformScreen; row_on() keeps them off the desktop's list, which shows
exactly the rows it did (pinned by test). "Send logs" hides where nothing serves it.

pf-console-ui (test harness): a test-only fixed-step clock for the screenshot dump. The
dump's aurora phase was wall-clock time, so two runs agreed only at equal machine load and
speed — a pixel comparison of main against a candidate read as a regression that was pure
drift. With the fixed step the same tree renders the same pixels anywhere.

Gates: container fmt + clippy -D warnings + build + tests; cargo ndk clippy -D warnings on
arm64-v8a / x86_64 / armeabi-v7a; :app:compileDebugKotlin; the fixed-clock dump of this
tree vs main.
WP6 of design/android-skia-console-port.md. rust-skia publishes prebuilt Skia archives for
aarch64/x86_64/i686-linux-android but not armv7-linux-androideabi, and the 32-bit TV boxes
(Chromecast-with-Google-TV class) are console-UI-always devices — so the archive is built once
with skia-bindings itself (FORCE_SKIA_BUILD=1 for pf-console-ui's gl+textlayout feature set:
1666 ninja steps, ~80 s on an M-series Mac) and packed in the exact skia-binaries/ layout the
downloader unpacks. Key a25a0fdb7d90429aa2d1-armv7-linux-androideabi-gl-jpegd-jpege-pdf-textlayout,
sha256 4867856b… (table in kit/build.gradle.kts beside the two GitHub keys).

- clients/android/native: the console host and its deps are on every Android ABI now
  (the arch gate is gone; nativeConsoleAvailable() answers true on all three).
- kit/build.gradle.kts: forwards SKIA_BINARIES_URL (-PskiaBinariesUrl or the env) to every
  cargo-ndk run — the {tag}/{key} template that serves all three keys from one mirror.
- .gitea/workflows/android.yml: the repo variable SKIA_BINARIES_URL feeds it.

Proven: on a clean target dir with the url template pointing at a local mirror, every ABI's
skia-bindings log says DOWNLOAD AND INSTALL SUCCEEDED (armv7 included), the full debug APK
builds and installs, and cargo ndk clippy -D warnings is green on all three ABIs.

⚠ Until the three archives are hosted (storage.unom, then set the CI variable), an armv7 build
without the url falls back to a Skia source build — minutes with ninja present, a red leg
without it. Hosting is the one step left in this WP.
WP7 of design/android-skia-console-port.md, unlocked by WP6 (every ABI carries the native
host) and the on-glass pass. The third gamepad-UI implementation is gone: Android's console
is now the same pf-console-ui shell Windows and Linux show, and the Compose mirror that
re-implemented it screen by screen is deleted rather than parked.

Deleted (~6.5 kLOC): GamepadHome, GamepadSettingsScreen, GamepadAddHostScreen,
GamepadDialogs, HomeTiles, the console halves of LibraryScreen (coverflow, ConsoleLibrary),
ConnectOverlay (ConnectTakeover), ConnectScreen / ConnectPrompts / AdaptiveDialogs
(gamepadUi branches, console options dialog), App.kt's GamepadShell + GamepadScreen — and
their tests (HomeTilesTest, GamepadSettingsLayout/RowsTest, ConsoleSubScreenRoutes/RowsTest,
the buildSettingsRows pins in GamepadPaletteTest, the Compose-console shot scenes; the tab
vocabulary is pinned by the Rust twin against the same shared vectors file).

Kept, deliberately: GamepadChrome/Nav/Aurora/Ink/Palette — the Compose platform screens the
console still opens (Controllers, Licences per D7), MainActivity's touch-UI pad navigation,
and the settings palette preview all draw with them; LibraryPosition + the TOUCH library
(the touch shell's own pushed screen); GamepadUi.kt (the mode switch).

App.kt folds the native host's presence into `gamepadUi` itself: no host, no console —
a controller then drives the touch UI through Compose focus (nothing left to fall back to).
The sysprop becomes a triage switch (`debug.punktfunk.console_backend=none` forces the
touch UI on glass; the `compose` value is meaningless now).

Marked breaking for the store-screenshot surface: the Compose console's marketing scenes
(console home, console settings, coverflow, connect takeover) are gone — the Skia shell
renders over native GL and cannot compose under Roborazzi. Its shots come from the desktop
screenshot dump (the same pixels by construction) or a device capture.

Gates: :app:testDebugUnitTest + :kit:testDebugUnitTest green, installDebug on the NP3 and
the console renders on glass (carousel, OS marks, legend, aurora — the desktop look).
build(android): the Skia archives are served from our own Gitea release, by default
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m23s
ci / docs-site (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 2m11s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 5m41s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m18s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m18s
android / android (pull_request) Successful in 12m3s
nix / flake (pull_request) Failing after 14m54s
ci / rust (pull_request) Successful in 17m49s
ba227057b6
The WP6 hosting step lands where the project's git assets live: a public release on
git.unom.io (R2-backed) rather than the sccache bucket — unom/skia-binaries, tag 0.99.0,
mirroring rust-skia's own release layout so skia-bindings' {tag}/{key} url template fits
it verbatim:

  https://git.unom.io/unom/skia-binaries/releases/download/{tag}/skia-binaries-{key}.tar.gz

The release carries the armv7-linux-androideabi archive we build (rust-skia publishes
none) and byte-for-byte mirrors of the aarch64/x86_64 assets, each with its sha256 and
provenance in the release notes.

kit/build.gradle.kts now bakes that template in as the DEFAULT for every cargo-ndk run —
dev boxes and CI need no configuration, GitHub is out of the Android build path entirely,
and `-PskiaBinariesUrl` / the SKIA_BINARIES_URL env (and CI's repo variable) remain as
overrides for staging the next skia-safe bump's archives before they are released.

Proven: unauthenticated downloads of all three archives byte-match the local sha256s;
a clean-target cargo-ndk armv7 check installs from the release (`DOWNLOAD AND INSTALL
SUCCEEDED`); assembleDebug with the skia-bindings build dirs wiped re-fetched all three
ABIs from the default and built green.
enricobuehler merged commit e43d67c721 into main 2026-08-18 15:01:53 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#301