diff --git a/README.md b/README.md index 9fcb0ed..9f535a1 100644 --- a/README.md +++ b/README.md @@ -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 ``; 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: diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 5b3f4bc..4a5e847 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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 |