Native release polish: a usable routing client on Android, a served APK, and 1.0.0 #45

Merged
jeroen merged 11 commits from feat/44-native-release-polish into main 2026-08-11 04:38:23 +00:00
Owner

Closes #44. Supersedes the narrower #34.

The headline

The APK has never once published, and the reason was hidden by a formatting error. CI ran cargo fmt --check as the first step of the check job, so an unformatted merge ended the job in 30 seconds and the Android lane below it never ran. Behind it sat a real compile break: the APK is built --no-default-features, which drops solo, which makes SessionMode::Solo and SessionStatus::Starting unconstructed — and unused = "deny" makes that an error. deploy-android.yml failed on every push and viberfox.garage44.eu/apk/ has been a 404 throughout.

Both fixed. cargo check -p viberfox_android --target aarch64-linux-android now passes, verified against a real NDK — the first time that has ever been true in this repo. cargo fmt --check moved to the last step, on the rule that whatever gates last cannot hide anything.

What else is in here

Android is a platform now. A JNI location provider behind the existing LocationWatch seam (no Java, no dex — hasCode="false" is what lets this app ship without Gradle), keep-screen-on, ACTION_VIEW intents, a flush on WillSuspend (Android never sends AppExit), worker quiesce while backgrounded, and a Back gesture that unwinds keyboard → driver mode → panel → exit.

Text entry exists. NativeActivity has no IME, so every field could be focused and not typed into — which for a routing client is the address bar. The client draws its own keyboard.

The phone can render. Msaa::Sample2 was applied on Android where Vulkan's guaranteed minimum is 1|4; the clipmap rasterised 224 tiles at 512² (~310 MB) where the globe had already concluded 256² (~78 MB); the worker cap was silently doing nothing on native.

Bugs that made it feel unreliable. Collision was entirely off whenever the Height layer was on (the airborne test read a position with terrain lift folded in, and Dutch relief is ~2 m against a 0.2 m threshold — the avatar walked through every wall). One ☆ press permanently removed the HUD and compass. while true do end at Luau top level hung the simulator forever and survived restart. Crafted tile bytes could request ~24 TB and abort the app.

Almost no emoji render at all. A new test found that the bundled font stack cannot draw ▶ ◀ ✔ 🏠 📷 📋 📍 🚩 📝 ✏ 🔄 ☀ 🌙 ↗, so Back, Forward, Home, the camera picker and every bookmark row action have been painting empty boxes on every platform since they were written. The inventory comment asserted the opposite, twice.

Navigation. A failed reroute used to end navigation silently; the route now survives, the line stays drawn, the marker snaps to the road, GPS quality is visible and gates rerouting, and there are turn chimes — the only part usable without looking at the screen. You can finally route to a search result, a bookmark or a POI.

Release. MIT OR Apache-2.0 across all six crates, 1.0.0, a monotonic versionCode, a window background so cold start is not a black screen, an adaptive launcher icon, Play Protect instructions, and release.yml gated on tests.

Verification

Green: workspace --all-targets; viberfox 371, viberfox_geo 109, viberfox_core 26, viberfox_simulator 48+, big_space 14; --no-default-features; aarch64-linux-android; wasm on nightly; cargo fmt --check.

Two things could not be verified anywhere in this container and need real hardware:

  1. One adb install with adb logcat -s viberfox:V bevy_render:V wgpu:V — launch, map streams, rotate, home, resume, back. Nothing between "it compiles for aarch64" and "a person uses it" has ever been observed.
  2. The location provider specifically. It rests on the claim that fixes caused by our own PendingIntent registration land in the cache we poll. That is how AOSP's LocationProviderManager behaves, but if it is wrong the symptom is an arrow that never moves — and there is no third dex-free option, only GameActivity and Gradle. The four device-dependent claims are written into run_location's doc comment.

Anything touching camera, shaders, egui layout or lighting is visually unverified; this container has no GPU.

