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: