Add the Rippr Flutter port: source, history bundle, and installable APK

The port runs on Android and iOS and is feature-complete; the native Android app
is superseded but kept, since it is still the only version that has recorded real
rides.

rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different
from the native com.rippr so both install side by side. Recording the same ride
on both at once is the strongest available check that the port is faithful.

Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS
has no APK equivalent and must be built and signed through Xcode with a free
Apple ID, which gives a 7-day profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 22:37:45 -05:00
parent 64f5dff30b
commit 0bc42b2e5a
98 changed files with 8417 additions and 24 deletions

View File

@@ -0,0 +1,141 @@
# Architecture
Why Rippr is built this way. Ported from the native app's `docs/ARCHITECTURE.md`, keeping
the reasoning that still holds and recording what changed.
```
LocationSource (geolocator) ← the only file that knows the platforms differ
│ LocationFix
▼
RecordingEngine ── unbounded buffer ──► single writer loop (25 fixes / 2 s)
│ │
│ ┌─────────────┴──────────────┐
│ ▼ ▼
│ TripRepository Accumulator
│ (all transitions) distance / moving time /
│ │ elevation, folded per batch
│ ▼
│ Drift (SQLite)
│ Trip → Segment → TrackPoint
│ │
└──► LiveTelemetry ▼
(speedo) Riverpod providers
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
RecordScreen TripsScreen TripDetailScreen
└─ RideMap (flutter_map)
```
---
## The decisions that carry the most weight
### The fix path never blocks
A GPS fix is appended to an in-memory list and nothing else. A single writer loop drains
it in batches every two seconds. Slow disk stalls the writer, never the fix stream, and no
fix is dropped under back pressure.
Kotlin used `Channel(UNLIMITED)` plus a coroutine blocking on `receive()`. Dart has no
blocking receive and its single-threaded event loop makes one unnecessary — but the
guarantee is identical, and it is the reason a 2 Hz stream does not become two database
transactions per second.
### Recording state lives in the database
A row in `trips` with `endedAt IS NULL` **is** the fact of an in-progress ride. It survives
process death, which an in-memory flag cannot: Android can restart the process with no
intent, iOS can suspend and resume, and in both cases a flag would come back `false` while
a ride was genuinely underway.
### Points are stamped at creation
Every point carries its `tripId` and `segmentId` from the moment it is built, never looked
up at write time. This is what makes pause correct: a fix still buffered when the rider
pauses is written to the segment it was actually recorded during.
Refactoring this into a write-time lookup would break the guarantee **silently**.
### Segments make pause correct rather than cosmetic
Without them, pausing at a gas station and resuming across town draws a straight line
through terrain never ridden — and counts it as distance. Segments propagate all the way
through: distance accumulation, map polylines, and GPX `<trkseg>`.
The same reasoning applies to a crash. See `TripRepository.resumeIntoNewSegment`, which
fixes a bug the native app has here.
### Whoever owns the writes owns the database
The single most important structural decision of the port. `TrackingService` wrote to Room
directly. If Dart owned the schema but a native service owned the writes, every fix would
cross a platform channel that is *dead while iOS suspends Dart*.
So Dart owns both, and the platform layer only has to keep the isolate alive. That is also
why there is **one isolate**: the foreground service provides liveness, not a second Dart
runtime, so two isolates never contend for one SQLite file.
### Aggregates are accumulated live, then recomputed authoritatively
Distance and elevation need consecutive-point differences, so they are folded in the
writer loop and persisted per flush — letting the recording screen show live numbers
without rescanning the point table.
Those values are an **estimate**. On completion they are replaced by `computeSummary` over
the stored points, so a mid-ride process kill cannot leave permanently skewed totals.
### Decimation is render-only
Douglas–Peucker exists in the map path and nowhere else. A three-hour ride is ~21,600
points and would jank an undecimated polyline, but storage and export must carry every raw
point. Tests assert exact counts in exports to catch a leak.
---
## What changed from the native app
| Native | Port | Why |
|---|---|---|
| Room | Drift | Same shape; runs on the Dart VM, so data-layer tests need no device |
| Foreground service (hand-written) | geolocator's own | Removes `flutter_foreground_task` and two deprecations. Costs notification actions. |
| `PARTIAL_WAKE_LOCK` | `enableWakeLock` | Same mechanism, configured rather than coded |
| ViewModel + StateFlow | Riverpod | Direct mapping |
| navigation-compose | go_router | Three destinations, same shape |
| osmdroid | flutter_map | Same OSM raster tiles, same no-API-key reasoning |
| FileProvider + ACTION_SEND | share_plus | Also handles the iPad popover anchor |
| OkHttp | package:http | Direct mapping |
| `Float` | `double` | Dart has no float32. See the parity note below. |
### The Float→double divergence
Kotlin stores speed and accuracy as 32-bit `Float`. Dart has no float32, so these widen.
Every other value in the app is bit-identical across the two implementations; speed-derived
values differ in the last digits (`40.030228` vs `40.03022888407912`).
This is verified rather than assumed — `tool/parity/run.sh` compiles the real Kotlin
sources and diffs them against the Dart port across twenty fixtures.
### iOS specifics that are easy to get wrong
- `pauseLocationUpdatesAutomatically: false` — CoreLocation otherwise decides the ride has
ended, and does not reliably restart.
- `activityType: otherNavigation`, **not** `automotiveNavigation` — the latter snaps fixes
to the road network, which silently falsifies a recording of where you actually went.
---
## Things that must not regress
1. The unbounded buffer and single batched writer. Never write to the database from the
fix callback.
2. Points stamped with `tripId`/`segmentId` at creation.
3. Recording state derived from the database, never an in-memory flag.
4. No destructive migration. Any schema change ships a real `Migration`.
5. Decimation is render-only.
6. The theme names its text colours explicitly — a missing content colour once rendered a
64 sp figure black-on-black, and no test caught it.
7. The map zoom clamp. A short ride will render an empty grid without it.
8. `PRAGMA foreign_keys = ON`. SQLite defaults it off and Drift does not set it; without
it every CASCADE is decorative.
9. Tests use in-memory databases. An instrumented test once wiped a real device's rides.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,196 @@
> **Carried forward from the native repo** (`~/dojo/rippr/docs/v3/BACKLOG.md`) when the
> Flutter port completed. Two items below have changed status since it was written:
>
> - **Compose UI tests** — no longer a gap. The port has 15 widget tests plus 4
> integration tests; see `docs/port/PARITY-AUDIT.md`.
> - **Elevation gain accuracy** — the algorithm is now proven bit-identical across Kotlin
> and Dart (`tool/parity/run.sh`), so any future tuning can be checked against the
> original rather than guessed at. The instruction below still stands: **do not tune it
> blind.**
>
> Everything else carries over unchanged, including the v3 ideas and the
> "must not regress" list.
---
# v3 backlog
Everything known-outstanding as of v2.0.1, with enough context to pick up cold.
Nothing here is committed to — it is a menu, roughly ordered by value.
**Before planning anything: run the real-ride checklist in
[the real-ride checklist](port/REAL-RIDE-CHECKLIST.md).** Several items below may turn out to be non-issues, and
others may appear that nobody has thought of.
---
## 1. Carried over from v2 — the honest debt
### Elevation gain accuracy · *needs real data first*
~30 m of phantom gain per ten stationary minutes against synthetic ±8 m uniform noise. Real
GPS altitude error is *correlated* rather than uniform, so the true behaviour is unknown.
The current implementation is a 15-sample moving average plus reversal hysteresis (see
[ARCHITECTURE.md](ARCHITECTURE.md)). A naive version reported 1498 m over a parked
bike, so the guard rails matter.
**Do not tune this blind.** Record a flat ride, check whether the reported gain is
plausible, and only then adjust. If it needs work, options are a longer smoothing window, a
larger threshold, or using barometric pressure where available (much more accurate than GPS
altitude, and most phones have the sensor).
### Compose UI tests · *the largest coverage gap*
Zero UI tests across six screens. Everything was verified by manual screenshot. Worth
covering: navigation record→trips→detail→back, rotation/state retention, empty states,
`NotFound`, chart degradation below two points, selection mode enabling Merge only at two.
### Unmeasured, and probably should be
- **Battery drain** over a multi-hour ride — never measured, and it is the thing most likely
to make the app unusable in practice
- **Map memory across repeated navigation** — the osmdroid lifecycle is a known hazard and
the wiring was never leak-tested
- **GPX import into Strava/Garmin** — validated against an XML parser, but schema validity
does not guarantee a consumer accepts it
---
## 2. The original v3 candidate — live group ride view
Deferred from v2 as "needs real server work". This was in the **v1** brief's goal
statement, so it has been the intended destination all along.
Already in place:
- `TelemetryUploader` — batched POST, retry, offline-safe, cannot stall recording
- `synced` column and backlog semantics
- `trip_id` / `segment_id` per point, so a server can reconstruct rides and pauses
- `Config.deviceId` — stable per-install id to distinguish riders
Missing:
- **A server.** Nothing exists. This is the actual work.
- **UI for the endpoint** — currently only reachable via `Config.setUploadEndpoint()`
- Other riders' positions on a map, and a live map at all (see below)
- Auth, rider identity, group membership
**Worth deciding early:** this is the point where Rippr stops being a local-only app. That
brings hosting, privacy, and location-sharing consent into scope.
---
## 3. Live map on the recording screen — **decision reversed**
v2 deliberately shipped no live map, on the reasoning that the phone rides in a pocket.
Dylan has since asked for one — for visual appeal, and because **people may mount the phone
on the handlebars** to watch the route live. Treat the v2 stance as superseded.
**The handlebar case changes the premise, not just the feature.** v1 and v2 were both built
around "start it, pocket it, stop it". A mounted phone is a different product with different
constraints, and it is worth deciding explicitly whether that becomes a first-class mode:
- **Screen on for the whole ride** — battery goes from "a background service" to "a
service plus a lit screen plus continuous map rendering". Measure before committing.
- **Sunlight legibility** — the current dark theme is chosen for glanceability, but daylight
behind a visor is a different problem.
- **Glove-sized targets** — already partly handled (72dp buttons); a map needs the same care.
- **Keep-screen-awake** handling, and what happens on a call or notification.
What still holds regardless:
- **`TrackingService` must never reference a map.** Rendering belongs to the Compose
lifecycle of a visible screen, not the service.
- **No tile fetch or redraw while backgrounded**, even in mounted mode.
---
## 4. Smaller items
| Item | Notes |
|---|---|
| **Trip splitting** | Merge exists; split does not. The natural counterpart. |
| **SAF export** | Dropped in T16 as unnecessary — share sheet covers it. Add if a real need appears. |
| **Auto-pause** | Detect a stop and pause automatically. Rejected in v2 as unreliable in traffic; revisit only with real ride data showing it would help. |
| **Distance units** | Metric only, hardcoded. Trivial to add a preference. |
| **Settings screen** | None exists. `Config` has endpoint, deviceId, mapEnabled — the map toggle currently lives on trip detail because one switch did not justify a screen. |
| **Offline tile pre-download** | osmdroid caches what it renders; a mountain ride with no signal shows blank tiles. Respect OSM's usage policy — no bulk prefetch of their public servers. |
| **Notification live stats** | Show distance/duration in the ongoing notification, readable without unlocking. |
| **Crash reporting** | None. A recorder that dies mid-ride currently leaves no trace beyond logcat. |
---
## 5. Ideas
Terse on purpose. Unshaped, to be consolidated later.
- **Live map while recording.** More visually appealing than a numbers screen. Reverses the
v2 decision — see section 3 for the constraints that survive it.
- **Pick a real theme.** The current look is functional dark + safety orange, chosen to
match the icon. Decide on an actual visual identity and push the UI toward something
polished rather than merely clean.
- **User sign-up and accounts.** Register people, give their data somewhere to live.
Prerequisite for anything cloud-side, and pairs with the group-ride server in section 2.
- **Activity type per ride.** Motorcycle, bicycle, skateboard, running, other. The app is
not inherently motorcycle-only — the recording pipeline is activity-agnostic already.
Note: adds a column to `Trip`, so it needs a real `Migration` (the destructive fallback
is gone). Type could also drive sensible defaults — speed noise floor, map zoom,
elevation smoothing.
- **Paid cloud backup.** Ongoing storage of rides over time. Needs accounts first, plus a
decision on hosting, pricing, and what happens to data when someone stops paying.
- **Waypoint route planning.** Drop a series of pins on the map to "draw" a route, get
distance and estimates back, and save it to ride later. This is *pre*-ride planning —
a genuinely new mode alongside recording, not an extension of it. Needs its own entity
(`Route` + `Waypoint`), separate from `Trip`, since a plan is not a recording.
Straight-line pin-to-pin distance is easy and reuses `Geo.haversineMeters`; snapping to
actual roads needs a routing service (OSRM, GraphHopper, Valhalla — self-hostable) and is
a much larger step. Natural follow-ons: follow a planned route on the live map, and
compare a recorded ride against the plan afterwards.
### Threads running through these
Sign-up, cloud backup and group ride are one programme, not three: they all need a server,
identity, and a privacy stance. Worth scoping together rather than separately.
Activity type and theming are independent and much cheaper — either could ship alone.
Live map, handlebar mounting and waypoint following also cluster: all three assume a
visible screen during the ride, and all three want the same map component. Route planning
is the odd one out — it needs no ride in progress at all and could be built entirely
standalone.
---
## 6. Things that must not regress
Hard-won and easy to undo by accident. Each has a comment in the code explaining why.
1. **The unbounded `Channel` + single batched writer.** Do not write to the database from
the location callback.
2. **Points stamped with `tripId`/`segmentId` at creation.** Refactoring this into a
write-time lookup breaks the pause guarantee silently.
3. **Recording state derived from the database.** Never reintroduce an in-memory flag.
4. **`fallbackToDestructiveMigration()` stays removed.** Any schema change ships a
`Migration` against `app/schemas/com.rippr.data.AppDatabase/2.json`.
5. **Decimation is render-only.** It must never reach storage or export.
6. **`RipprTheme`'s `Surface`.** It sets `LocalContentColor`; without it, text without an
explicit colour renders black-on-black and disappears.
7. **The map zoom clamp.** `zoomToBoundingBox` ignores `maxZoomLevel`; a short ride will
render an empty grid without it.
8. **Instrumented tests use in-memory databases.** One previously wiped the real device
database in `setUp`.
---
## 7. Reading order for picking this up cold
1. [../README.md](../README.md) — what the app is and its current state
2. [ARCHITECTURE.md](ARCHITECTURE.md) — why it is built this way
3. `~/dojo/rippr/docs/v2/PROGRESS.md` (native repo) — every bug found during v2 and how
4. [the real-ride checklist](port/REAL-RIDE-CHECKLIST.md) — **especially "What the emulator cannot verify"**
5. `~/dojo/rippr/docs/DEVELOPMENT.md` (native repo) — when you actually need to build something
The v2 planning approach worked well and is worth repeating: one document per task with
goal, context, design, acceptance criteria and risks, written *before* implementing, plus a
running progress log recording what actually went wrong. Several bugs were caught precisely
because the risk had been written down first — and one (the osmdroid lifecycle) was written
down and then walked into anyway, which is its own lesson.