Closes #44. Supersedes the narrower #34. ## The headline **The APK has never once published, and the reason was hidden by a formatting error.** CI ran `cargo fmt --check` as the *first* step of the `check` job, so an unformatted merge ended the job in 30 seconds and the Android lane below it never ran. Behind it sat a real compile break: the APK is built `--no-default-features`, which drops `solo`, which makes `SessionMode::Solo` and `SessionStatus::Starting` unconstructed — and `unused = "deny"` makes that an error. `deploy-android.yml` failed on every push and `viberfox.garage44.eu/apk/` has been a 404 throughout. Both fixed. `cargo check -p viberfox_android --target aarch64-linux-android` now passes, verified against a real NDK — the first time that has ever been true in this repo. `cargo fmt --check` moved to the *last* step, on the rule that whatever gates last cannot hide anything. ## What else is in here **Android is a platform now.** A JNI location provider behind the existing `LocationWatch` seam (no Java, no dex — `hasCode="false"` is what lets this app ship without Gradle), keep-screen-on, `ACTION_VIEW` intents, a flush on `WillSuspend` (Android never sends `AppExit`), worker quiesce while backgrounded, and a Back gesture that unwinds keyboard → driver mode → panel → exit. **Text entry exists.** NativeActivity has no IME, so every field could be focused and not typed into — which for a routing client is the address bar. The client draws its own keyboard. **The phone can render.** `Msaa::Sample2` was applied on Android where Vulkan's guaranteed minimum is `1|4`; the clipmap rasterised 224 tiles at 512² (~310 MB) where the globe had already concluded 256² (~78 MB); the worker cap was silently doing nothing on native. **Bugs that made it feel unreliable.** Collision was entirely off whenever the Height layer was on (the airborne test read a position with terrain lift folded in, and Dutch relief is ~2 m against a 0.2 m threshold — the avatar walked through every wall). One ☆ press permanently removed the HUD and compass. `while true do end` at Luau top level hung the simulator forever and survived restart. Crafted tile bytes could request ~24 TB and abort the app. **Almost no emoji render at all.** A new test found that the bundled font stack cannot draw `▶ ◀ ✔ 🏠 📷 📋 📍 🚩 📝 ✏ 🔄 ☀ 🌙 ↗`, so Back, Forward, Home, the camera picker and every bookmark row action have been painting empty boxes on every platform since they were written. The inventory comment asserted the opposite, twice. **Navigation.** A failed reroute used to end navigation silently; the route now survives, the line stays drawn, the marker snaps to the road, GPS quality is visible and gates rerouting, and there are turn chimes — the only part usable without looking at the screen. You can finally route to a search result, a bookmark or a POI. **Release.** MIT OR Apache-2.0 across all six crates, 1.0.0, a monotonic `versionCode`, a window background so cold start is not a black screen, an adaptive launcher icon, Play Protect instructions, and `release.yml` gated on tests. ## Verification Green: workspace `--all-targets`; `viberfox` 371, `viberfox_geo` 109, `viberfox_core` 26, `viberfox_simulator` 48+, `big_space` 14; `--no-default-features`; `aarch64-linux-android`; wasm on nightly; `cargo fmt --check`. **Two things could not be verified anywhere in this container and need real hardware:** 1. **One `adb install`** with `adb logcat -s viberfox:V bevy_render:V wgpu:V` — launch, map streams, rotate, home, resume, back. Nothing between "it compiles for aarch64" and "a person uses it" has ever been observed. 2. **The location provider specifically.** It rests on the claim that fixes caused by our own `PendingIntent` registration land in the cache we poll. That is how AOSP's `LocationProviderManager` behaves, but if it is wrong the symptom is an arrow that never moves — and there is no third dex-free option, only GameActivity and Gradle. The four device-dependent claims are written into `run_location`'s doc comment. Anything touching camera, shaders, egui layout or lighting is visually unverified; this container has no GPU.
The APK has never once published. `deploy-android.yml` has failed on every
push to `main` since it landed, and `https://viberfox.garage44.eu/apk/` is a
404.

Two faults, one behind the other.

`cargo fmt --check` was the *first* step of CI's `check` job. A failing step
ends the job, so an unformatted merge meant the `cargo check`, wasm and
Android lanes below it never ran — and the Android lane is the only thing in
that file which compiles the APK's configuration. The fast red X read as
"someone forgot rustfmt", which is what it was, and also what it was hiding.
Moved to the last step: whatever gates last cannot hide anything.

Behind it: the APK is built `--no-default-features`, so no `solo` feature, so
`SessionMode::Solo` and `SessionStatus::Starting` are never constructed — and
`[workspace.lints.rust] unused = "deny"` makes that a hard error. Both
carried `cfg_attr(target_arch = "wasm32", allow(dead_code))`, which is the
platform-vs-feature confusion CLAUDE.md documents twice already: this tree's
seams were written as native-vs-wasm and Android answers "native" to all of
them. They now gate on `not(feature = "solo")` — the condition that is
actually true, and one that covers wasm as a side effect.

Verified with `cargo check -p viberfox --no-default-features`, and then with a
real `cargo check -p viberfox_android --target aarch64-linux-android`.
Tile bytes are network-controlled — a hostile tile server, or wifi MITM on a
phone — and `[profile.release] panic = "abort"` with no `catch_unwind`
anywhere means every allocation failure below was an immediate SIGABRT on a
user's device. All of it sits on the default offline path with no user
interaction: `trees_enabled` is on by default.

