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:
@@ -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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace # 300+ tests
|
cargo test --workspace # 300+ tests
|
||||||
cargo clippy --workspace --all-targets
|
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:
|
The workspace is deliberately layered so most of it is testable without hardware:
|
||||||
|
|||||||
+10
-4
@@ -346,7 +346,7 @@ is a deliberate trade of resilience for simplicity.
|
|||||||
|-------|--------|-------|
|
|-------|--------|-------|
|
||||||
| Shell | Tauri v2 | Desktop + Android from one codebase |
|
| Shell | Tauri v2 | Desktop + Android from one codebase |
|
||||||
| Core | Rust | BLE, physics, profiles, gearing, FIT |
|
| 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 |
|
| Crypto | `p256`, `hkdf`, `sha2`, `aes`, `ccm` | Click v2 session (§2.3.1); pure Rust, no OpenSSL |
|
||||||
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
|
| Frontend | Web — Svelte recommended | Connection UI, telemetry |
|
||||||
| Charts | `uPlot` | Canvas, built for streaming series |
|
| 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.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.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.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**
|
**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-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-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-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-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-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.
|
> 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 |
|
| 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-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-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 |
|
| **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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user