Record what the interface now promises

Six rows, each of which the last three commits either implement or were
written to stop being ambiguous about.

FR-7.4a says what "configurable" has to mean: reachable from the app,
validated, and durable. FR-7.4 has been a Must since the first draft and
was read as satisfied by a command that existed — which is how every
rider ended up on a 105 kg default.

FR-7.5a puts units on the display side of the line and keeps recordings
SI. FR-9.20 and FR-9.21 are the device screen: roles before radios, and
no MAC addresses. FR-9.22 and FR-9.23 are the ride screen: zones only
against a reference the rider gave, and gradient as a shape with the
next block announced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 20:15:18 +02:00
co-authored by Claude Opus 5
parent 0c757a4a15
commit 41574f694e
+299 -4
View File
@@ -1,9 +1,18 @@
# BikeControl — Requirements Specification # BikeControl — Requirements Specification
**Status:** Draft v0.4 **Status:** Draft v0.5
**Date:** 2026-08-05 **Date:** 2026-08-21
**Target hardware:** Van Rysel D100 trainer + Zwift Cog + **Zwift Click v2** **Target hardware:** Van Rysel D100 trainer + Zwift Cog + **Zwift Click v2**
> **v0.5 changes — the ride leaves the app.** §5.10 adds two ways for a finished activity
> to reach somewhere else: an Android **share sheet**, which needs no credential and is the
> only route into Garmin Connect; and an opt-in **upload to Runalyze**, chosen because its
> auth is a token the rider pastes in rather than an OAuth flow with a client secret to hide.
> Strava is deliberately not in scope (RISK-11). This is the first network traffic the app
> has ever originated, so §1.2's non-goal and NFR-6 are both amended to say precisely what is
> and is not permitted — the short version being that nothing on the network is ever between
> a rider and a ride.
> **v0.4 changes — the input decision is reversed.** Further research found that the Click > **v0.4 changes — the input decision is reversed.** Further research found that the Click
> v2 "unlock" is **performed by the rider in the free Zwift app**, not by the consuming > v2 "unlock" is **performed by the rider in the free Zwift app**, not by the consuming
> application, and that it then works with *any* third-party app for ~24 h (§2.3). Combined > application, and that it then works with *any* third-party app for ~24 h (§2.3). Combined
@@ -41,7 +50,10 @@ D100 smart trainer and a handlebar controller over Bluetooth Low Energy, and pro
### 1.2 Non-goals ### 1.2 Non-goals
- Multiplayer, social features, or any network service. - Multiplayer, social features, or acting as a network *service*. The app never listens on
a socket and never holds an account of its own. One-way export of a finished ride to a
destination the rider configured (§5.10) is not that, and is the only network traffic
the app ever originates.
- 3D world rendering or avatars. - 3D world rendering or avatars.
- ANT+ support (§10.1). - ANT+ support (§10.1).
- **Implementing the Click v2 unlock ourselves.** The rider performs it in the free Zwift - **Implementing the Click v2 unlock ourselves.** The rider performs it in the free Zwift
@@ -370,6 +382,7 @@ is a deliberate trade of resilience for simplicity.
| 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 |
| FIT | Ported from §3.2 (MIT) | See RISK-3 | | FIT | Ported from §3.2 (MIT) | See RISK-3 |
| HTTP | `reqwest` + `rustls-tls` | Activity upload only (FR-10), and nothing else in the app opens a socket. **Not** `native-tls`: OpenSSL cross-compiled against the Android NDK is a build problem this project does not need, and pure-Rust crypto is already the choice for the Click session |
**Architectural constraint.** The control loop lives in **Rust, not JavaScript**. **Architectural constraint.** The control loop lives in **Rust, not JavaScript**.
`tauri-plugin-blec` is designed to expose BLE to the frontend; we use its Rust-side API and `tauri-plugin-blec` is designed to expose BLE to the frontend; we use its Rust-side API and
@@ -383,6 +396,7 @@ bikecontrol/
│ ├── core/ # physics, profiles, gearing, state machine — no BLE, no UI │ ├── core/ # physics, profiles, gearing, state machine — no BLE, no UI
│ ├── ble/ # FTMS client, Zwift Click v2 client (handshake + crypto) │ ├── ble/ # FTMS client, Zwift Click v2 client (handshake + crypto)
│ ├── fit/ # FIT encoder (ported from §3.2) │ ├── fit/ # FIT encoder (ported from §3.2)
│ ├── sync/ # activity upload — one trait, one impl per service (§5.10)
│ └── probe/ # CLI for protocol discovery (Phase 0) │ └── probe/ # CLI for protocol discovery (Phase 0)
├── src-tauri/ # Tauri shell, commands, event bridge ├── src-tauri/ # Tauri shell, commands, event bridge
└── ui/ # web frontend └── ui/ # web frontend
@@ -583,6 +597,8 @@ blocks:
| FR-7.2 | Integrate speed into distance, which drives route position | Must | | FR-7.2 | Integrate speed into distance, which drives route position | Must |
| FR-7.3 | Model inertia so speed changes feel natural rather than snapping to steady state | Must | | FR-7.3 | Model inertia so speed changes feel natural rather than snapping to steady state | Must |
| FR-7.4 | Expose rider mass, bike mass, Crr, CdA and wheel circumference as configurable | Must | | FR-7.4 | Expose rider mass, bike mass, Crr, CdA and wheel circumference as configurable | Must |
| FR-7.4a | **Configurable means reachable and durable.** A setup screen in the app edits them, Rust validates and refuses values that make the engine produce nonsense, and the result is written to `settings.json` in the app data directory so it survives the process. A command the UI never calls does not satisfy FR-7.4 — it left every rider on the 105 kg default | Must |
| FR-7.5a | Units are a **display preference** (metric or imperial), applied at the last step before the glass. Everything computed, stored and recorded stays SI, so a FIT file never depends on what the screen was set to | Should |
| FR-7.5 | Use the trainer's reported speed as diagnostic/fallback only | Should | | FR-7.5 | Use the trainer's reported speed as diagnostic/fallback only | Should |
| FR-7.6 | Track elapsed time, moving time, elevation gained, average and normalised power | Should | | FR-7.6 | Track elapsed time, moving time, elevation gained, average and normalised power | Should |
@@ -623,6 +639,8 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| FR-9.2 | Per-device connect / disconnect / forget, with clear state and error text | Must | | FR-9.2 | Per-device connect / disconnect / forget, with clear state and error text | Must |
| FR-9.3 | Show FTMS control acquisition separately from BLE connection — connected ≠ controllable | Must | | FR-9.3 | Show FTMS control acquisition separately from BLE connection — connected ≠ controllable | Must |
| FR-9.4 | Show per-pod connection and unlock state, with a **guided walkthrough** of the Zwift unlock (FR-3.10) launched from here and from any locked-state warning | Must | | FR-9.4 | Show per-pod connection and unlock state, with a **guided walkthrough** of the Zwift unlock (FR-3.10) launched from here and from any locked-state warning | Must |
| FR-9.20 | **The setup is presented as roles, not radios.** Trainer, shifter and heart rate each get a slot that says what is filling it, what is missing and the one action that would fill it; the device list of FR-9.1 sits below them. A peripheral of no known role is listed but folded away — unless no trainer has been identified at all, since a trainer that does not advertise FTMS until it is connected classifies as unknown | Should |
| FR-9.21 | **No MAC addresses on screen.** An address is not something a rider reads, types or acts on, and on Android it is randomised. The device kind is a glyph and the identity is the name; where two rows would otherwise be indistinguishable, four characters of the address disambiguate them | Should |
**Ride screen** **Ride screen**
@@ -640,6 +658,8 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| 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.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.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.22 | **Effort is shown as a zone where the rider has given a reference.** Power against FTP (Coggan's seven) and heart rate against maximum (five), applied to the rolling average rather than the instantaneous figure so the colour does not strobe at 4 Hz. **No reference, no zone**: an unset FTP draws the plain number rather than a zone measured against a guess. Colour never carries the meaning alone — the zone's short name is rendered beside it | Should |
| FR-9.23 | **Gradient is a shape as well as a number**, and the profile's next block is announced with the distance or time to it — what is coming decides whether to shift now | Should |
| 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 | | 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**
@@ -651,6 +671,242 @@ tests, and removes dependence on the trainer's internal mass assumptions.
--- ---
### 5.10 Sharing and upload (FR-10)
Everything in this section operates on a file that already exists. `stop_ride` writes the
activity and *then* emits the summary (FR-8.2), so by the time a rider can ask for any of
this, the ride is on disk and safe. Nothing here touches the ride loop, the BLE stack or
the recorder, and nothing here may sit between a rider and a ride: the app stays fully
usable with the network down and no account anywhere (NFR-6).
Two destinations, deliberately different in kind.
**Share** (FR-10.110.8) hands the file to another app on the phone. No network code, no
credential, no service-specific knowledge and no terms of service on our side — the rider
picks Strava, Garmin Connect, Dropbox or email from the system chooser and that app does
the rest. It is the *only* route into Garmin Connect, whose Connect Developer Program is
partner-approval-only and effectively closed to new applicants, and it is the cheapest
thing in this document that a rider would use every day.
**Upload** (FR-10.910.24) sends the file to one named service over HTTPS. That service is
**Runalyze**, chosen for what it does *not* require: no OAuth, no registered application,
no client secret to hide inside an open-source binary, and no per-application rate ceiling
shared across every install. Strava has all four of those problems and is out of scope
here — RISK-11. Runalyze is the cheap case that proves the trait, the queue and the failure
classification; a second service should then cost one new impl and nothing else.
#### 5.10.1 Share the activity to another app (Android)
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-10.1 | Share the finished activity to any app on the device that accepts a file, from the post-ride summary (FR-9.13) and from the recovered-ride list (FR-8.4) | Must |
| FR-10.2 | The share hands out a `content://` URI from the app's own `FileProvider` with `FLAG_GRANT_READ_URI_PERMISSION`, scoped to the receiving app for the life of the intent. The FIT is never copied out of app-private storage to make this work | Must |
| FR-10.3 | The receiving app sees the ride's own filename — `2026-08-21T18-42-10.fit` — not a temporary name. A rider looking at Garmin Connect a week later must be able to tell which ride is which | Must |
| FR-10.4 | Presented with `Intent.createChooser`, so the rider always gets a picker and Android never silently commits them to a default | Must |
| FR-10.5 | The share *reads* the automatic copy in the rides directory. Nothing is moved and nothing is deleted, so a dismissed chooser costs nothing — the same contract `save_copy` already holds for FR-9.14 | Must |
| FR-10.6 | Any ride still on disk can be shared, not only the one that just ended | Should |
| FR-10.7 | If nothing on the device accepts the file, say so plainly and point at FR-9.14's save dialog as the way out | Must |
| FR-10.8 | Desktop is out of scope. FR-9.14's save-to-a-chosen-location *is* the desktop share; there is no `xdg-open` handoff and no portal | — |
**Mechanism.**
The manifest already declares a `FileProvider` under `${applicationId}.fileprovider`
`tauri-plugin-dialog` put it there — but its generated `res/xml/file_paths.xml` grants only
`<external-path>` and `<cache-path>`. The rides directory is neither: `app_data_dir()` on
Android is internal, app-private storage. `file_paths.xml` therefore needs a `<files-path>`
entry covering `rides/`, and per NFR-13 that file must become a **tracked** source under
`src-tauri/android/src/main/res/xml/`, copied in by `scripts/sync-android-sources.sh`
otherwise the next `tauri android init` regenerates the two-entry version over it. This is
precisely the silent failure NFR-13 exists for: an APK that builds, installs, runs, and
throws `IllegalArgumentException: Failed to find configured root` the first time anyone
presses Share.
The share itself is one Kotlin method on `MainActivity`, called over JNI from `android.rs`
exactly as `requestBluetoothEnable` already is, plus one Tauri command in front of it:
```text
ACTION_SEND
type = <OQ-11>
EXTRA_STREAM = FileProvider.getUriForFile(ctx, "$packageName.fileprovider", fit)
addFlags(FLAG_GRANT_READ_URI_PERMISSION)
→ startActivity(Intent.createChooser(intent, …))
```
`startActivity` may only be called from the UI thread, and the thread hop belongs in Kotlin
for the reason `requestBluetoothEnable` documents: Rust has no cheap way to get there.
The MIME type is the one part that cannot be settled from a desk. `application/vnd.ant.fit`
is the registered type; `application/octet-stream` is what a good many Android apps actually
filter on. Guessing wrong yields an *empty chooser*, not an error — OQ-11, RISK-10.
#### 5.10.2 Upload the activity to Runalyze
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-10.9 | Upload a finished activity to Runalyze | Must |
| FR-10.10 | The only credential ever asked for is a personal API token, generated by the rider at `runalyze.com/settings/personal-api` and pasted in. No password, no OAuth, no account of ours | Must |
| FR-10.11 | Upload is **opt-in and off by default**. An installation that has never been given a token originates no network traffic of any kind, ever (NFR-6) | Must |
| FR-10.12 | With auto-upload on, a finished ride is queued at the moment the activity is written — not when the rider happens to reach a screen | Must |
| FR-10.13 | With it off, the rider can still upload a chosen ride on demand | Must |
| FR-10.14 | The upload never blocks the summary, the UI, or the next ride. A rider who finishes and immediately starts again waits on nothing | Must |
| FR-10.15 | The queue survives process death, a force-stop and a flight-mode ride. A ride queued in a basement uploads when the phone next sees a network, on a later launch if that is when it happens | Must |
| FR-10.16 | Retries are bounded and backed off; the backoff is persisted, so a restart cannot reset it into a hot loop | Must |
| FR-10.17 | Errors are classified *retryable* (DNS, TLS, timeout, 5xx, 429) or *permanent* (bad token, rejected file, other 4xx), and the two are never conflated. A bad token must not be retried for a week, and a dropped connection must not be reported as a rejected ride | Must |
| FR-10.18 | Idempotent per (ride, service): a ride already accepted is never sent twice, whatever the rider presses | Must |
| FR-10.19 | A duplicate reported by the service is recorded as **success**, not failure. That single rule is what makes FR-10.16's retries safe | Must |
| FR-10.20 | `recording::prune` never deletes a ride with a non-terminal queue entry, however old it is | Must |
| FR-10.21 | The rider can see the state of every queued upload, retry a failed one, and cancel one outright | Should |
| FR-10.22 | The token is never written to a log, never put in a message shown to the rider, and never emitted as a `tracing` field at any level — NFR-8's "log everything" stops at the credential | Must |
| FR-10.23 | TLS certificate verification is never disabled, and no setting exists that disables it | Must |
| FR-10.24 | A failed upload cannot damage the ride. The rides directory is read-only to this whole subsystem | Must |
**The API, as we use it.** Confirmed against Runalyze's own reference client
(`Runalyze/personal-api`, `bash/upload-activities.sh`):
```text
POST https://runalyze.com/api/v1/activities/uploads
token: <personal token>
Content-Type: multipart/form-data
file=@2026-08-21T18-42-10.fit
```
| Element | Value | State |
|---------|-------|-------|
| Endpoint | `POST /api/v1/activities/uploads` | **Confirmed** |
| Auth | a `token:` header — *not* `Authorization:` | **Confirmed** |
| Form field | `file` | **Confirmed** |
| Accepted formats | `fit`, `tcx`, `gpx`, `ttbin`, `fitlog` | **Confirmed** |
| Auth failure | response body contains `No valid token` | **Confirmed** — the only error the reference client checks |
| Success body | — | **Unknown, OQ-12** |
| Duplicate response | — | **Unknown, OQ-12** |
| Rate / size limits | none published | Unknown |
Two consequences worth stating before anything is written.
**Processing is asynchronous on their side.** Runalyze's own README says uploads are queued
and do not appear immediately. There is no activity id to poll for and no "it is live"
moment to report, so an accepted upload means *accepted* and the UI must say that — not
"uploaded to Runalyze", as though the rider could go and look at it. This is simpler than
Strava's upload-then-poll; the honest wording is the whole of the difference.
**The success and duplicate bodies must be captured, not guessed.** The reference client
discards the body apart from one substring check, and the published documentation is behind
a login. Before FR-10.19 can be implemented, one real upload and one deliberate re-upload of
the same file are run by hand and both bodies pinned as fixtures — the same discipline
§2.3.1 applied to the Click's handshake reply, which turned out to be `02 03` and not the
documented `01 03`. Until that is done the honest classification is: a 2xx not containing
`No valid token` is *accepted*, and duplicate detection does not exist.
#### 5.10.3 The upload queue
The design problem here is not the HTTP. It is that a rider finishes in a basement, on a
phone with no signal, and closes the app. `stop_ride``POST` is wrong for exactly the
reason `stop_ride` → save-dialog was wrong: it makes the ride's fate depend on a moment that
may not go well. §5.8 solved that by writing the file first and offering the dialog second.
This is the same solution one layer out — an upload is a **pending intent recorded next to
the file**, not an action attempted once.
```text
app_data_dir()/uploads.json
{ "version": 1,
"entries": [ { "ride": "2026-08-21T18-42-10",
"target": "runalyze",
"state": "pending" | "done" | "failed",
"attempts": 3,
"nextAttemptMs": 1755800000000,
"detail": "No valid token" }, … ] }
```
Same contract as `devices.json` and `settings.json`: versioned, an unknown version discarded
rather than guessed at, written only when something changes, and **advisory** — an
unreadable `uploads.json` costs the rider an upload, never a ride, so every failure there is
logged and swallowed.
| From | Event | To |
|------|-------|----|
| *(ride written, or the rider asks)* | queued | `pending`, attempts 0 |
| `pending` | accepted — **or duplicate** (FR-10.19) | `done`, **terminal** |
| `pending` | retryable error, attempts left | `pending`, attempts + 1, `nextAttemptMs` pushed out |
| `pending` | retryable error, attempts exhausted | `failed`, **terminal**, carrying the last reason |
| `pending` | permanent error | `failed`, **terminal**, carrying the reason |
| `failed` | explicit rider retry (FR-10.21) | `pending`, attempts 0 |
| any | the FIT is no longer on disk | entry dropped |
- Backoff exponential from 30 s, capped near an hour, and **persisted** — a relaunch must
not reset a failing entry into a tight retry loop against someone else's server.
- Attempts capped (10). Past that the entry is `failed` carrying the last retryable reason,
and only an explicit rider retry revives it.
- The drain runs at launch — *after* `recover_orphans`, so a ride rebuilt from its journal
can be queued too — and on a low-frequency timer thereafter. Never on the ride loop's
thread, and it never holds the ride-state lock across a socket, for the reason
`RecorderHandle` documents about holding it across an `fsync`.
- One upload in flight at a time. Concurrency buys nothing here and costs correctness.
- An entry whose FIT has gone (deleted by a rider, pruned by an older build) is dropped,
not retried.
**Prune (FR-10.20).** `prune` currently keeps the newest 20 stems and deletes the rest. It
gains a predicate: a stem with a non-terminal entry in `uploads.json` is never deleted. Without
that, a laptop left off for three weeks quietly deletes the rides it was going to upload.
#### 5.10.4 Where the code goes
```text
crates/sync/ # NEW — one trait, one impl. No Tauri, no filesystem policy.
├── lib.rs # ActivityTarget, UploadOutcome, UploadError
└── runalyze.rs # the multipart POST and its response classification
src-tauri/src/
└── uploads.rs # NEW — the store, the queue, the drain task, the commands
```
```rust
pub struct Activity<'a> { pub path: &'a Path, pub filename: &'a str }
pub enum UploadOutcome { Accepted { remote_id: Option<String> }, Duplicate }
pub enum UploadError {
/// Try again later: DNS, TLS, timeout, 5xx, 429.
Retryable(String),
/// Do not try again: bad token, rejected file, any other 4xx.
Permanent(String),
}
pub trait ActivityTarget {
fn id(&self) -> &'static str;
async fn upload(&self, activity: Activity<'_>) -> Result<UploadOutcome, UploadError>;
}
```
`crates/sync` takes no Tauri dependency and knows nothing about where rides live, for the
same reason `crates/fit` knows neither: it can then be tested against a local mock HTTP
server with no app running, the way `fit` is tested by encoding with our writer and decoding
with somebody else's parser. `Duplicate` is an outcome rather than an error precisely
because FR-10.19 turns on it, and a variant is harder to mishandle than a string match at a
call site.
Dependency: `reqwest` with `rustls-tls`, **not** `native-tls`. OpenSSL cross-compiled
against the Android NDK is a build problem this project does not need, and pure-Rust crypto
is the choice already made for the Click session (§4).
#### 5.10.5 The token
`devices.json` and `settings.json` are plaintext, which is right for a MAC address and a
rider's mass. An API token is not in that class: it grants write access to somebody's
training history for as long as they leave it valid.
| ID | Requirement | Priority |
|----|-------------|----------|
| FR-10.25 | The token is not stored in `settings.json` beside the rider's mass | Must |
| FR-10.26 | Desktop: the OS credential store (`keyring` — Secret Service, Keychain, Credential Manager). A store that is missing or locked degrades to *no token configured* and asks the rider to paste it again; it never silently falls back to a plaintext file | Must |
| FR-10.27 | Android: app-private storage, which the OS already isolates per app — `EncryptedSharedPreferences` if it can be reached without a Java dependency costing more than it saves | Should |
| FR-10.28 | The UI never redisplays a stored token. It shows *configured* / *not configured*, and offers replace and remove | Must |
And be honest about the ceiling: on a rooted phone, or a desktop with no keyring daemon
running, none of this hides the token from someone holding the device. What it buys is that
the token is not sitting in a world-readable JSON file beside the rides, and is not in the
settings file a rider pastes into a bug report.
---
## 6. Non-Functional Requirements ## 6. Non-Functional Requirements
| ID | Requirement | | ID | Requirement |
@@ -660,7 +916,7 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| NFR-3 | **UI** — charts stay smooth for a 2-hour ride without unbounded memory growth | | NFR-3 | **UI** — charts stay smooth for a 2-hour ride without unbounded memory growth |
| NFR-4 | **Resilience** — no BLE dropout, malformed packet or missing characteristic may crash the app | | NFR-4 | **Resilience** — no BLE dropout, malformed packet or missing characteristic may crash the app |
| NFR-5 | **Portability**`core` and `fit` compile for Android with no platform-specific code | | NFR-5 | **Portability**`core` and `fit` compile for Android with no platform-specific code |
| NFR-6 | **Offline**full functionality with no internet connection. The app never contacts Zwift or any other service; only the rider's separate daily unlock needs the internet | | NFR-6 | **Offline**every function that is part of *riding* works with no internet connection: discovery, connection, control, profiles, physics, recording and the FIT file. The app contacts no service unless the rider has configured one (FR-10.11), and it never contacts Zwift — only the rider's separate daily unlock needs the internet. An installation with no upload token configured originates no network traffic at all |
| NFR-7 | **Startup** — launch to scanning in under 3 seconds | | NFR-7 | **Startup** — launch to scanning in under 3 seconds |
| 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 |
@@ -669,6 +925,8 @@ tests, and removes dependence on the trainer's internal mass assumptions.
| 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-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-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 | | 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 |
| NFR-15 | **Nothing on the network is on the ride path** — no ride may be blocked, delayed or degraded by a network operation. Sharing and uploading run *behind* the ride, never in front of it; the ride loop awaits neither, and a service that is down, slow or wrong costs a rider an upload and never a session |
| NFR-16 | **Credentials are not settings** — an API token is never stored in `settings.json`, never logged at any level, and never redisplayed by the UI once stored (FR-10.22, FR-10.2510.28). NFR-8's "log everything" stops at the credential |
--- ---
@@ -748,6 +1006,20 @@ Three rules fall out of it:
by the rider in Zwift by the rider in Zwift
``` ```
Everything above is the ride. §5.10 hangs off the *right* of it and touches nothing in it:
```
Recorder ──► rides/<stamp>.fit ──┬──► the FIT is the ride artifact; nothing below alters it
├──► FR-9.14 save dialog (desktop + Android, today)
├──► FR-10.1 share sheet (Android — JNI → Kotlin → FileProvider)
└──► FR-10.9 upload queue ──► crates/sync ──► Runalyze
uploads.json (HTTPS, opt-in)
```
The arrows only ever point away from the file. Nothing on the right re-enters the ride loop,
and the queue is the only thing there that owns state of its own.
--- ---
## 9. Delivery Plan ## 9. Delivery Plan
@@ -806,6 +1078,20 @@ TASK-3's resistance-curve calibration), RISK-9 resolution.
FR-1.5 (auto-connect), FR-3.7 (battery), FR-3.14 (mark as unlocked), FR-3.17 (rebinding), FR-1.5 (auto-connect), FR-3.7 (battery), FR-3.14 (mark as unlocked), FR-3.17 (rebinding),
FR-4.6 (ERG), FR-9.11/9.12, Android. FR-4.6 (ERG), FR-9.11/9.12, Android.
### Phase 5 — Sharing and upload
FR-10, plus the NFR-6 / NFR-15 / NFR-16 amendments. Two independent pieces: the share ships
first and is blocked on nothing in the upload.
| Step | Work |
|------|------|
| **1** | **Share (FR-10.110.8).** `file_paths.xml` becomes a tracked source under `src-tauri/android/` with a `<files-path>` for `rides/`, copied by `sync-android-sources.sh` and checked in CI like the manifest (NFR-13). One Kotlin method on `MainActivity`, one JNI entry in `android.rs`, one command, one button on the summary. **Settles OQ-11 on a real phone against Garmin Connect and Strava** — which is the reason it goes first |
| **2** | **Token storage (FR-10.2510.28)**, before any code exists that has a token to lose |
| **3** | **`crates/sync` and the Runalyze impl**, against a local mock HTTP server. Then one real upload and one deliberate re-upload by hand, both bodies pinned as fixtures — **OQ-12**, without which FR-10.19 is a guess |
| **4** | **The queue (FR-10.1210.21)**: `uploads.json`, the drain task, the prune predicate, the UI |
*Milestone: a ride reaches Garmin Connect from the phone with no cable, and reaches Runalyze
without the rider touching anything.*
--- ---
## 10. Risks ## 10. Risks
@@ -821,6 +1107,10 @@ FR-4.6 (ERG), FR-9.11/9.12, Android.
| **RISK-7** | The unlock expires ~24 h, so a forgotten re-unlock blocks a ride | Frustration at session start | Detect and warn *before* the ride (FR-3.12), with a guided walkthrough (FR-3.10) | | **RISK-7** | The unlock expires ~24 h, so a forgotten re-unlock blocks a ride | Frustration at session start | Detect and warn *before* the ride (FR-3.12), with a guided walkthrough (FR-3.10) |
| **RISK-8** | D100 lacks `0x11` sim mode | Reduced fidelity | Low impact — the app owns the physics | | **RISK-8** | D100 lacks `0x11` sim mode | Reduced fidelity | Low impact — the app owns the physics |
| ~~**RISK-9**~~ **RETIRED 2026-08-20** | The left pod was where third-party support was weakest — QZ has an open `wontfix` where the left Click's `` never registers | — | **Did not reproduce.** Every button on both pods verified on its documented bit (§5.3). The one evening the left pod *did* look dead was the teardown leak in §7, not the pod | | ~~**RISK-9**~~ **RETIRED 2026-08-20** | The left pod was where third-party support was weakest — QZ has an open `wontfix` where the left Click's `` never registers | — | **Did not reproduce.** Every button on both pods verified on its documented bit (§5.3). The one evening the left pod *did* look dead was the teardown leak in §7, not the pod |
| **RISK-10** | The FIT share MIME type is not what the receiving apps filter on | **The chooser comes up empty, with no error anywhere.** A feature that looks broken and logs nothing | OQ-11: settle it on a real phone against Garmin Connect, Strava and a file manager *before* the UI is written. `application/vnd.ant.fit` is the registered type; `application/octet-stream` is what many Android apps actually match, and is the fallback |
| **RISK-11** | Strava is the service riders will ask for next, and its token exchange needs a `client_secret` inside an open-source native binary — it has no PKCE — against a rate limit shared by every install rather than held per rider | An obvious feature request carries policy risk and a ceiling that arrives without warning | Deferred deliberately, not forgotten. Runalyze first proves the trait, the queue and the error classification; when Strava is done, the likely shape is the rider registering their *own* API application, which moves both the secret and the rate limit onto them. Until then the share sheet reaches Strava's own app and needs none of it |
| **RISK-12** | Runalyze's success and duplicate response bodies are undocumented — the reference client discards the body apart from one substring check, and the published docs are behind a login | FR-10.18/10.19's idempotency would rest on a guess, and a retried upload could silently double a ride | Capture both by hand and pin them as fixtures before implementing (OQ-12). Until then, treat a 2xx not containing `No valid token` as accepted and do not claim duplicate detection at all |
| **RISK-13** | `file_paths.xml` is generated by the dialog plugin and regenerated by `tauri android init`, and its roots do not cover app-private storage | Share throws `Failed to find configured root` in a build that is otherwise perfectly healthy | Exactly the failure NFR-13 exists for. The file moves under `src-tauri/android/`, the sync script copies it, and CI checks it as it already checks the manifest |
### 10.1 On ANT+ ### 10.1 On ANT+
@@ -842,6 +1132,9 @@ intolerable.
| **OQ-6** | Which frontend framework? Svelte recommended. | | **OQ-6** | Which frontend framework? Svelte recommended. |
| **OQ-9** | *(§3.7)* Port QZ's GPL-3.0 crypto and licence this project GPL-3.0, or reimplement from the documented algorithm and stay unencumbered? | | **OQ-9** | *(§3.7)* Port QZ's GPL-3.0 crypto and licence this project GPL-3.0, or reimplement from the documented algorithm and stay unencumbered? |
| **OQ-10** | If TASK-0 shows the left pod is unreliable (RISK-9), do you want gradient remapped onto the right pod, or would you rather chase the left-pod bug? | | **OQ-10** | If TASK-0 shows the left pod is unreliable (RISK-9), do you want gradient remapped onto the right pod, or would you rather chase the left-pod bug? |
| **OQ-11** | Which MIME type do Garmin Connect's and Strava's Android apps actually accept a `.fit` on — `application/vnd.ant.fit`, `application/octet-stream`, or both? Answered by trying it on a phone, not by reading (RISK-10). |
| **OQ-12** | What do Runalyze's upload responses look like on success, and on a re-upload of a file already accepted? Both need capturing before FR-10.19 can be written (RISK-12). |
| **OQ-13** | Is auto-upload a standing preference or a per-ride choice — and if standing, does a ride abandoned after ninety seconds get uploaded too? (Suggest: standing, with a minimum-duration floor, so a two-minute test of the trainer does not land in the training log.) |
**Resolved:** OQ-1 (paddles shift, D-pad adjusts gradient) · OQ-7 (stay on Tauri, port the **Resolved:** OQ-1 (paddles shift, D-pad adjusts gradient) · OQ-7 (stay on Tauri, port the
MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2). MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
@@ -862,3 +1155,5 @@ MIT logic) · OQ-8 (no bridge, no fallback input path) · Click version (v2).
| **Crr** | Coefficient of rolling resistance | | **Crr** | Coefficient of rolling resistance |
| **CdA** | Drag coefficient × frontal area | | **CdA** | Drag coefficient × frontal area |
| **Normalised power** | Weighted average power reflecting physiological cost of variable efforts | | **Normalised power** | Weighted average power reflecting physiological cost of variable efforts |
| **FileProvider** | Android component that hands another app a `content://` URI onto a file in this app's private storage, without making the file world-readable (FR-10.2) |
| **Personal API token** | Runalyze's credential: a string the rider generates in their own account settings and pastes in. Not OAuth — no authorisation flow, no redirect, no client secret (FR-10.10) |