mvt-reader allocates `Vec::with_capacity(geometry_data.len())` per `MoveTo`,
and each `LineString` keeps that capacity, so a ~3 MB body of `[9,0,0]`
repeated — well under `http::MAX_BODY` — requests about 24 TB. A length cap
alone does not close it: the blow-up is quadratic, so a cap loose enough to
admit the 495 KB Groningen fixture still admits a ~700 GB request from a
crafted body the same size. `guard_tile_body` therefore walks the protobuf
and computes exactly what mvt-reader will reserve, before it sees anything.
The walk fails *closed*, which is a deliberate reversal of the first draft:
accepting what it cannot parse would make a two-byte proto2 group header a
disarm switch. The MVT schema has no group field anywhere, so refusing costs
nothing real, and ~40 existing tests now also exercise the walker against a
real tile — a walker bug fails CI loudly rather than blanking a map quietly.

Separately, mvt-reader clips the coordinate cursor at `i32::MAX` and never to
the layer extent, so a short zigzag-varint pair delivers coordinates near
2.1e9. Three spatial-bucket builders then swept that range at 32 m a bucket —
on the order of 10^14 HashMap insertions of a fresh Vec. Each now
bbox-rejects the segment and clamps its loop to the tile's own grid.

Measured on the fixture: mvt-reader over-reserves ~10x on well-formed input
(3.13 M coordinate slots reserved against 1.94 M retained), which is why the
ceiling is 256 MiB rather than a few MB; the real geometry buffer is 1.6% of
extent against the 25% acceptance window, i.e. ~15x of headroom. The guard's
scan is a byte loop over 198 KB — sub-millisecond against a ~42 ms decode and
a ~121 ms rasterise.

Also: the furniture way field was scaled by the `pois` layer's extent while
iterating `streets` geometry. MVT declares extent per layer, so on any tile
where the two differ every bench faced the wrong way.

`TilePayload::decode` now takes an explicit raster size, so the client can ask
for 256² on a phone the way the globe path already does.
The Linux build defaults to the `solo` feature, so this simulator runs
in-process in the shipped app — and `panic = "abort"` means anything here
kills the user's client outright.

The worst of it: `Slot::compile` ran the author's top-level chunk with a
plain `pcall`, not a coroutine. Luau's `lua_isyieldable` is
`nCcalls <= baseCcalls` and only `lua_resume` raises the base, so a `pcall`
body is non-yieldable — and mlua *silently discards* a `VmState::Yield` it
cannot honour. `LOAD_FUEL` therefore never fired, and the comment promising
that `while true do end` at the top level must not hang the host was simply
false. The existing test only covered a loop inside `on_start`, which does
run in a coroutine and was correctly metered; that is why it survived.

The consequences compounded: the host thread never returned to `rx.recv()`,
`HostMsg` is an unbounded channel fed every scripted prim 20x/s, so the
process grew until OOM — and because the source is persisted before it runs,
a restart replayed it and hung again before any other prim compiled, while
still logging "scripts loaded" with the full count. The chunk now runs as a
fuel-bounded thread like every handler, and `Slot::handler` uses `raw_get`,
closing the second route in (a top-level `setmetatable(getfenv(), ...)`,
which `luaL_sandbox` does not prevent).

The rest, in the same spirit — things one client could do to everyone else:

- Per-connection inboxes were unbounded, and `send` on an unbounded channel
  never fails. A client whose TCP window closed blocked the drain while
  nearby speakers pushed ~100 KB/s of voice into it forever. Now two bounded
  lanes, text (256) and voice (64), with different overflow policies.
- Voice could be delivered into any room without joining it, and the room
  name was bounded only by `MAX_FRAME` = 32 MiB. Membership is checked and
  `MAX_ROOM_LEN` applies to voice, text and subscribe alike.
- `ClientIntent { move_x: INFINITY }` divided to NaN and wrote NaN into the
  authoritative position, out to every client. NaN alone was already rejected
  (`NaN > 1e-4` is false); infinity was the hole.
- argon2 ran while holding the SQLite mutex, which the tick loop also takes
  while holding the world write lock — fifty unauthenticated sockets froze
  every client. The row read now drops the guard before hashing, and the
  verify is `spawn_blocking` behind a semaphore.
- Session tokens never expired and the map only grew; `SetPrimScript` had no
  rate limit and broadcast the source to everyone; `accept_loop` hot-spun on
  EMFILE; the timer queue could silently evict a script's `Start` event.
- The Luau sandbox opened the `debug` library. Not exploitable today, but the
  stated model was wrong and no test would have caught it growing.

No protocol change: `MAX_ROOM_LEN` is a constant beside `ChatScope`, so
`PROTOCOL_HISTORY` gains no row.
Android had no position source at all: `LocationWatch::available()` was
`cfg!(target_arch = "wasm32")`, so driver mode on the phone narrated the
avatar you were walking with a keyboard the phone does not have. For a
routing client that is the difference between a map and a navigator.