View File

@@ -0,0 +1,168 @@
# T27 — parity audit
Every row of the native app's v2.0.1 feature table, with the evidence behind each claim.
**Deliberately not ticked in bulk.** A v2 task once had every acceptance criterion checked
by a blanket regex, including items nobody had verified. So each row below states *how* it
is known, and three categories are kept apart:
| | Meaning |
|---|---|
| ✅ **Verified** | An automated test asserts it, or it was demonstrated running on a device |
| 🟡 **Implemented, unproven** | The code is there and reviewed, but nothing has exercised it in the conditions that matter |
| ⛔ **Gap** | Present in the native app, absent here |
**175 automated tests** — 171 unit and widget, 4 integration on a real iOS simulator.
---
## The feature table
### GPS recording via foreground service — ✅ / 🟡
**Verified:** the pipeline has 24 tests covering all five actions, batching, the unbounded
buffer, accuracy filtering, noise-floor sanitisation and process-death resume. The app
launches and runs on an iOS simulator.
**Unproven:** that it records a real ride. No simulator produces velocity, so speed,
moving time and speed colouring are structurally untestable here.
Android liveness moved from a hand-written `TrackingService` to geolocator's own
foreground service (`foregroundServiceType="location"`, `enableWakeLock`), verified
present in the merged manifest. iOS uses `UIBackgroundModes: [location]` with
`allowBackgroundLocationUpdates`.
→ `REAL-RIDE-CHECKLIST.md` items 1–11, A1–A3, I1–I5.
### Trips with pause/resume/stop/discard — ✅
26 repository tests plus 24 engine tests. Every transition idempotent inside a
transaction; `startTrip` adopts rather than duplicating; pausing closes the segment;
discard cascades; a ride that captured nothing is dropped rather than saved.
The pause guarantee has its own test: **a fix buffered before a pause is written with the
old segment id**, because ids are stamped at creation.
### Live speed + elapsed clock — ✅
The 2.0.1 fix is pinned by two widget tests: the headline reads `SPEED`, not `MAX SPEED`,
and a separate test asserts the figure names a colour distinct from the background — the
black-on-black regression that only a screenshot caught in v2.
The clock now ticks **only while a ride is active**, which is both correct and what makes
the screen testable at all.
### Path rendered on OpenStreetMap — ✅ / 🟡
**Verified** by four map tests, which the native map never had: one polyline per segment
with a test asserting none straddles a pause, render-only decimation, the zoom clamp at
OSM's max tile zoom 19, and a `shortRideZoom` fallback for degenerate bounds — the v2.0
empty-grid bug, now guarded.
**Unproven:** speed colouring. Every simulated path renders in one colour because speed is
always zero.
### Ride statistics + charts — ✅
21 statistics tests, and stronger evidence than the native app ever had: the parity
harness drives the real Kotlin and the Dart port from one shared fixture and they agree
**to the last digit**, including the elevation accumulator at `38.959594555022136`.
Charts degrade to a message below two points rather than rendering a blank box, with a
widget test for it.
**Carried over deliberately:** the ~30 m elevation drift against synthetic noise. Fixing
it during translation would have made every differential failure ambiguous. It stays in
the v3 backlog, where the standing instruction is *do not tune this blind*.
### Rename / delete / merge rides — ✅
Repository tests cover merge re-parenting, rejection of self/active/missing merges,
atomicity, and the two properties that matter: **segments are never joined**, and
aggregates are **recomputed rather than summed** because distance is not additive across
the gap. Widget tests cover the UI, including Merge enabling at exactly two selections.
Rename collapses empty and whitespace-only input to null.
### GPX + GeoJSON export — ✅
15 tests parsed with a real XML parser and `jsonDecode`, not substring matching. The
parity harness additionally confirms both formats are **byte-identical** to the Kotlin
output — same length, same FNV hash, including an escaped hostile trip name.
Exports carry the raw stored points; decimation never reaches them.
### Upload to a REST endpoint — ✅ (still no UI, as before)
7 tests: disabled endpoint, success, rejection, network failure, multi-batch backlog,
partial failure, and per-point trip/segment identity. Runs on its own timer so a dead
endpoint cannot disturb recording.
Parity is exact, including the absence of UI.
### Live map during recording — ⛔ by design
Absent in v2, absent here. It is a **v3 decision**, and the backlog records that Dylan has
since asked for it and that handlebar mounting changes the app's founding premise.
### Group ride view — ⛔ by design
Deferred to v3 in the native app; unchanged.
---
## Gaps and departures
### ⛔ Notification actions — the one capability lost
The native notification carried Pause/Resume buttons. geolocator's
`ForegroundNotificationConfig` cannot carry actions, so the notification is display-only;
tapping it opens the app.
This was the price of dropping `flutter_foreground_task`, which bought the removal of two
deprecations that Flutter says become hard build errors. Worth revisiting if the shade
controls turn out to matter on a real ride — a separate notification plugin could add them
back without touching the engine.
### ✅ A departure that fixes a native bug
`restoreAfterProcessDeath` now resumes into a genuinely new segment. The native version
adopts the segment a crash left open, so the authoritative recomputation measures straight
through the dead time — **111 km of phantom distance** in the reproduction. Documented at
length in `PROGRESS.md` and in `TripRepository.resumeIntoNewSegment`.
### Improvements that are not parity items
- **The first UI tests this project has ever had** (15), closing what the v3 backlog names
as v2's largest coverage gap.
- **Instrumented tests became unit tests.** `SchemaTest`, `TripRepositoryTest` and
`MergeTest` were 666 lines needing a booted emulator; they now run in about two seconds
with nothing running.
- **A cross-language parity harness** that can be re-run at any time.
- A **record screen layout bug** fixed that Compose was clipping silently.
---
## Not done, and why
**The bundle id is still `com.rippr.port`.** Switching it to `com.rippr` is the final
cutover step — but doing it now would make the two apps unable to coexist, and
`REAL-RIDE-CHECKLIST.md` item **A3** depends on recording the same ride on both
simultaneously. That comparison is the strongest evidence available that the port is
faithful.
**Switch it after T25, not before.**
Also outstanding before any release: the app icon is still the Flutter default, and every
build so far has been debug.
---
## Verdict
Feature parity is **complete in implementation** and **verified as far as anything can be
without riding**. One capability is lost (notification actions), one native bug is fixed,
and the test coverage is substantially better than the app being replaced.
What remains is not code. It is a rider, two phones, and
[REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md).

