Write down what the Android target actually costs

FR-9.17 to FR-9.19 state the screen-size contract: size is measured, the
legibility floors are absolute, and touch is a first-class input.
NFR-12 to NFR-14 state what the build now guarantees — pinned images, no
source that exists only under gen/, and BLE Java locked to the crate.

Three corrections to the spec, all of them places where it described a
plan the code did not follow:

The stack table still named tauri-plugin-blec. The code uses btleplug
directly and has since the BLE crate was written; blec wraps it in its
own device model, which crates/ble would then have to be written against
twice. Recorded with the reason, not silently edited.

RISK-1 (btleplug's Android backend is the least mature part of the stack)
is partly retired rather than closed: the target builds, the Java is
version-locked and the JNI init is symbol-checked in CI. A scan and a
connect on real hardware are still unproven, so TASK-4 keeps that half
and says exactly what is left — install on a phone, find the D100 and
both pods, and survive a screen-off.

NFR-11 is honest about being coarser on Android. FLAG_KEEP_SCREEN_ON is
held for as long as the app is foregrounded, not scoped to the ride as
the desktop inhibitor is. It needs no permission and cannot leak, but it
is not what the requirement describes, so the requirement says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 19:53:11 +02:00
co-authored by Claude Opus 5
parent 7a4e2be65e
commit 8fcee06d84
2 changed files with 100 additions and 5 deletions
+90 -1
View File
@@ -221,12 +221,101 @@ ever sees a number.
---
## Android
The Android build is the same Rust, the same Svelte, and the same BLE stack —
`crates/core`, `crates/ble` and `crates/fit` contain no platform code (G-4,
NFR-5). Only two things are Android-specific.
**BLE.** btleplug's Android backend is half Java. `btleplug::platform::init`
must be handed a `JNIEnv` from a Java thread before the first scan, so
`MainActivity.onCreate` calls into `src-tauri/src/android.rs` to do it. Those
Java classes are not a maven dependency: `scripts/sync-android-sources.sh`
lifts them out of the btleplug and jni-utils crate sources at exactly the
versions in `Cargo.lock`, which is what makes a Java/Rust mismatch impossible.
**Screen size.** See below.
```bash
cd src-tauri
cargo tauri android init # regenerates gen/android from scratch
cd ..
./scripts/sync-android-sources.sh # re-applies everything in src-tauri/android/
./scripts/check-android-sources.sh
cd src-tauri && cargo tauri android build --apk --target aarch64
```
`src-tauri/gen/` is generated and untracked. Everything hand-written lives in
`src-tauri/android/` — the manifest with the BLE permissions, `MainActivity.kt`,
the app Gradle script, the ProGuard keep rules, and the theme. Editing a file
under `gen/` loses it at the next `init`; `check-android-sources.sh` fails the
build if anyone does.
Runtime permissions are split at API 31: `BLUETOOTH_SCAN` (with
`neverForLocation`, which we can honestly claim because every scan is filtered
by service UUID) plus `BLUETOOTH_CONNECT` on Android 12+, and
`ACCESS_FINE_LOCATION` below it, because that is what a BLE scan legally
required at the time. `MainActivity` asks on first launch and again on resume.
`adb logcat -s BikeControl` shows the `tracing` output — Android has no stdout,
so it is routed through liblog.
---
## Screen size
The UI treats screen size as a measurement, not a set of guesses. Everything
lives in [`ui/src/lib/viewport.ts`](ui/src/lib/viewport.ts): a pure function
from a measurement (width, height, DPR, whether the pointer is coarse) to a
layout plan — type sizes in pixels, column counts, and which sections are worth
their space. `viewport.svelte.ts` measures and publishes it as CSS custom
properties and `data-*` attributes on `<html>`; the stylesheets read those and
never restate a breakpoint of their own.
The rule the plan follows is that a small screen is a **content** problem, not
a scaling one. Readouts have legibility floors in pixels and never go below
them; when the space is not there the sparklines go, then the secondary effort
numbers, then the detail row — the route chart and the live numbers survive
longest. On a phone the control bar's buttons grow to 48 px and the keyboard
hints disappear, because there is no keyboard behind them.
That is testable without a device, which is the point:
```bash
npm --prefix ui test # asserts the plan for a Pixel 7, a tablet, a desktop…
```
---
## Building and releasing
CI runs on Gitea (`.gitea/workflows/`) inside two images built from this repo:
| Image | Dockerfile | Jobs |
|---|---|---|
| `bikecontrol-builder` | `Dockerfile.builder` | tests, clippy, frontend, Linux deb/AppImage, Android APK |
| `bikecontrol-arch-builder` | `Dockerfile.arch` | the `.pkg.tar.zst` (needs `makepkg`) |
```bash
scripts/build-builder-image.sh --push # both
scripts/build-builder-image.sh --only arch # just the Arch one
```
The workflows pin these by tag, so a Dockerfile change only reaches CI once the
image has been pushed. Pushing a `v*` tag builds all three platforms and
publishes a Gitea release; the Android job needs the `ANDROID_KEYSTORE_BASE64`,
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS` and `ANDROID_KEY_PASSWORD`
secrets, without which the APK is debug-signed.
---
## Development
```bash
cargo test --workspace # 300+ tests
cargo clippy --workspace --all-targets
cd ui && npm run check # svelte-check
npm --prefix ui run check # svelte-check
npm --prefix ui test # vitest — the layout plan
```
The workspace is deliberately layered so most of it is testable without hardware:
+10 -4
View File
@@ -346,7 +346,7 @@ is a deliberate trade of resilience for simplicity.
|-------|--------|-------|
| Shell | Tauri v2 | Desktop + Android from one codebase |
| Core | Rust | BLE, physics, profiles, gearing, FIT |
| BLE | `btleplug` via `tauri-plugin-blec` | blec supplies Android JNI/permission plumbing |
| BLE | `btleplug` directly | One `platform::Manager` on every target. On Android its "droidplug" backend is half Java: the classes are taken from the crate source at the `Cargo.lock` version (NFR-14), `platform::init` is called from `MainActivity.onCreate`, and the runtime permissions are requested there. `tauri-plugin-blec` was the v0.4 plan and would have supplied that plumbing, but it wraps btleplug in its own device model, which `crates/ble` would then have to be written against twice |
| Crypto | `p256`, `hkdf`, `sha2`, `aes`, `ccm` | Click v2 session (§2.3.1); pure Rust, no OpenSSL |
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
| Charts | `uPlot` | Canvas, built for streaming series |
@@ -617,6 +617,9 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| FR-9.11 | Instantaneous power is noisy — show a rolling average alongside or instead | Should |
| FR-9.12 | Dark theme suitable for indoor training | Should |
| FR-9.16 | Estimated energy expenditure in kcal, from measured work and rider mass. One model shared by the live readout and the exported FIT, so the two agree; the trainer's own energy field is not used, because FTMS does not define what it means | Should |
| FR-9.17 | **Screen size is a measured input to the layout, not an assumption.** Viewport width, height, device pixel ratio and pointer coarseness are measured at startup and on every resize, rotation and fold, and a single pure function maps them to type sizes in pixels, column counts and which sections are shown. There is exactly one definition of "narrow" in the codebase; stylesheets consume the result and never restate a breakpoint of their own | Must |
| FR-9.18 | **Legibility floors are absolute.** No readout scales below the size at which it stops being readable from the riding position — the primary number never below 40 px, secondary never below 28 px. A viewport too small for the content sheds content instead: sparklines first, then the summary-style effort numbers, then the route detail row. The route profile (FR-9.7) and the live numbers are the last to go | Must |
| FR-9.19 | **Touch is a first-class input on Android.** Every control is at least 48 px on a coarse pointer, hover styling is suppressed, and keyboard hints are hidden — with a word on every control that carried only a key cap | Must |
**Post-ride**
@@ -641,7 +644,10 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| NFR-8 | **Observability** — all BLE traffic loggable at debug level, including decrypted Click frames, for protocol diagnosis |
| NFR-9 | **Shutdown** — every exit path completes the SAF-2 sequence and closes within 8 seconds, whatever the radio was doing when the rider quit |
| NFR-10 | **No busy-waiting** — a supervisor whose event source has closed drops it. No loop may spin on a permanently-ready future, and no arm of a `biased` select may starve the one that carries the shutdown command |
| NFR-11 | **Screen stays lit** — a live ride (running or paused) holds a system idle inhibitor, so the display never blanks or locks under a rider whose hands are on the bars. Held for the ride, not for the app: it is taken when the ride starts and released when it ends, however it ends. Failing to take it costs a blanked screen, never a ride |
| NFR-11 | **Screen stays lit** — a live ride (running or paused) holds a system idle inhibitor, so the display never blanks or locks under a rider whose hands are on the bars. Held for the ride, not for the app: it is taken when the ride starts and released when it ends, however it ends. Failing to take it costs a blanked screen, never a ride. On Android this is `FLAG_KEEP_SCREEN_ON`, set for as long as the app is in the foreground: it needs no permission and lapses on backgrounding, so there is nothing to leak — but it is coarser than the desktop inhibitor, which is scoped to the ride |
| NFR-12 | **Reproducible builds** — every artifact (Linux `.deb`/AppImage, Arch `.pkg.tar.zst`, Android APK) is built in CI from a pinned container image, not from a developer's machine. The images are built from `Dockerfile.builder` and `Dockerfile.arch` in this repo |
| NFR-13 | **No untracked source**`src-tauri/gen/` is generated and untracked, so every hand-written Android source lives in `src-tauri/android/` and is copied in after each `tauri android init`. CI fails if a source exists only under `gen/`, or differs from its tracked copy. The failure this prevents is silent: an APK that builds and installs and then behaves as if the file was never written |
| NFR-14 | **Version-locked BLE Java** — btleplug's Android backend is half Java, and a Java/Rust mismatch surfaces as a `NoSuchMethodError` at the first scan rather than a build error. Those classes are therefore taken from the crate sources at the versions in `Cargo.lock`, never from a separately published artifact |
---
@@ -712,7 +718,7 @@ resistance change.
|------|-------------|
| **TASK-0** | **Prove the Click v2 — the riskiest thing in the project.** Unlock both pods in the Zwift app, then with `probe`: (a) try the **unencrypted** path (A-3); (b) if that fails, do the full `RideOn` + ECDH/HKDF/AES-CCM handshake; (c) log raw and decrypted frames against a known button sequence; (d) **confirm every button on *both* pods registers** (RISK-9); (e) measure how long events survive without a fresh unlock. Prototyping in Python against §3.6's MIT code is legitimate — the goal is knowledge, not shipped code |
| **TASK-3** | **Characterise the D100 resistance curve** — required to map virtual gears onto it (FR-4.1) |
| **TASK-4** | Android BLE spike: minimal Tauri v2 Android build scanning via `blec` (RISK-1) |
| **TASK-4** | Android BLE spike (RISK-1). **Built, not yet proven on hardware:** the APK builds via btleplug's droidplug backend, and the manifest carries the Android 12+ runtime pair `BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` with `android:usesPermissionFlags="neverForLocation"` on the scan permission — which we can claim honestly only because every scan is filtered by service UUID — plus `ACCESS_FINE_LOCATION` capped at API 30 for older releases. `MainActivity` requests them at runtime before the first scan, not merely declares them. What remains is the actual spike: install on a phone and confirm a scan finds the D100 and both Click pods, and that a connect and a control-point write survive a screen-off |
> If TASK-0(d) fails on the left pod, resolve OQ-10 before building the button mapping.
@@ -746,7 +752,7 @@ FR-4.6 (ERG), FR-9.11/9.12, Android.
| ID | Risk | Impact | Mitigation |
|----|------|--------|------------|
| **RISK-1** | `btleplug`'s Android backend is the least mature part of the stack | Android target lost | TASK-4 spikes it in Phase 0 |
| **RISK-1** | `btleplug`'s Android backend is the least mature part of the stack | Android target lost | Partly retired: the target builds, the Java backend is version-locked to the crate (NFR-14) and the JNI init is wired and symbol-checked in CI. What is *not* yet proven is a scan and a connect on real hardware — TASK-4 |
| **RISK-2** | Virtual shifting feel may be poor if the D100's resistance curve is coarse or laggy | Core feature degraded | TASK-3 characterises it early; fall back to fewer, wider-spaced gears |
| **RISK-3** | Rust FIT *encoders* are thin — most crates read rather than write | FR-8.2 slips | Port the MIT writer from §3.2; TCX fallback |
| **RISK-4** | **No fallback input path (§3.7).** A firmware change or a protocol error leaves no second way in | Controller input lost entirely until the client is fixed | Accepted deliberately. Keep protocol handling isolated in `ble`; pin known-good behaviour in tests; keyboard/on-screen controls (FR-3.19) are the only stopgap |