`platform::android` is the Android arm behind the existing seam, and it is
JNI-only — no Java, no Kotlin, no `classes.dex`. That constraint is not
stylistic: `android:hasCode="false"` is what lets this app ship without a
Gradle project at all, and every push-shaped location API needs a Java type
we would have to define (`LocationListener` is an interface;
`getCurrentLocation` wants a `Consumer`; `Proxy` needs an `InvocationHandler`,
itself an interface). So updates are requested with the `PendingIntent`
overload — which starts the provider without requiring a receiver — and the
fix is read by polling `getLastKnownLocation`. Permission is requested
through `Activity.requestPermissions`, a framework method, and polled through
`checkSelfPermission`, because the result callback is unreachable for the
same reason.

Four claims in that design need a device to confirm, and they are written
into `run_location`'s doc comment in falling order of confidence. The first
is load-bearing: that fixes caused by our own registration land in the cache
we poll. If it does not hold, the symptom is an arrow that never moves, and
there is no third dex-free option — the fallback is GameActivity and Gradle.

Also here, both long-standing gaps rather than new work:

- Keep-screen-on, paired exactly with driver mode. A phone blanks 30 s into a
  drive and navigation is over. It needs no JNI at all: `set_window_flags`
  wraps `ANativeActivity_setWindowFlags`, which posts to the UI thread's
  looper itself.
- `open_url` via `Intent.ACTION_VIEW`, started from the *application* context
  rather than the activity — `Activity.startActivity` touches the decor view
  from the calling thread, `ContextImpl.startActivity` is a pure binder call.
  The POI popup's "Website" and "OSM" buttons were enabled, clickable and
  permanently dead on the phone; they now work, and on a platform that still
  cannot act they disable themselves instead of lying.

The manifest gains FINE and COARSE location. Both, deliberately: since API 31
the permission dialog only offers the user an Approximate choice if COARSE is
declared, so requesting FINE alone forces someone who wants to share a rough
position to refuse outright. Background location is not requested and should
not be — the fix is read only while driver mode is on and the app is on
screen.

Verified by compiling: `cargo check -p viberfox_android --target
aarch64-linux-android` now passes, which it has never done in this repo.
Everything past "does it compile" is open until an `adb install`.
NativeActivity brings up no IME. Every `TextEdit` in this client can be
focused on Android and cannot be typed into — which for a routing client
means the address bar and the geo search, i.e. the front door. The web build
on a phone has the same problem for a different reason.

So the client draws its own: a QWERTY slab in a floating `Area`, themed from
`theme.rs`, appearing when `ctx.text_edit_focused()` says a field genuinely
holds focus. Not `egui_wants_keyboard_input()` — CLAUDE.md is explicit that
it means `memory.focused().is_some()`, true for any focused button, and that
using it is what silently swallowed avatar movement keys.

Three things are load-bearing and none are obvious:

**Focus restore is most of the work.** egui's default `SurrenderFocusOn::
Clicks` drops the field's focus on any click that is not on it, so the first
key tap kills the very condition that shows the keyboard. `restore_focus`
puts it back *and re-locks the `EventFilter`*, because `request_focus`
installs a default all-false filter that `Focus::begin_pass` reads before any
widget runs — without that, the first arrow key moves focus to the next
widget instead of the caret. This is also why the system must run after every
system that can draw a text field: registered earlier, the surrender happens
after the restore and undoes it, and the keyboard types one character into
nothing and hides itself.

**Dismissal keys off the pointer, not `clicked()`.** A finger that drifts a
few points produces a drag, and keying dismissal off `clicked()` would close
the keyboard mid-word.

**Key height meets 48 dp; key width cannot.** Ten columns at 48 dp needs 480
dp and the handset has 393. Height is a hard floor computed from the real
scale — `ui_scale` is winit's scale factor, which on Android *is* density —
and the width trade is the one every shipping phone keyboard makes, because a
fingertip contact patch is taller than it is wide.

It docks to the bottom and flips to the top when the bottom slab would
intersect the focused field. A keyboard over the map is fine; one over the
field is not, and that rule is a test. The dock stands down while it is up
(same `Area`-over-`Area` reasoning driver mode already uses), and
`place_pins` subtracts the slab from its cull rect — a pin left underneath is
still an interactive `Area` and would eat taps meant for the keys.

14 tests, including an end-to-end one that feeds a real focused `TextEdit`
the keyboard's own output for `Shift a b backspace c` and reads back "Ac".
Visually unverified: no GPU here, and neither Android nor a phone browser can
be exercised in this container.
`quality::Budget` already dialled the phone down — shadows off, bloom zero,
sky-IBL off — but the levers that mattered most had no field, and every
env-var escape hatch (`VIBERFOX_FPS_CAP`, `VIBERFOX_NO_SPACE_RAYMARCH`,
`VIBERFOX_QUALITY`) is unreachable on Android, which has neither a process
environment nor a query string. So each of these is a `Budget` field.

**MSAA was the dangerous one.** `Msaa::Sample2` applied on Android, and
Vulkan's guaranteed minimum for `framebufferColorSampleCounts` is `1 | 4` —
2x is optional. That is byte-for-byte the failure the surrounding comment
already documents twice for WebGPU and lavapipe: `main_texture_sampled` fails
to create and nothing renders at all. Cost, too: `AtmosphereSettings`
requires `Hdr`, so at 1080x2340 the attachments are ~100 MB with 2x, and
16 B/px x 2 samples + depth overruns a tile buffer and forces extra binning
per draw.

**The clipmap rasterised every tile at 512² on every tier.** The globe fixed
this long ago (256² plus `render_mvt_sized`, measured as 2-3x oversampled per
axis); the flat path never got it. 224 tiles at 512² RGBA with mips is
~310 MB against ~78 MB, and since scan conversion dominates parsing by ~20x
and scales with area, it is also most of the time-to-first-map. Note the
caveat recorded at the constant: the finest ring drops to ~1.4 texel/m, which
is tolerable only because at ground level the ground is vector geometry over
the imagery rather than the imagery.

The rest, briefly:

- Six of the largest mesh and texture assets were stored in both worlds.
  Nothing reads them back. `tile_loader` already documented this exact
  mistake for tile textures ("paid for twice… hundreds of megabytes") and
  fixed it there; the meshes never got the same treatment.
- Atmosphere LUTs recomputed ~4 M march steps per frame at Bevy's desktop
  defaults; halved on the phone. Space-tier raymarching is off there.
- `AsyncComputeTaskPool` now honours `Budget::worker_cap`. It could not
  before: `platform::workers::init` is a no-op off the web, so the phone's
  cap of 3 constrained nothing and the real width was `TaskPoolOptions`.
- Android gets FIFO present mode. `AutoNoVsync` is a Wayland/i915 occlusion
  workaround; on a handset it means rendering frames SurfaceFlinger discards,
  paid for in heat on a device that throttles.
- `apply_feature_toggles` swept every building, window, 3DBAG mesh, tile and
  region quad every frame. It is now gated on a *value* comparison, not
  `resource_changed::<DataLayers>()` — `update_data_layers` mutably derefs
  the resource every frame and Bevy marks changed on `DerefMut`, so that gate
  would have been true forever. The gate also covers the space-tier boolean
  and the anchor tile, or a descent from orbit leaves the buildings hidden.
  Newly streamed entities get their own `Added<>` pass.
- `animate_stop_rings` wrote every ring's `Transform` every frame; the phase
  is quantised to 20 Hz, above the threshold where a large soft-edged shape
  stops reading as continuous and below every frame rate we ship.
- Four leaks of the shape this repo has fixed three times already — a key
  removed from one collection and left in a parallel one: `HeightLayer::
  patches` had no removal path at all, `TransitVehicles::label_mats` was
  skipped by its own teardown, `detail_pois` was kept while its three sibling
  maps were pruned (bounded rather than pruned — it is also the dedup key for
  the detail stream), and the routing `GraphCache` pinned up to three graphs
  for the session after one route.
- `bookmarks` flattened the whole tree every frame with the panel closed;
  the HUD built its strings above its own visibility gate.

Fixes along the way that are not performance:

**Collision was off whenever the Height layer was on.** The airborne test
read `display_position`, which folds in `terrain_lift` — and CLAUDE.md
measures Dutch-city relief at ~2 m against an `AIRBORNE_EPS` of 0.2. So the
client believed the avatar was airborne on every frame and skipped steering
entirely: the avatar walked through every wall online, and offline the
snap-out backstop never ran, stranding anyone who teleported inside a
building. The offline movement path always subtracted the terrain, which is
what makes the other two sites bugs rather than a design choice.

**One bookmark permanently killed the HUD and the compass.** `render_hud` set
`bookmarks.panel_open`, a legacy operand nothing ever cleared, and the
compass, locate button, clock, coordinates and altitude all read it. Press
the star once and the status overlay was gone for the session. `RightPanel`
is the real state now and is the only thing consulted.

**Nothing persisted on Android.** `user.json` was cwd-relative and a
NativeActivity process starts at `/`, so every save failed and only warned —
bookmarks, Home, layer prefs and the remembered session were lost on every
app switch, which also meant the one destination path that needs no typing
did not survive. The store now resolves an app-data root the way the tile
cache already did, writes through a temp file and a rename, quarantines a
corrupt file instead of overwriting it within one frame, and carries
`#[serde(default)]` on every leaf so one added field cannot wipe an install.
The disk cache gained a startup size sweep; it had no bound and nothing ever
reclaimed it.

Street labels now sample the terrain field and rebuild on its revision — the
road surfaces under them were displaced and the names were not, so with the
Height layer on they sank into the road.
feat(android): behave like an app the system can suspend, and answer Back
Some checks failed
CI / cargo check (push) Failing after 2m23s
CI / build & test viberfox (push) Failing after 2m48s
997c899b68
Three gaps that only exist on the phone, and all three read as the app being
broken rather than as missing features.