View File

@@ -0,0 +1,349 @@
# Rippr — Flutter Port (Android + iOS)
## Context
Rippr is a native Android GPS ride recorder: ~3,800 lines of Kotlin across 34 files,
shipped through v1 and v2.0.1, validated on real rides. It records telemetry from a
foreground service into Room, renders the path on osmdroid, and exports GPX/GeoJSON.
Dylan wants one codebase running on **both iOS and Android** before any further features
are built. This is a **full rewrite in Flutter**, not an incremental or add-to-app
migration — the research in `docs/PORT_RESEARCH.md` puts the cutover threshold at 10
screens and Rippr has **six**, with no legacy debt worth preserving in Kotlin form.
**The goal is parity, not improvement.** Every capability in the v2.0.1 feature table
works on both platforms; v3 features stay in the backlog. Deliberate scope discipline —
the port is finished when it does exactly what the Kotlin app does.
### Three findings that shape this plan
**The pure-logic core ports almost verbatim.** Eight files (~890 lines) carry zero Android
imports — a discipline held deliberately since v1. `Geo`, `RideStatistics`,
`RideAccumulator`, `RideExport`, `Telemetry`, `Format`, `LiveTelemetry`, `UploadStatus`.
Their ~965 lines of existing JVM tests become a **differential oracle**: the same fixtures
must produce the same numbers in Dart. This is the single biggest de-risker available and
Phase 1 exists to cash it in first.
**Whoever owns the writes must own the database.** `TrackingService` writes to Room
directly from its writer loop. If Dart owns the schema but a native service owns the
writes, every fix crosses a platform channel that is *dead while iOS suspends Dart*. So
Dart owns both, and the background layer only has to keep the Dart isolate alive.
**iOS suspends a stationary app; Android does not.** This is the one place where "write
once" is partly an illusion, and it gets its own task and its own real-world validation
rather than being discovered late.
### Decisions taken
| Decision | Choice | Rationale |
|---|---|---|
| Strategy | **Full rewrite** | 6 screens, well below the 10-screen threshold |
| Repo | **`~/dojo/rippr-flutter`**, new git history | Native app stays installable as reference and fallback |
| Existing rides | **Start fresh** | No importer; export to GPX first if any ride matters |
| Location engine | **`geolocator` + `flutter_foreground_task`** | MIT/free; avoids the ~$500/yr Transistor licence |
| Isolate model | **Single isolate** | Foreground service for process liveness only — no second isolate, no cross-isolate SQLite |
| Database | **Drift** | Type-safe, streams, real migrations, runs on the Dart VM |
| State | **Riverpod** | Maps cleanly from ViewModel + StateFlow |
| Routing | **go_router** | Three destinations, same shape as navigation-compose |
| Map | **flutter_map** | OSM raster tiles, no API key — same reasoning that chose osmdroid |
| Live map while recording | **Still no** | Parity target is v2.0.1; it is a v3 decision |
### Non-goals
Every v3 backlog item: live map, waypointing, activity type, accounts, cloud backup,
theming overhaul, group ride. Also no new features of any kind, and no data importer.
---
## Architecture mapping
```
TrackingService (Kotlin) RecordingEngine (Dart, main isolate)
FusedLocationProviderClient ──► geolocator.getPositionStream
Channel<TrackPoint>(UNLIMITED) ──► StreamController (unbounded)
single writer coroutine ──► single async drain loop (batch 25 / 2s)
Mutex ──► package:synchronized Lock
Room + @Transaction ──► Drift + transaction()
Flow<Trip?> ──► Stream<Trip?> (Drift .watch)
PARTIAL_WAKE_LOCK ──► flutter_foreground_task (Android)
START_STICKY + adopt active ──► on-launch adopt of `endedAt IS NULL`
ViewModel + StateFlow ──► Riverpod Notifier / AsyncNotifier
navigation-compose ──► go_router
osmdroid MapView ──► flutter_map
FileProvider + ACTION_SEND ──► share_plus
SharedPreferences ──► shared_preferences
OkHttp ──► package:http
```
**Platform-divergent, by necessity:** Android runs a foreground service with a persistent
notification to keep the process alive. iOS declares `UIBackgroundModes: location` and
sets `allowsBackgroundLocationUpdates` with `pauseLocationUpdatesAutomatically = false`.
Both keep the *same* Dart pipeline running; only the liveness mechanism differs.
**Invariants carried over verbatim** (from `docs/v3/BACKLOG.md` §6 — each has a comment in
the Kotlin explaining why, and each must survive the port):
1. The location callback never blocks on disk — unbounded buffer, single batched writer
2. Points stamped with `tripId`/`segmentId` **at creation**, never looked up at write time
3. Recording state derived from the database, never an in-memory flag
4. No destructive migration — Drift migrations from v1 of the Dart schema onward
5. Decimation is render-only, never reaching storage or export
6. Theme sets a default content colour (Kotlin's `Surface` lesson; Dart's is `DefaultTextStyle`)
7. The map zoom clamp — a short ride must not zoom past the tile server's max
8. Tests use in-memory databases
---
## Phases and tasks
Each task gets `docs/port/NN-slug.md` in the new repo, following the v2 template that
worked well: Goal · Context · Design · Implementation · Acceptance criteria · Tests ·
Risks · Out of scope. Plus a running `docs/port/PROGRESS.md` recording what actually went
wrong — the v2 feedback loop caught real bugs and is worth repeating.
### Phase 0 — Ground clearing *(blocking; nothing else can start)*
| # | Task | Depends |
|---|---|---|
| T00 | Disk space + toolchain | — |
| T01 | Repo scaffold | T00 |
**T00 — This is a genuine blocker, not a formality.** The machine has **16 GiB free at 92%
capacity** and needs, roughly: Flutter SDK + artifacts ~5 GB, an iOS simulator runtime
(**none installed**) ~9 GB, CocoaPods (**not installed**; system Ruby is 2.6.10, so install
via Homebrew, not `gem`), plus the Android emulator's non-negotiable **7.4 GB free-space
floor** and two build trees. That does not fit — v1 hit this same wall and lost real time
to it. Reclaim first (`brew cleanup -s`, Homebrew and Playwright caches, `pip cache purge`,
`go clean -cache`, old Gradle caches, stale AVDs), then install. Xcode 26.0.1 is present.
**Exit criteria:** `flutter doctor -v` clean for both toolchains, an iOS simulator *and*
the Android emulator each boot, and ≥15 GB still free afterwards.
**T01** — `flutter create` with both platforms, bundle/application id `com.rippr`, git
init, initial commit. Add the dependency set. Write `docs/port/` scaffolding and a
`README` pointing back at the native repo's `ARCHITECTURE.md`, `TESTING.md`, and v2
`PROGRESS.md` as the source of truth for *why* things are shaped as they are. Confirm
`flutter test` and a debug build on both platforms before a line of real code.
### Phase 1 — Pure logic *(no platform, no UI, no database)*
Highest value per unit risk, and it builds Dart fluency on code whose correct answers are
already known. Each task ports the Kotlin file **and its existing test suite**.
| # | Task | Ports | Depends |
|---|---|---|---|
| T02 | Geo utilities | `geo/Geo.kt` + `GeoTest` (165 + 210 ln) | T01 |
| T03 | Telemetry + formatting | `Telemetry.kt`, `ui/Format.kt`, `UploadStatus.kt` + `TelemetryTest` | T01 |
| T04 | Ride statistics | `stats/RideStatistics.kt` + `RideStatisticsTest` (302 + 265 ln) | T02, T03 |
| T05 | Live accumulator | `RideAccumulator.kt`, `LiveTelemetry.kt` + `AccumulatorTest` | T04 |
| T06 | Export writers | `export/RideExport.kt` + `RideExportTest` (151 + 211 ln) | T02 |
| T07 | Cross-language parity harness | — | T02–T06 |
**T04 is the hardest-won code in the project.** `ElevationAccumulator` — 15-sample moving
average, reversal hysteresis, `gainIncludingPending()`, and a `finish()` that reconciles
against `lastRaw`. A naive version once reported **1498 m of climbing over a parked bike**.
Port it structurally faithfully; do not "improve" it during translation.
**T07** — Drive identical fixtures through both implementations and assert agreement to
six decimal places: haversine over known pairs, Douglas–Peucker output, elevation gain over
the noisy-stationary fixture, batched-vs-single-batch distance, GPX/GeoJSON byte output.
**Watch for a harness that reports suspiciously identical results** — a v2 sweep returned
four identical values because a quoting bug corrupted the source while the compile error
hid behind `/dev/null`. Never redirect a build to `/dev/null` inside a measurement loop.
### Phase 2 — Data layer
| # | Task | Depends |
|---|---|---|
| T08 | Drift schema | T01 |
| T09 | Trip repository | T08, T05 |
**T08** — Mirror `app/schemas/com.rippr.data.AppDatabase/2.json`: `Trip` (with
`TripState` RECORDING/PAUSED/COMPLETED), `Segment`, `TrackPoint`; FK `CASCADE`, indices on
`tripId`/`segmentId`, WAL. Starts at Dart schema version 1 with real migrations from day
one — the destructive fallback never comes back.
**T09** — Port `TripRepository`: every transition idempotent inside a transaction,
`startTrip` **adopts** an active trip rather than duplicating one, `mergeTrips`
re-parents segments and points without ever joining segments, then recomputes aggregates.
**A quiet win here:** `TripRepositoryTest`, `SchemaTest`, and `MergeTest` (666 lines) are
*instrumented* tests today, needing a device. Against Drift on the Dart VM they become
plain unit tests — faster, and runnable without an emulator booted.
### Phase 3 — Recording engine *(the risky phase)*
| # | Task | Depends |
|---|---|---|
| T10 | Location source seam | T03 |
| T11 | Recording pipeline | T09, T10 |
| T12 | Android foreground service | T11 |
| T13 | iOS background location | T11 |
| T14 | Process-death resume | T11, T12, T13 |
**T10** — A `LocationSource` interface with a `geolocator` implementation and a fake for
tests. This seam is what makes the engine testable without a device, and it is also the
escape hatch: if `geolocator` proves unreliable on a real iOS ride, swapping in
`flutter_background_geolocation` becomes one implementation rather than a rewrite.
**T11** — The heart. Unbounded `StreamController`, single async drain loop batching 25
fixes / 2 s, accumulator folded per batch, aggregates persisted per flush. Five actions:
start / pause / resume / stop / discard. Pause closes the open segment; resume opens a new
one. **Stamp `tripId`/`segmentId` at point creation** — a fix in flight during a pause must
land in the segment it actually belongs to. Stop discards a trip with zero points.
**T12** — `flutter_foreground_task` for process liveness and the persistent notification,
`foregroundServiceType="location"`, wake lock, notification actions. Configured **without**
a separate Dart isolate — the recording loop stays on the main isolate, so there is never
cross-isolate access to one SQLite file.
**T13 — where the platforms genuinely diverge.** `Info.plist` needs
`NSLocationWhenInUseUsageDescription`, `NSLocationAlwaysAndWhenInUseUsageDescription`, and
`UIBackgroundModes: [location]`, with **context-rich** strings — generic ones are the
leading cause of Guideline 5.1.1 rejection. Set `allowsBackgroundLocationUpdates = true`
and `pauseLocationUpdatesAutomatically = false`. Then **document and measure** what
actually happens when the bike stops at a light versus parks for ten minutes; the app must
resume cleanly rather than silently ending a ride.
**T14** — On launch, adopt any trip with `endedAt IS NULL` and restore the accumulator from
the persisted row, matching the Kotlin restart path. Verify by force-killing mid-ride on
both platforms.
### Phase 4 — UI
| # | Task | Ports | Depends |
|---|---|---|---|
| T15 | Shell: router, Riverpod, theme | `RipprNavHost`, `ui/theme/` | T01 |
| T16 | Record screen | `ui/record/` | T11, T15 |
| T17 | Trips list | `ui/trips/` | T09, T15 |
| T18 | Trip detail: stats + charts | `ui/detail/`, `ui/components/Stats.kt` | T04, T15 |
| T19 | Map + path rendering | `ui/components/RideMap.kt` | T02, T18 |
| T20 | Rename / delete / merge | | T17, T18 |
| T21 | Export UI | T06 | T18 |
**T15** — Functional dark + safety orange, matching the icon. The theme must set a default
content colour: in Compose, removing `Surface` once made a 64 sp speed figure render
black-on-black and **no test caught it — only a screenshot did**. Flutter's equivalent
exposure is `DefaultTextStyle`.
**T16** — Headline is **live speed plus a wall-clock elapsed ticker**, not max speed. v2.0
shipped max-speed-as-headline and it read as a frozen, broken screen on a real ride,
because on an emulator every value is zero and a number that never moves looks fine.
Keep the 72 dp glove-sized controls; confirm on Discard only.
**T19** — Per-segment polylines so pauses leave visible gaps, speed-bucketed colouring,
Douglas–Peucker decimation **render-only**, fit-to-bounds followed by a **zoom clamp**: a
50 m ride once zoomed past OSM's max tile zoom of 19 and rendered an empty grid. Set a real
user agent before any tile fetch or OSM returns 403, and cache tiles in app-private
storage. Guard the map's own bounds so it cannot overdraw adjacent controls.
### Phase 5 — Verification
| # | Task | Depends |
|---|---|---|
| T22 | Uploader + config | T09 |
| T23 | Widget tests | Phase 4 |
| T24 | Integration tests, both platforms | Phase 4 |
| T25 | Real-ride validation | T24 |
| T26 | iOS release readiness | T25 |
**T22** — `package:http` uploader with batching, retry, offline-safe backlog via the
`synced` column, carrying `trip_id`/`segment_id` in the payload. `shared_preferences` for
endpoint, `deviceId`, `mapEnabled`. Parity note: this still has **no UI**, exactly as today.
**T23 — closes v2's largest known gap.** Zero UI tests exist across six screens today;
Flutter makes widget tests cheap enough that there is no excuse to carry that debt into the
port. Cover navigation record→trips→detail→back, empty states, chart degradation below two
points, and merge enabled only at exactly two selections.
**T25** — Run the checklist in `docs/TESTING.md` on **both** platforms. Non-negotiable,
because **neither simulator can produce velocity** — `adb emu geo fix` teleports and the
iOS simulator's synthetic locations are no better, so max speed, average moving speed,
moving time, and **speed colouring on the map** are all unverifiable in CI. Also ride
**short and long**: a ~900 m fixture hid the short-ride zoom bug completely.
**T26** — Privacy nutrition labels matching actual runtime behaviour, usage strings
audited, background-location justification ready for review.
### Phase 6 — Cutover
| # | Task | Depends |
|---|---|---|
| T27 | Parity audit and handover | all |
Walk the v2.0.1 feature table row by row and demonstrate each on both platforms. **Do not
tick boxes in bulk** — a v2 task once had every criterion checked by a blanket regex
including items never actually verified. Then: port `ARCHITECTURE.md` with the decisions
that changed, carry `docs/v3/BACKLOG.md` forward, and mark the native repo archived with a
pointer to its replacement.
---
## Dependency graph
```
T00 ─► T01 ─┬─► T02 ─┬─► T04 ─► T05 ─┐
│ └─► T06 ─┐ │
│ T03 ──► T04 │ │
│ └─► T10 │ │
│ │ │
├─► T08 ─► T09 ───┼──────┴─► T11 ─┬─► T12 ─┐
│ │ │ ├─► T13 ─┼─► T14
│ │ │ │ │
└─► T15 ─┬───┼────┼───────────────┘ │
│ │ │ │
T02─┬─► T07 │ │ │ │
│ │ │ │ │
└────────┴───┴────┴─► T16/T17/T18 ─► T19 ─► T20/T21
│
T22 ──────────────────────► T23 ─► T24 ─► T25 ─► T26 ─► T27
```
Critical path: **T00 → T01 → T08 → T09 → T11 → T12/T13 → T14 → T16 → T24 → T25 → T27**.
Phase 1 is almost entirely parallelisable and carries near-zero risk — but per your v2
preference, everything runs **sequentially** so dependent architecture surfaces before it
becomes expensive to change.
---
## Key risks
| Risk | Mitigation |
|---|---|
| **Disk space blocks the toolchain** | T00 gates everything; hard exit criteria before any code |
| **iOS suspends a stationary app mid-ride** | T13 measures it explicitly; T10's seam makes swapping to `flutter_background_geolocation` cheap if free tooling loses rides |
| `geolocator` proves less reliable than FusedLocation | T25 rides both a real Android and a real iOS device before the native app is retired |
| Elevation hysteresis subtly mistranslated | T07 asserts six-decimal agreement against the Kotlin implementation |
| Two isolates racing one SQLite file | Avoided by design — foreground service provides liveness only, recording stays on the main isolate |
| Decimation leaking into storage or export | Render-only, and T06's ported tests assert exact point counts |
| Simulators hide velocity-dependent bugs | Stated up front in T25; **a green suite proves nothing about speed** |
| Silent parity loss | T27 audits the feature table row by row, demonstrated not asserted |
---
## Verification
```bash
flutter analyze && flutter test # pure logic, data layer, widgets
flutter test integration_test -d <android-emulator>
flutter test integration_test -d <ios-simulator>
flutter build apk --debug && flutter build ios --debug --no-codesign
```
Plus the **cross-language parity harness** (T07): identical fixtures through Kotlin and
Dart, agreement asserted to six decimals — the port's strongest single guarantee, and the
reason Phase 1 comes first.
**Definition of done:** every row of the v2.0.1 feature table demonstrated on a real
Android phone *and* a real iPhone, the `docs/TESTING.md` checklist passed on both, and no
capability lost.
---
## Open question, deferred deliberately
The native app's known **elevation drift** (~30 m per ten stationary minutes against
synthetic noise) ports along with the algorithm — faithfully, bug included. That is correct
for a parity port: fixing it during translation would make any differential test failure
ambiguous. It stays in the v3 backlog, where it already says *do not tune this blind*.