**Nothing flushed on suspend.** `autosave_user_store` treated `AppExit` as its
only flush trigger, and Android never sends one — the system suspends the
process and later kills it. So every app switch lost up to `AUTOSAVE_INTERVAL`
of work on the platform where switching away *is* how you stop using an app.
`AppLifecycle::WillSuspend` is now a second trigger; Bevy's docs are explicit
that you get exactly one frame, which is why this hangs off the existing
save rather than scheduling anything.

**Nothing stopped working while backgrounded.** The tile streamers kept
fetching and the worker pool kept rasterising on a device whose battery the
user was not watching. `workers::set_paused` already existed for the shot
harness and has the property that matters: it *parks* jobs rather than
dropping them. That is not a detail — the streamers de-duplicate on "a fetch
is in flight", so a discarded decode is never re-queued and the tile would be
missing for the rest of the session.

**Back did nothing at all.** Under NativeActivity the key reaches the app and
is consumed there, so the first gesture every Android user reaches for to
dismiss something got no response, and a full-screen panel could only be left
by finding its close button. Back now unwinds the same stack Escape does on
the desktop, innermost first — leave driver mode, close the panel, and only
then quit.

Two input bugs found by the same sweep, both on the desktop too:

`handle_delete_key` gated on `egui_wants_keyboard_input()`, which is
`memory.focused().is_some()` and stays true after clicking any widget at all.
Delete therefore did nothing until you first clicked empty space. CLAUDE.md
names this predicate as the wrong one and records that it had already
swallowed the avatar's movement keys once; this was the remaining site.

The gizmo's T/R/S were bound globally with no selection check, and two of
those keys belong to the avatar — `R` is flight climb, `S` walks backwards.
Walking around silently rewrote the gizmo mode, so whichever direction you
last moved in decided how the next object you selected would be manipulated.
build(release): licence, version 1.0.0, and an APK a stranger can install
Some checks failed
CI / cargo check (push) Failing after 1m41s
CI / build & test viberfox (push) Has been cancelled
bb5c906de3
The repo had no LICENSE at all. The README carefully licensed everyone else's
work and said nothing about viberfox's own, so the published tarball was
formally all-rights-reserved with a download button. Now `MIT OR Apache-2.0`,
matching the vendored big_space fork so the notice obligations line up, with
`license` on every crate — it does not propagate from `[workspace.package]`
by itself, each crate has to ask.

`LICENSE-THIRD-PARTY.md` is the canonical attributions list, 14 records in a
deliberately dumb format: `## <name>` opens a record, then single-line
`role:`/`license:`/`url:`/`notice:`/`scope:`, no continuations. That is so the
in-app About panel can `include_str!` it and split on `## ` — no Markdown
parser, and no AssetManager plumbing on Android or wasm. ODbL and CC BY 4.0
both require attribution a user can actually find; four sources were credited
nowhere at all, including the HYG star catalog, whose CC BY-SA is a real
obligation.

Everything else here is about the gap between "the APK builds" and "a person
installs it and it works":

**Versions.** `versionCode` was hardcoded to 1, so every APK ever published
claimed version 1 — no update semantics, and nothing on-device saying which
build it is. Now passed through `aapt2 link` from the workspace version plus a
short sha, with the code as `10000 + run_number`. Note `git rev-list --count
HEAD` cannot be used: `actions/checkout` fetches depth 1, so it is always 1.
The offset also keeps published codes above anything hand-built.

**Cold start was a black screen.** NativeActivity shows nothing until the
surface exists and Bevy is up — Bevy init plus 62 MB of glTF through the
AssetManager, plausibly 10-30 s on a phone, which reads as a crash. This was
the largest "installs and appears not to work" risk after the build itself.
There is now a window background in the brand teal, and the platform
`windowSplashScreen*` attributes on API 31+ (no androidx, so no Gradle).

**The launcher icon** was a density-less 256 px PNG, treated as mdpi baseline
and rescaled on every real phone, with 8+ launchers applying the legacy
shrink-inside-a-white-blob treatment. Five density buckets and an adaptive
icon, all generated in the script from the single source — the CI image's
tooling is unknown from here, so it tries `magick`, then `convert`, then a
pure-stdlib Python resizer (1% RMSE against Lanczos), then warns and ships the
old icon rather than failing the build.

**The install page** described the "allow installing from this browser"
consent and stopped there. What actually stops a non-technical installer is
Play Protect's "Unsafe app blocked" for an unknown developer, whose only way
through is More details → Install anyway. That is now written out, with the
exact taps.