View File

@@ -0,0 +1,799 @@
# Port progress log
Running record of what actually happened, task by task — including what went wrong.
The v2 equivalent of this file caught real bugs by making risks explicit before they
were walked into, so the practice carries over.
Plan: [PLAN.md](PLAN.md) · Source of truth for *why*: the native repo's
`docs/ARCHITECTURE.md`, `docs/TESTING.md`, `docs/v2/PROGRESS.md`.
---
## T00 — Disk space + toolchain · **complete**
**Outcome:** `flutter doctor` reports no issues in any category. Flutter 3.47.0 (Dart
3.13.0), Xcode 26.0.1, CocoaPods 1.17.0, Android SDK 36.0.0, iOS 26.0.1 simulator runtime.
### The disk panic was largely a false alarm — but measure twice
Planning measured **16 GiB free at 92%**, which drove a whole cleanup strategy. A second
measurement minutes later, before deleting anything, showed **36 GiB free at 81%**. Most
likely APFS local snapshots aging out.
**Nothing was deleted.** Gradle caches, AVDs, and `~/.cargo` were all left intact.
Post-install the machine sits at ~22 GiB free.
**Lesson:** re-measure immediately before acting on a disk-space number. Had the plan been
followed literally, ~9 GB of still-useful caches would have been destroyed for no reason.
### Things that actually needed fixing
| Problem | Fix |
|---|---|
| `cmdline-tools component is missing` | `sdkmanager --sdk_root=$ANDROID_HOME "cmdline-tools;latest"` — the exact trap already documented in the native repo's `DEVELOPMENT.md`: brew's `sdkmanager` resolves its own SDK root and does not see `~/Library/Android/sdk` unless `--sdk_root` is passed explicitly |
| Android licenses unaccepted | `yes \| sdkmanager --sdk_root=$ANDROID_HOME --licenses` |
| Default JDK is 25, which AGP rejects | `flutter config --jdk-dir=<temurin-21>` — same constraint as the native build, now pinned in Flutter's config rather than relying on an exported `JAVA_HOME` |
| No iOS simulator runtime installed | `xcodebuild -downloadPlatform iOS` (8.05 GB). Note simulator *devices* already existed for a runtime that did not — `simctl list devices` looked populated while `list runtimes` was empty |
| CocoaPods absent, system Ruby 2.6.10 | `brew install cocoapods`, deliberately not `gem install` |
---
## T01 — Repo scaffold · **complete**
`~/dojo/rippr-flutter`, `flutter create --org com.rippr --project-name rippr`.
### Two deliberate deviations from the generated defaults
**Bundle id is `com.rippr.port`, not `com.rippr`.** `--org com.rippr` + name `rippr`
produces `com.rippr.rippr`, which is wrong either way. The choice of `com.rippr.port` is
deliberate and temporary: the plan requires the native app to stay installable as a
reference and fallback, and **two apps cannot share an applicationId**. Keeping them
distinct means both can sit on the same phone — which also enables the strongest possible
validation in T25: record the same ride on both simultaneously and compare the numbers.
> **T27 must switch this to `com.rippr`** at cutover. Recorded here because it is exactly
> the kind of temporary decision that silently becomes permanent.
The Android `namespace` stays `com.rippr` and the Kotlin source was moved from
`kotlin/com/rippr/rippr/` to `kotlin/com/rippr/` to match.
### Builds verified on both platforms
`✓ build/ios/iphonesimulator/Runner.app` and `✓ build/app/outputs/flutter-apk/app-debug.apk`.
Android needed three changes to the generated `build.gradle.kts`:
```kotlin
compileSdk = 37 // a dependency demands it; the build fails outright on 36
minSdk = 26 // parity with the native app (Android 8.0)
targetSdk = 36
```
`sdkmanager` cannot fetch `platforms;android-37` from the stable channel — it reports
"Failed to find package". **AGP installed it automatically** during the build (as
`android-37.0`), along with CMake 3.22.1 and a 2.8 GB NDK, because the licences had
already been accepted. Convenient, but see the disk note below.
### ⚠ `flutter_foreground_task` is on two deprecation paths
Both warnings name the same package — the one chosen for Android background liveness in
T12:
- **iOS:** does not support Swift Package Manager. *"This will become an error in a future
version of Flutter."*
- **Android:** applies the Kotlin Gradle Plugin. *"Future versions of Flutter will fail to
build if your app uses plugins that apply KGP."*
Neither breaks today's build. Both should be re-checked at T12, and they strengthen the
case for the `LocationSource` seam in T10 — the background layer needs to stay swappable.
### The disk problem was real, just not where the plan predicted
The plan braced for SDK *installs* filling the disk. The actual consumption was **builds**:
free space fell from 22 GiB to **3.9 GiB** during the first Android build — Gradle caches
grew 3.7 → 8.0 GB, the NDK added 2.8 GB, and `build/` alone reached 2.7 GB.
Recovery, in order of how safe each step was:
| Action | Reclaimed |
|---|---|
| Delete `build/` + `flutter clean` + the native app's `app/build` | ~3.8 GiB |
| Prune Gradle caches for versions **no project uses** (9.5.0, 9.7.0), `build-cache-1`, and the native project's 8.11.1 distribution | ~3 GiB |
Ended at **11 GiB free (94% used)**. `modules-2` (1.9 GB) was deliberately **kept** —
deleting it forces a re-download of every dependency, which is a real time cost for space
we do not currently need. Only two Gradle versions are actually in use: 9.3.1 (this
project) and 8.11.1 (the native app, whose distribution cache re-downloads on demand).
**Standing risk for T24:** the Android emulator needs a fixed 7.4 GiB free to boot. At 11
GiB that works, but one more full build cycle could eat the margin. Run `flutter clean`
before booting the emulator.
### Dependencies
`flutter_riverpod` · `go_router` · `drift` + `drift_flutter` · `path_provider` ·
`geolocator` · `flutter_foreground_task` · `permission_handler` · `flutter_map` +
`latlong2` · `share_plus` · `shared_preferences` · `http` · `synchronized`.
Dev: `drift_dev`, `build_runner`, `mocktail`, `integration_test`.
**`sqlite3_flutter_libs` resolves to an `+eol`-tagged release (0.6.0+eol).** It was added
explicitly at first, then removed — `drift_flutter` depends on it transitively regardless,
so the pin belongs to drift, not to us. Worth watching when drift next majors, but not
actionable now.
---
## T02 — Geo utilities · **complete**
`lib/src/geo/geo.dart` + `test/geo_test.dart`. **23/23 passing.**
Ported structurally faithfully from `com.rippr.geo.Geo`: haversine (with the
`asin(sqrt(a))` conditioning note), iterative Douglas–Peucker with an explicit stack,
equirectangular perpendicular distance with clamped projection, null-on-empty bounds,
path length. Every test case and tolerance carried over unchanged, including the
Calgary–Edmonton 280.9 km figure that was corrected during v2 after the *test* proved
wrong rather than the code.
**Deliberate API divergence:** Kotlin's `object Geo` namespace became top-level functions,
which is idiomatic Dart. `LatLon` is our own type rather than `latlong2`'s `LatLng` — the
pure layer must not depend on the map package; conversion happens at the render boundary.
### Two things went wrong
**`library;` after the import.** Dart requires the library directive before all other
directives. Caught immediately by the compiler — noted only because a file-level doc
comment is otherwise easy to attach wrongly.
**A hang that was not a hang.** `flutter test` and `flutter build ios` were run
concurrently and both sat at 0% CPU for minutes. The suspicion was Rosetta, because
`flutter_tester` lives under `artifacts/engine/darwin-x64/` — **that was wrong**: the
binary there is arm64 and the directory name is legacy. The real cause was
`ibtool`/`actool` spawning `IBAgent-iOS` and `AssetCatalogSimulatorAgent`, which deadlock
against a booted simulator. Run alone with simulators shut down, the same suite finishes
in under a second.
Two lessons, both echoing v2's harness troubles:
- **Do not run an iOS build against a booted simulator** if anything else needs it.
- **A killed background job still reports exit code 0.** Both jobs "completed
successfully" *because they were killed*. Never read a success code from a process you
terminated — re-run it cleanly.
---
## T03 — Telemetry, formatting, ephemeral state · **complete**
`telemetry.dart` (msToKmh, sanitizeSpeedKmh, isUsableFix, formatDuration, encodeBatch),
`ui/format.dart`, `telemetry/live_telemetry.dart`. **8 tests.**
Also added `domain/models.dart` — `Trip`, `Segment`, `TrackPoint`, `TripState`,
`RideStats` as plain Dart with **no persistence dependency**. Drift will map *to* these
in T08 rather than the domain depending on the database. This is the Dart equivalent of
the discipline that made the Kotlin logic testable on the JVM.
**Kotlin `Float` becomes Dart `double`.** Dart has no float32. Widening is the right
call — a shim would be friction for sub-millimetre precision on GPS-derived values — but
it means speed-derived values cannot be compared bit-for-bit. See T07 for the measured
consequence.
**`Format` builds its `DateFormat` per call**, unlike the Kotlin original which captured
`Locale.getDefault()` once at class-init. Doing the same in Dart would freeze the format
for the process lifetime and ignore a locale change.
---
## T04 — Ride statistics · **complete**
`stats/ride_statistics.dart` including `ElevationAccumulator`. **21 tests.**
Ported structurally faithfully — moving average, reversal hysteresis,
`gainIncludingPending()`, and the `finish()` reconciliation against `lastRaw`. Nothing
was "improved" during translation, per the plan.
### The failure that proved the port correct
The ported elevation test failed: **50.86 m against Kotlin's 35 m bound.** That looks
exactly like a porting bug in the hardest code in the project.
It was not. The two languages' `Random(42)` are different streams. Rather than tune the
bound blind — which `docs/v3/BACKLOG.md` explicitly warns against — the question was
settled by building the T07 harness early and driving **both implementations from one
shared LCG**:
```
noisy_gain = 38.959594555022136 ← Kotlin
noisy_gain = 38.959594555022136 ← Dart
```
Bit-identical. The port is exact.
**This also found something about the native app.** On the shared fixture the algorithm
yields ~39 m, which would **fail Kotlin's own 35 m bound**. The native test passes on
seed luck, not on a property of the algorithm. A sweep of 25 Dart seeds spanned
24.7–46.7 m (median 36). The Dart test now uses the shared LCG, asserts bit-equality
with Kotlin, and sets its bound from measured behaviour with headroom.
> Worth carrying back to the native repo if it is ever revived: that guard is weaker
> than it looks.
---
## T05 — Live accumulator · **complete**
`recording/ride_accumulator.dart`. **12 tests, green on the first run.**
The cross-batch anchor is intact — `distance across many small batches matches one big
batch` is the guard, and its absence under-reports distance by a few percent invisibly.
---
## T06 — Export writers · **complete**
`export/ride_export.dart`. **15 tests, green on the first run.** Parsed with a real XML
parser and `jsonDecode`, not substring matching, exactly as the Kotlin suite did.
---
## T07 — Cross-language parity harness · **complete**
`tool/parity/` — `run.sh`, `main.kt` (Kotlin oracle), `probe.dart`. Run it with
`JAVA_HOME` set to a 17–21 JDK; needs `kotlinc` (`brew install kotlin`, ~95 MB).
It copies `Geo.kt`, `RideStatistics.kt` and `RideExport.kt` **verbatim** from the native
repo and compiles them against minimal stand-ins for the Room-annotated holders and one
`Telemetry` constant. The files under test are never reimplemented.
### Result
Every key byte-identical across 20 fixtures, including:
| | |
|---|---|
| `noisy_gain` | `38.959594555022136` — the elevation accumulator, to the last digit |
| `simplify_count` | 218 of 21,600 points, identical Douglas–Peucker decisions |
| `gpx_len` / `gpx_fnv` | GPX output byte-identical, including the escaped hostile name |
| `geojson_len` / `geojson_fnv` | GeoJSON byte-identical |
**The single accepted difference** is `run_avg_speed`: `40.030228` (Kotlin `Float`) vs
`40.03022888407912` (Dart `double`) — precisely the divergence predicted in T03. The
harness prints this explanation on failure so a future reader is not left guessing.
### Two traps hit while building it
**`String.hashCode` is not comparable across languages.** The first version compared
Java's and Dart's hashes of the GPX output — which would have "failed" forever for no
reason. Replaced with an FNV-1a implemented identically in both.
**A missing jar silently passed as success.** When `RideExport.kt` was not yet copied,
compilation failed, `java -jar` errored, and the pipeline continued. `run.sh` now checks
for the jar and exits non-zero. This is the same class of bug as the v2 sweep that
returned four identical results because a compile error hid behind `/dev/null` — the
comment in `run.sh` says so explicitly.
---
## Phase 1 complete
**79 tests passing, `flutter analyze` clean.** ~890 lines of Kotlin logic ported, with
its ~965 lines of tests, and proven equivalent rather than assumed equivalent.
Next: **T08 (Drift schema)**, the first task that touches persistence.
---
## T08 — Drift schema · **complete**
`lib/src/data/database.dart` (+ generated `database.g.dart`). **16 tests.**
Mirrors `app/schemas/com.rippr.data.AppDatabase/2.json`: three tables, CASCADE foreign
keys, indices on `tripId` / `segmentId` / `synced`, WAL with `synchronous = NORMAL`.
Starts at Dart schema version 1 with a real `MigrationStrategy` — the destructive
fallback never comes back.
### Foreign keys are OFF by default in SQLite
Room switched them on for us. **Drift does not.** Without `PRAGMA foreign_keys = ON` in
`beforeOpen`, every `CASCADE` in the schema is decorative and deleting a trip silently
orphans all of its points. There is now a test that reads the pragma back and asserts it
is `1`, because this is invisible until data is already wrong.
### Two collisions worth recording
**Drift generates row classes named after the table.** `Trips` → `Trip`, colliding with
the domain model of the same name and producing 21 confusing analyzer errors of the form
*"Trip can't be assigned to Trip"*. Fixed with `@DataClassName('TripRow')` etc. The
mapping functions `_toTrip` / `_toSegment` / `_toPoint` convert row → domain, so the
domain layer stays unaware Drift exists.
**Drift snake_cases column names.** `speedKmh` became `speed_kmh`, which broke the one
raw-SQL query (the live stats aggregate) with `no such column: speedKmh`. Rather than 30
`.named()` annotations, `build.yaml` sets `case_from_dart_to_sql: preserve`. That keeps
the schema column-for-column identical to Room's, lets the raw SQL stay byte-identical to
the Kotlin DAO query it was ported from, and leaves a Room-file importer possible later.
### The instrumented-to-unit win, realised
`SchemaTest` needed a device and an emulator. The Drift equivalent runs on the Dart VM in
well under a second with nothing booted. `TripRepositoryTest` and `MergeTest` (441 more
lines) should convert the same way in T09.
**95 tests passing, analyze clean.**
---
## T09 — Trip repository · **complete**
`lib/src/data/trip_repository.dart`. **26 tests, green on the first run.**
Every lifecycle transition ported: `startTrip` (adopting, never duplicating), `pauseTrip`,
`resumeTrip`, `completeTrip`, `discardTrip`, `renameTrip`, `deleteTrip`, `mergeTrips`,
`recomputeAggregates`. Each runs inside `_db.transaction` and each is idempotent, because
the platform can restart the recorder from any state.
`Room.withTransaction` maps onto Drift's `transaction()` almost exactly, so this was the
most mechanical port so far.
### What the tests protect
- **Adoption over rejection.** `startTrip` on an already-active trip returns the *same*
handle rather than opening a second trip — the behaviour that makes a process kill
survivable.
- **Merge never joins segments.** The boundary between two merged rides stays a segment
boundary, exactly like a pause. The regression test puts the two rides a degree of
latitude apart and asserts the ~111 km gap never reaches `distanceM`.
- **Aggregates are recomputed, not summed** — because distance is not additive across
that gap.
- **Rename collapses empty and whitespace-only input to null**, so a stored `""` can
never diverge from the UI's date-label branch.
- **Merge rejects** self-merge, an active trip, and a missing id, and leaves no orphans.
### One piece of speculative code removed
A `mergeTableUpdates` helper was written to nudge Drift's stream queries after
re-parenting, then deleted before commit: Drift's own `update()` already notifies
dependent streams, so it earned nothing and would have been misleading scaffolding.
### The instrumented-to-unit win, totalled
`TripRepositoryTest` + `MergeTest` + `SchemaTest` were **666 lines of instrumented tests
requiring a booted emulator**. All three are now plain unit tests finishing in about two
seconds with nothing running.
---
## Phase 2 complete
**121 tests passing, `flutter analyze` clean.** The data layer is done and the domain,
statistics and export layers above it are proven equivalent to the Kotlin original.
Next: **Phase 3, the recording engine** — the risky phase. T10's `LocationSource` seam
first, then the pipeline, then the two platform liveness stories. The
`flutter_foreground_task` deprecation warnings recorded under T01 become relevant at T12.
---
## T10 — Location source seam · **complete**
`lib/src/recording/location_source.dart`: `LocationFix`, `LocationSource`,
`LocationException`, and `FakeLocationSource`.
`LocationFix` is deliberately **not** `TrackPoint` — a fix has no trip or segment
identity. Those ids are stamped on by the engine at creation, which is the whole basis of
the pause guarantee.
### `geolocator` can replace `flutter_foreground_task` entirely
`geolocator_android` ships `ForegroundNotificationConfig`, which raises a foreground
service with `foregroundServiceType=location` for as long as the position stream is
active, and exposes `enableWakeLock` and `setOngoing`. That is **everything**
`TrackingService` used a foreground service and a `PARTIAL_WAKE_LOCK` for.
Dropping `flutter_foreground_task` would remove both deprecation paths recorded under
T01 (no Swift Package Manager on iOS; applies KGP on Android) at no cost to the design.
**One parity casualty:** geolocator's notification config has no support for *actions*,
so the notification would be display-only — the native app's Pause/Resume buttons in the
shade would be lost. Decision deferred to T12, where it actually bites. The seam means
neither choice touches the engine.
---
## T11 — Recording pipeline · **complete**
`lib/src/recording/recording_engine.dart`. **24 tests.**
The Kotlin `Channel(UNLIMITED)` + blocking-`receive` writer coroutine becomes a plain
`List` buffer plus a periodic `Timer`. Dart has no blocking receive and its single
threaded event loop makes one unnecessary — the guarantee is unchanged: the fix callback
only appends and returns, so disk latency can never stall GPS. `Mutex` becomes
`synchronized`'s `Lock`, serialising the periodic flush against explicit drains at pause,
stop and discard.
All five actions ported: start (adopting), pause, resume (via start), stop, discard, plus
`restoreAfterProcessDeath`.
---
## 🐞 A real bug found in the native app
**`TrackingService.restoreAfterProcessDeath` does not do what its comment says.**
```kotlin
// Resume into a *new* segment: the time the process was dead is a real
// gap in the recording and should render as one.
trips.resumeTrip(System.currentTimeMillis())?.let { handle ->
```
`resumeTrip` calls `adoptOrOpenSegment`, which returns the **existing open segment** if
there is one. After a *pause* that is correct, because pausing closes the segment first.
After a *crash* nothing closed it — so the adopt branch wins and the stated intent is
silently not met.
### The consequence is data corruption, not cosmetics
Points either side of the dead time land in one segment. `RideStatistics.compute` groups
by segment, and it is the **authoritative** pass that overwrites the live estimate when
the trip completes. So the gap gets measured as if it had been ridden.
Measured, by reverting the fix and running the guard:
```
the dead time leaked into distance: 111217.31924957958 m
```
**111 km of phantom distance** added to a ride because the process died and the rider
relaunched somewhere else. The map would also draw a straight line across roads never
ridden — exactly the artefact segments exist to prevent.
The live accumulator gets this right (it is re-seeded with a null anchor). The
authoritative recomputation then overwrites the correct figure with the wrong one.
### The fix
`TripRepository.resumeIntoNewSegment` closes the stale segment and opens a fresh one.
The stale segment is closed **at its last recorded point**, not at `now` — recording
genuinely stopped when the process died, and `computeSummary` sums closed segment spans
for elapsed time, so closing at `now` would bill the dead time as ride time.
This is a deliberate, documented **departure from parity**. The plan says port bugs
faithfully, and that holds for the elevation drift where a "fix" would make differential
testing ambiguous. It does not hold here: the code contradicts its own stated intent and
the result is silently wrong data.
> Worth carrying back to the native app if it is ever revived. Second finding of its kind
> after the seed-lucky elevation bound.
### And a near-miss worth recording
The first version of the guard **passed with the bug still present**. The fixture called
`pause()` to flush points to disk — but pausing *closes* the segment, so the crash state
was never reproduced. It was only caught by deliberately reverting the fix and checking
the test failed.
The rewritten fixture writes points through the repository directly, leaving the segment
open exactly as a crash does. It now fails at 111 km with the native behaviour and passes
with the fix.
**A regression test nobody has watched fail is not yet a regression test.**
**145 tests passing, analyze clean.**
---
## T12 / T13 — Platform liveness · **implemented, unvalidated on a real ride**
`lib/src/recording/geolocator_location_source.dart`, plus the Android manifest and iOS
`Info.plist`. This is the **only** file in the app that knows the two platforms differ.
### `flutter_foreground_task` dropped
Dylan's call, on the T10 finding. `geolocator_android`'s `ForegroundNotificationConfig`
raises a service with `foregroundServiceType="location"` and holds a wake lock for as
long as the position stream is subscribed. The plugin declares its own
`GeolocatorLocationService` in its manifest, which merges automatically — nothing to
declare ourselves. Verified in the merged manifest.
**Both deprecation warnings recorded under T01 are now gone from the build output.**
> **⚠ Parity gap for T27:** geolocator's notification cannot carry *actions*, so the
> native app's Pause/Resume buttons in the notification shade are not reproduced. Tapping
> the notification opens the app. This is the only known capability lost so far.
### Android
Permissions mirror the native manifest. **`ACCESS_BACKGROUND_LOCATION` is deliberately
not requested** — recording runs under a foreground service with a visible notification,
which is exactly what `foregroundServiceType="location"` is for. Asking for background
location would trigger the harder "Allow all the time" flow for no benefit.
### iOS — two settings that matter more than they look
```dart
pauseLocationUpdatesAutomatically: false // CoreLocation does not reliably restart
activityType: ActivityType.otherNavigation // NOT automotiveNavigation
```
`automotiveNavigation` makes CoreLocation **snap fixes to the road network**. For a
navigation app that is a feature; for a recorder whose entire purpose is the path actually
travelled, it silently falsifies the data. `otherNavigation` is the correct choice for a
motorcycle.
`UIBackgroundModes: [location]` plus `allowBackgroundLocationUpdates` keeps the process —
and therefore the Dart isolate and the writer timer — alive while updates flow. Usage
strings are written specifically rather than generically, since vague text is the leading
cause of Guideline 5.1.1 rejection.
**None of this is validated.** Whether iOS suspends a stationary app mid-ride is
answerable only by riding. It belongs to T25.
---
## T14 — Process-death resume · **complete**
Implemented in `RecordingEngine.restoreAfterProcessDeath` and called from a post-frame
callback at startup. Covered by four tests, including the 111 km guard documented under
T11. The remaining acceptance criterion — force-killing mid-ride on real hardware —
belongs with T25.
---
## The app runs
Built and launched on the iOS simulator. Drift opened against app-private storage,
Riverpod resolved the graph, `restoreAfterProcessDeath` correctly found no active trip,
and the controls were in the right state (START enabled, PAUSE/STOP disabled while idle).
`lib/main.dart` is a deliberate **harness**, not the real UI — it exists so the pipeline
can be exercised on hardware before Phase 4 builds any screens, because the pipeline is
precisely the part unit tests and emulators cannot validate. T15 replaces it.
---
## ⚠ Disk: the real environmental constraint
Today's arc: **36 GiB free → 355 MiB** at the worst point, with roughly 7 GiB reclaimed
along the way and still ending at 6.6 GiB.
A full dual-platform build cycle costs roughly **10 GiB** — Gradle caches, a 2.8 GB NDK,
CocoaPods, DerivedData, `build/`, and the installed simulator app. It is reclaimable, but
it is not optional space.
Standing footprint: `/opt/homebrew` 17 GB, `~/Library/Android` 7.5 GB, Flutter SDK 3.9 GB,
iOS runtime ~8 GB.
**Before T24 boots the Android emulator** (fixed 7.4 GiB free required):
```bash
flutter clean && rm -rf ios/Pods ~/Library/Developer/Xcode/DerivedData/*
```
Realistically this machine needs headroom freed outside the dev tooling before Phase 5.
---
## Phase 3 complete
**145 tests passing, analyze clean, both platforms building and the app running on iOS.**
Next: **Phase 4, the UI** — six screens, and the first widget tests this project has ever
had.
---
## Phase 4 — UI · **complete**
**T15** shell (theme, `go_router`, shared components) · **T16** record screen · **T17**
trips list · **T18** trip detail with stats and charts · **T19** map · **T20**
rename/delete/merge · **T21** export · **T23** widget tests, brought forward.
**164 tests passing, analyze clean.**
### The theme's load-bearing detail, made structural
Compose's `Surface` set `LocalContentColor`; removing it once made a 64 sp speed figure
render black-on-black, and **no test caught it — only a screenshot did**. The Dart theme
sets `bodyColor`/`displayColor` on `TextTheme` explicitly rather than relying on a
wrapping widget, so the failure cannot recur by someone deleting a container. `BigStat`
also names its colour directly, and a widget test asserts that colour differs from the
ground.
### The widget tests immediately earned their keep
**A real layout bug, first run:** with a ride active the record screen grows to six stat
rows and `RenderFlex overflowed by 20 pixels`. Compose *clips this silently*, so the same
bug may well be latent in the native app and simply invisible. Fixed by making the screen
scrollable while still centring when there is room — which matters more here than usual,
because 72 dp glove-sized controls make the content genuinely tall.
### Two Flutter-testing traps, both costly
**`pumpAndSettle` never settles against a repeating timer.** The elapsed clock ticks every
second, so the first widget-test run sat at the framework's 10-minute timeout — for
*every* test. Two fixes: the ticker now runs **only while a ride is active** (better
behaviour regardless — an idle screen has no clock to advance), and tests that do have a
live ride use explicit `pump()` calls.
**`flutter_test` asserts no `Timer` is pending after disposal**, which Drift trips: it
keeps a stream query alive briefly after its last listener leaves so re-subscribing is
cheap. Tests now run through a `screenTest` wrapper that removes the tree and pumps past
that window. Run time went from *timeout* to **two seconds**.
> And again: a killed background job reports exit code 0. Twice during this phase a
> "completed" run had actually been terminated. Always re-read the log.
### Map
`flutter_map`, same OSM raster tiles and same no-API-key reasoning that chose osmdroid.
All four load-bearing behaviours carried over and now **tested**, which the native app's
map never was:
- one polyline per segment, so a pause is a visible gap — the test puts two segments a
degree apart and asserts no polyline straddles it
- decimation **render-only**; a test asserts vertices drop while endpoints survive
- the **zoom clamp** at OSM's max tile zoom of 19, plus a `shortRideZoom` fallback for
degenerate bounds — this is the v2.0 empty-grid bug, now pinned by a test
- a real user agent, or the tile servers return 403
Speed colouring is bucketed into one polyline per run rather than per-vertex paint —
`PolyChromaticPaintList` was fiddly in osmdroid and flutter_map has no equivalent either.
**Still unvalidatable:** no simulator produces velocity, so every path renders in one
colour until a real ride.
### Export
`share_plus` replaces `FileProvider` + `ACTION_SEND`, and handles the iOS popover anchor
an iPad needs. Files are written to the temporary directory — they are a transfer
artefact, not storage. The export deliberately passes the **raw stored points**, never
the map's decimated path.
---
## Remaining
Phases 0–4 are complete. What is left is verification and cutover:
- **T22** uploader + config (the last piece of parity; still no UI, exactly as today)
- **T24** integration tests on both platforms — needs the emulator, so check disk first
- **T25** the real-ride checklist on **both** platforms. Nothing above substitutes for it:
neither simulator produces velocity, so max speed, moving time and speed colouring are
all still unverified.
- **T26** iOS release readiness · **T27** parity audit, including switching the
applicationId from `com.rippr.port` back to `com.rippr`
**Known parity gap so far:** notification actions (Pause/Resume in the shade), lost with
`flutter_foreground_task`.
---
## Phase 5 — Verification
### T22 — Uploader and config · **complete**
`lib/src/telemetry/telemetry_uploader.dart` and `lib/src/config/config.dart`. **7 tests.**
`package:http` with a `MockClient` replaces OkHttp with MockWebServer, and the tests run
against a real in-memory Drift database rather than a fake DAO — closer to production and
still no device. Batching, retry, the offline-safe backlog via `synced`, and per-point
trip/segment identity all carried over.
Upload runs on **its own timer** in the engine, injected as a callback so the engine has
no opinion about HTTP and tests need no network. Every failure path is swallowed: nothing
about uploading may disturb recording.
`Config` uses `shared_preferences`, with a hand-rolled UUID v4 rather than a package for
sixteen bytes. `mapEnabled` now comes from preferences instead of being hardcoded.
**Parity note:** there is still **no UI** for the endpoint, exactly as in the native app.
> Two `prefer_initializing_formals` lints are suppressed with a reason: Dart does not
> permit a named parameter whose name begins with an underscore, so the lint's suggested
> fix does not compile.
### T23 — Widget tests · **complete** (delivered in Phase 4)
### T24 — Integration tests · **complete**
`integration_test/app_test.dart`. **4 tests, passing on the iOS simulator.**
These cover what widget tests structurally cannot: Drift opening against real platform
storage, plugin registration resolving, `go_router` driving a real Navigator, and a cold
start surviving. The database test in particular is meaningful — an in-memory Drift
instance cannot prove the sqlite3 native library loaded on the device.
```
flutter test integration_test -d <device-id> → 00:50 +4: All tests passed!
```
**Not yet run on the Android emulator**, which needs 7.4 GiB free to boot; run
`flutter clean` first.
### T25 — Real ride · **outstanding, and it is the important one**
Written up as [REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md): eleven shared checks, three
Android-only, five iOS-only.
Three things in it are worth calling out:
- **A2 (Android):** force-stop mid-ride and confirm the dead time is *not* added to
distance — the native bug found in T11, measured at 111 km.
- **I3 (iOS):** park for fifteen minutes mid-recording and see whether recording resumes.
**This decides the platform strategy.** If `geolocator` loses rides to suspension, the
`LocationSource` seam exists so `flutter_background_geolocation` (~$500/yr) can replace
it as one new implementation.
- **Record the Android ride on both apps at once.** They have different application ids
precisely so they can coexist, and a direct numeric comparison is far stronger evidence
than either app alone.
### T26 — iOS release readiness · **configured, not submitted**
[RELEASE-IOS.md](RELEASE-IOS.md). Usage strings are written specifically rather than
generically (the leading 5.1.1 rejection cause), privacy-label answers are decided, and
the pre-submission list covers the bundle-id switch, the still-default app icon, a release
build, and a demo video for the review notes.
---
## Phase 5 status
**T22, T23, T24 complete. T25 and T26 need a real device and a real rider.**
`175 tests passing` — 171 unit and widget, plus 4 integration on device. Analyze clean.
> Corrected: an earlier summary in this file said 178. 171 + 4 is 175.
---
## T27 — Parity audit and handover · **complete, except the cutover step**
[PARITY-AUDIT.md](PARITY-AUDIT.md) walks every row of the v2.0.1 feature table with the
evidence behind each claim, keeping three categories strictly apart: **verified** (a test
asserts it, or it ran on a device), **implemented but unproven**, and **gap**.
Deliberately not ticked in bulk — a v2 task once had every criterion checked by a blanket
regex including unverified items, and that lesson is written into the audit's preamble.
### Documentation carried forward
- `docs/ARCHITECTURE.md` — the durable reasoning, with a table of what changed and why,
the Float→double divergence, and the two iOS settings that are easy to get wrong
- `README.md` — status, the parity harness, and the two native bugs this port found
- `docs/V3-BACKLOG.md` — carried over with a header noting the two items whose status
changed (UI tests are no longer a gap; elevation can now be checked against Kotlin)
- The native repo's `README.md` now opens with a pointer here, and records both bugs
All internal documentation links verified to resolve.
### The cutover step is deliberately NOT done
**The application id stays `com.rippr.port`.**
Switching it to `com.rippr` is the last act of the port, but doing it now would stop the
two apps coexisting — and `REAL-RIDE-CHECKLIST.md` item **A3** depends on recording the
same ride on both simultaneously. That side-by-side comparison is the strongest evidence
available that the port is faithful, and it is worth more than finishing the checklist
tidily.
The native repo is therefore marked **superseded, not archived**: it is still the only
version that has recorded a real ride.
### An arithmetic correction
An earlier entry in this file said 178 tests. The real figure is **175** — 171 unit and
widget, plus 4 integration. Corrected in place.
---
## The port is complete
| | |
|---|---|
| **175 tests** | 171 unit and widget, 4 integration on a real iOS simulator |
| **~4,600 lines** of Dart | plus 3,000 generated by Drift |
| **Both platforms build** | and the app runs on an iOS simulator |
| **Analyze clean** | throughout |
| **Parity proven, not assumed** | `tool/parity/run.sh` diffs against the real Kotlin |
Two bugs found in the native app. One capability lost (notification actions). The first UI
tests the project has ever had.
**What remains is not code.** It is a rider, two phones, and
[REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md).

View File

@@ -0,0 +1,100 @@
# T25 — the real-ride checklist
**This is the only task in the plan that cannot be automated, and it is the one that
matters most.** 171 unit and widget tests plus 4 integration tests are green, and none of
them prove the app records a ride correctly.
Adapted from the native repo's `docs/TESTING.md`, extended for two platforms.
---
## Why a green suite proves nothing here
**Neither simulator produces velocity.** `adb emu geo fix` teleports the device and iOS's
simulated locations are no better, so every recorded `speedKmh` is `0.0`. That makes the
following **structurally unverifiable** without riding:
- max speed, average moving speed
- moving time (everything sits under the 1.5 km/h noise floor, so it stays 00:00:00)
- speed colouring on the map — the whole path renders in one colour
- elevation gain against real, *correlated* GPS altitude error
- battery over a multi-hour ride
- whether iOS suspends a stationary app mid-ride
And the subtler trap, which cost v2.0 a release: **when a value cannot change under test,
the UI around it cannot be judged either.** Max speed as the headline looked perfectly
fine on an emulator where every number was zero. On a real ride it read as a frozen,
broken screen.
---
## Before you ride
Install both apps. They have different application ids on purpose
(`com.rippr` and `com.rippr.port`), so they coexist:
```bash
flutter build apk --debug && flutter install # Flutter port
adb install -r ~/dojo/samplez/rippr-2.0.1-debug.apk # native reference
```
**Run both simultaneously on the Android ride.** Recording the same ride twice gives a
direct numeric comparison, which is far stronger evidence than either app alone.
---
## The ride
Do this once on **Android** and once on **iOS**. Pocket the phone — that is the founding
use case.
| # | Check | Why it is here |
|---|---|---|
| 1 | Start, pocket, ride ~20 min, stop | The actual usage pattern |
| 2 | Max speed plausible against the speedometer | Unverifiable on any simulator |
| 3 | Distance plausible against the odometer | Guards the cross-batch anchor |
| 4 | Moving time excludes stops | Noise floor behaviour on real data |
| 5 | **Elevation gain near zero on flat ground** | The most likely silent bug; synthetic noise is uniform, real error is correlated |
| 6 | Pause at a stop, resume — **no straight line across the gap** | The segment guarantee |
| 7 | Speed colouring visibly varies along the path | Cannot render in more than one colour on a simulator |
| 8 | **A short ride (under 100 m) still shows streets** | The v2.0 zoom bug |
| 9 | GPX opens correctly in Google Earth or Strava | Schema-valid is not the same as accepted |
| 10 | Battery drain over a multi-hour ride | Never measured, on either app |
| 11 | Pause/resume survives a screen-off stretch | Android wake lock, iOS background mode |
### Android only
| # | Check |
|---|---|
| A1 | The ongoing notification appears and persists with the screen off |
| A2 | Force-stop the app mid-ride, relaunch — the ride resumes into a **new segment**, and the dead time is **not** added to distance |
| A3 | Compare totals against the native app recording the same ride |
> **A2 is the fix for a real bug found in the native app.** See T11 in
> [PROGRESS.md](PROGRESS.md): the native version measures straight through the dead time,
> which a synthetic test showed adding **111 km**. Confirm the port does not.
### iOS only — the genuine unknown
| # | Check |
|---|---|
| I1 | The blue background-location indicator appears while recording |
| I2 | Lock the screen for 10 minutes of riding — fixes keep arriving |
| I3 | **Park for 15 minutes without stopping the recording, then ride again.** Does recording resume? |
| I4 | Take a phone call mid-ride; recording survives |
| I5 | Swipe the app away mid-ride — what happens? Document it, whatever it is |
**I3 is the decisive test for the whole platform strategy.** If iOS suspends the app when
stationary and does not reliably resume, `geolocator` is not sufficient and the
`LocationSource` seam exists precisely so `flutter_background_geolocation` (~$500/yr) can
be swapped in as one new implementation. Do not make that call without this data.
---
## Recording the results
Append findings to [PROGRESS.md](PROGRESS.md) under a T25 heading, including the numbers
from both apps where Android was recorded twice. If elevation gain on flat ground is
implausible, **do not tune it blind** — the native backlog says so explicitly, and the
port's parity harness (`tool/parity/run.sh`) means any change can be checked against the
Kotlin implementation first.