Also: `release.yml` gated on nothing, so a tag pushed on a red `main` published
a tarball whose tests fail — it now needs a test job. The shipped Linux binary
requires AVX2 (`target-cpu=x86-64-v3`) and died with SIGILL on Atom-derived
parts still being sold, with no message; `run.sh` checks and says so. And the
deploy's verify step now asserts 16 KB LOAD alignment, which NDK r27 gives by
default but nothing checked — a future change would surface only as "won't
load" on Pixel 8+.
docs(notes): make the standing facts true again after the release pass
Some checks failed
CI / cargo check (push) Failing after 1m51s
CI / build & test viberfox (push) Failing after 2m52s
0be5a84e99
`docs/notes` holds facts the code and git log do not, edited in place when one
stops being true. Today falsified a lot of them.

The largest is `android-build.md`, whose "Verification status" section
described a build that had in fact never once succeeded in CI. It now records
why — `cargo fmt --check` ran first in the `check` job, so an unformatted
merge ended the job before the Android lane, and behind it sat a real
`not(feature = "solo")` dead-code break — together with the general rule,
which is the part worth keeping: *whatever gates last cannot hide anything.*

It also gains the design constraint behind the new location provider, which is
the sort of thing that will otherwise be rediscovered the expensive way:
`hasCode="false"` means no `classes.dex`, and every push-shaped location API
needs a Java type we would have to define — `LocationListener` is an
interface, `getCurrentLocation` wants a `Consumer`, and `Proxy` looks like an
escape but needs an `InvocationHandler`, which is an interface too. Hence
`PendingIntent` plus polling. The four claims that need a device to confirm
are pointed at, and so is the conclusion that there is no third dex-free
option: the fallback is GameActivity and Gradle.

The rest: `touch-input.md` gains the drawn keyboard and why egui's
`SurrenderFocusOn::Clicks` forces it to run last; `prim-scripting.md` gains
the reason a fuel yield is a silent no-op outside a coroutine, which is the
trap anyone adding a new script entry point will walk into;
`tile-raster-cost.md` and `globe-tile-cost.md` gain `Budget::tile_px` and the
310 MB / 78 MB arithmetic; `terrain-relief.md` gains the revision-rebuild rule
with street labels as the consumer that had been missed;
`wasm-performance.md`, `session-modes.md`, `navigation-display.md`,
`address-bar.md` and `headless-shots-software-renderer.md` gain smaller
corrections.

New: `container-android-toolchain.md`. The agent container has no NDK and no
`aarch64-linux-android` target, and `/usr/local/rustup` is root-owned so the
target cannot simply be added — it needs a copied `RUSTUP_HOME`. That cost
time today and would have cost it again.
Two halves: the route no longer breaks when you deviate from it, and you can
finally start one from something you found.

**A failed reroute used to end navigation silently.** Rerouting went through
`set_endpoint_at` → `invalidate()` → `route = None`, and if the new solve
failed, `drive_driver_mode` saw no route and called `exit()`. The message
existed only in the Route panel — which driver mode closes on entry, and whose
dock is hidden while driving. So the everyday cases (an underground car park,
a missed motorway exit, a tile-server hiccup) looked like navigation just
vanishing. The mode now survives, and the card offers Retry and Stop.

**And the display went blank for the whole refetch.** The same `invalidate()`
despawned every `RouteVisual`, so the line disappeared and the ETA fell to
zeros at the exact moment you most needed them. `RouteState::held` parks the
route being replaced and everything keeps measuring against it until the new
one lands. The audit's suggestion — "the generation counter already
distinguishes them" — was wrong in a load-bearing way: the generation was
bumped at *request* time, and that bump is what despawned the line. Not
bumping it is the fix.

The refetch itself is mostly gone too: `covers` lets the graph cache accept a
*contained* corridor, which is exactly the missed-turn shape (a shorter,
narrower capsule inside the one already fetched), so the common case is one
A* instead of up to 96 tiles. A local splice back onto the existing line would
be better still and needs `routing::` changes, so it was left alone rather
than half-done.

**The marker is snapped to the road** under 25 m of error, oriented along the
segment, deferring to the true course past a quarter turn. Progress and
reroute logic keep using the real fix. Unsnapped, every GPS wobble in an urban
canyon read as a positioning bug.

**GPS quality is now visible** — `NoProvider`/`Waiting`/`Error`/`Stale`/
`Poor`/`Good` — and gates the automatic reroute, so leaving a tunnel no longer
triggers a spurious corridor refetch on a fix that is 50 m out.

**There are turn cues.** Five synthesised tones at the countdown bands the
display already coarsens to, plus reroute and arrival. This is the only part
of navigation usable without looking at the screen, which in a car is the only
way it gets used at all. No speech: there is no TTS in this tree.

**Phone layout**: the manoeuvre card and trip strip overlapped by ~56 pt at
274 pt of viewport; they now clamp and stack. Stop moved onto the always-drawn
card, because it used to live on a strip that disappears whenever a panel is
open — and the documented escape hatch was Escape, on a device with no keys.