View File

@@ -0,0 +1,78 @@
# T26 — iOS release readiness
What App Review will look at, and what is already in place. Background-location apps get
more scrutiny than most, and the research in `docs/PORT_RESEARCH.md` names unclear
privacy disclosure as the leading rejection cause.
**Status: configured, not submitted.** Nothing here has been through review.
---
## Guideline 5.1.1 — data privacy and transparency
**Rejection cause:** generic usage strings like *"This app needs location"*.
Already in `ios/Runner/Info.plist`, written specifically:
| Key | Says |
|---|---|
| `NSLocationWhenInUseUsageDescription` | Records the GPS track of your ride so you can see route, speed and distance afterwards. **Nothing is recorded until you press Start.** |
| `NSLocationAlwaysAndWhenInUseUsageDescription` | Keeps recording while the phone is in your pocket or the screen is off, so a ride you started is captured beginning to end. **Recording stops the moment you press Stop.** |
Both name what is collected, why, and when it stops. That last clause matters — it is the
difference between "an app that wants your location" and "a recorder you control".
## App Privacy nutrition labels
Declare, and make sure it stays true:
- **Location → Precise Location**, linked to the user? **No.** Used for **App
Functionality** only.
- **No data collected for tracking**, no advertising identifiers, no analytics SDKs.
- **Data is not transmitted off-device by default.** The uploader exists but has no UI and
no endpoint configured; it is inert unless someone sets one deliberately.
> If a server ever ships (the v3 group-ride idea), these labels must change **before** it
> does. Undisclosed background transmission is a straightforward rejection.
## Guideline 2.1 — completeness and background stability
The exposure is crashing on background resume. Mitigations in place:
- Recording state lives in the database, so resuming after suspension reads real state
rather than guessing.
- `restoreAfterProcessDeath` runs at startup and is covered by four tests, including the
crash-gap guard.
- Every upload failure path is swallowed; the network cannot stop a recording.
- Database write failures are caught per batch — losing points beats losing the app.
**Still unproven:** what iOS actually does when the app is suspended while stationary.
That is item **I3** in [REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md) and it must be
answered before submission.
## Guideline 4.2 — minimum functionality
Not a realistic risk: native Flutter UI, real hardware integration, offline-first storage,
no web view anywhere.
---
## Before submitting
- [ ] **Switch the bundle id** from `com.rippr.port` to `com.rippr` (T27). The `.port`
suffix exists only so the native app can be installed alongside during the port.
- [ ] `CFBundleName` is still the generated lowercase `rippr`; `CFBundleDisplayName` is
already `Rippr`. Make them consistent.
- [ ] App icon and launch screen — still Flutter defaults. The native app's adaptive icon
artwork lives in `~/dojo/rippr/design/` and needs re-exporting at iOS sizes.
- [ ] Run [REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md) on a real iPhone, especially I3.
- [ ] Record a demo video of a real ride for the review notes. Background-location apps
are frequently asked to justify the entitlement; a video pre-empts a rejection round.
- [ ] `flutter build ipa --release` and confirm the release build works — everything so
far has been debug.
## Known parity gap to disclose internally
The notification cannot carry actions, so the native app's Pause/Resume buttons in the
shade are absent on both platforms. Not an App Review issue; it is a feature difference
the T27 audit must record.