**Destinations you can actually reach.** `set_endpoint_at` had exactly two
callers, so the only way to navigate to something you had *found* was to fly
there and long-press the map. Search results, saved places and POIs you walk
up to all route now, through a queued request because none of those systems
owns `RouteState`. Named requests take a new `set_endpoint_named` that skips
the reverse lookup — the row already said "Albert Heijn" and the geocoder
would have spent a round trip to answer "Kerkstraat 12".

Search also stops reading as broken: Nominatim does no prefix matching, so a
half-typed name returns nothing, and the dropdown used to flash empty between
keystrokes. The last list stays, greyed, and "No results" waits 600 ms. The
address bar's empty state lists recents, which on a phone is a first-class
destination source. The route dropdown is clamped to the viewport — it forced
a 300 pt minimum on a 274 pt screen.

Back also dismisses the on-screen keyboard before anything else, which is
where the system IME sits on every other Android app; without that ordering it
would have left driver mode out from under someone trying to put the keys
away.
feat(ui): one visual system, a phone-shaped chrome, and an About screen
Some checks failed
CI / cargo check (push) Failing after 2m26s
CI / cargo check (pull_request) Failing after 47s
CI / build & test viberfox (push) Failing after 3m25s
CI / build & test viberfox (pull_request) Failing after 2m12s
e8174e4a8f
**The tofu discovery is the part worth reading.** A new test walks the crate
source and asserts every non-Latin-1 character in a short string literal has a
glyph in the loaded font stack. It found that the bundled fonts can draw
almost **no emoji at all** — not `▶ ◀ ✔ 🏠 📷 📋 📍 🚩 📝🔄🌙 ↗ →`, none
of them. So the Back and Forward buttons, the Home button, the camera picker,
the day/night switcher, the copy confirmation, every bookmark row action and
the "Driver mode" button have all been painting empty boxes, on every
platform, for as long as they have existed.

This was invisible from here (the container has no GPU) and the inventory
comment in `theme.rs` asserted the opposite — twice, once in the original and
once when it was "re-derived from the cmaps" during this work. Both were
wrong in the same way: the glyph exists in a font file, but `Font::has_glyph`
resolves through the whole fallback chain and reports what will actually be
drawn, and that is the only answer that matters. Two of my own first-pass
fixes (`📌`, `➡`) were themselves tofu and had to be redone.

Everything now goes through `theme::first_drawable` with a tail that is known
present, or uses a character verified in the stack (`○ ◎ ★ ☆ × ⚑ ⚐ ⌖ · • ■ ⚙
☰ ⎘`). Where no glyph survives, the control says a word — worse-looking than
an icon and infinitely better than a box, and on touch it is better outright,
since a tooltip can never appear there. The allowlist the test was born with
is empty, and the test fails if an entry stops reproducing, so it cannot
quietly outlive its reason.

**The visual system.** theme.rs gains the vocabulary the drift had no API for:
a type scale (the sub-label alone had drifted to 13 / 12.5 / 12 / 11 across
four panels), a radius scale (one documented value, eight in the app),
`primary_button`, `chip_frame`, and touch-target constants — `interact_size`
was 24 pt, i.e. ~36 dp on a phone, against a 48 dp guideline.

**The phone.** The address bar was ~1.7× wider than the screen and the
hamburger — the only route to Settings, Sign-in and Exit — was drawn last and
therefore first off the edge; it now folds. The Layers panel had no
`ScrollArea` and was half again taller than the viewport. The sign-in card was
484 pt wide, unmovable and uncentred past the edge, offering a Solo row that
cannot work in a build without the feature. The object editor wore egui's
stock window chrome against nine panels wearing the theme's.

**About** — version, build, and the full credits parsed from
`LICENSE-THIRD-PARTY.md`. ODbL and CC BY 4.0 require attribution a user can
find, and until now the only attribution in the app was three small lines at
the bottom of one collapsible panel.

**Settings** became a real settings screen rather than a mic level meter:
units, turn chimes, keep-screen-on, night mode, quality preset. Nothing fakes
an effect — a preference with no consumer says so. The two that do have
consumers are wired: keep-screen-on is derived from state each frame rather
than set at the ends of driver mode, so turning it off mid-drive releases the
lock, and the quality preset is read before `quality::init` (via a separate
silent read, so the store's own "user.json unreadable" warning still lands
after the log subscriber exists).

The web loader was a developer console — a monospace check list and a boot log
shown for the whole ~70 MB download — while `splash.rs`'s own module doc says
the two splashes are drawn to match. It now carries the logo, wordmark and
motto, with the diagnostics behind the `?log` flag that already existed, and
both abort pages point Android visitors at the APK they do not need WebGPU
for.
jeroen merged commit 4925257bc6 into main 2026-08-11 04:38:23 +00:00
Sign in to join this conversation.
No description provided.