diff --git a/rippr-full-history.bundle b/rippr-full-history.bundle index 2112fd0..39ed938 100644 Binary files a/rippr-full-history.bundle and b/rippr-full-history.bundle differ diff --git a/rippr-src/README.md b/rippr-src/README.md index bd49dfd..ed656b7 100644 --- a/rippr-src/README.md +++ b/rippr-src/README.md @@ -1,4 +1,32 @@ -# Rippr +# Rippr — native Android (superseded) + +> ## ⚠ This app has been ported to Flutter +> +> Active development moved to **`~/dojo/rippr-flutter`**, which runs on Android *and* +> iOS. That port is feature-complete and better tested than this app; see its +> `docs/port/PARITY-AUDIT.md` for a row-by-row comparison. +> +> **This repository is kept as a reference and a fallback**, not archived. It is still the +> only version that has recorded real rides, and the port's validation depends on running +> both apps side by side — they use different application ids (`com.rippr` here, +> `com.rippr.port` there) specifically so they can be installed together. +> +> **Two bugs in this code were found by porting it.** Both are fixed in the port and +> still present here: +> +> 1. **`TrackingService.restoreAfterProcessDeath` measures the dead time as distance.** +> Its comment says it resumes into a new segment; it calls `resumeTrip`, which adopts +> the segment a crash left open. `RideStatistics.compute` then measures straight +> through the gap — reproduced at **111 km** of phantom distance. See +> `TripRepository.resumeIntoNewSegment` in the port. +> 2. **The elevation regression guard passes on seed luck.** On a fixture shared between +> both languages the algorithm yields ~39 m, which would fail this repo's own 35 m +> bound in `RideStatisticsTest`. It passes only because `kotlin.random.Random(42)` +> draws a benign sequence. +> +> Everything below describes this app as it stands, and remains accurate. + +--- A native Android GPS ride recorder for motorcycles. Records telemetry from an un-killable foreground service into a local SQLite database, renders the traversed path diff --git a/rippr-src/docs/port/PLAN.md b/rippr-src/docs/port/PLAN.md new file mode 100644 index 0000000..c76c7bc --- /dev/null +++ b/rippr-src/docs/port/PLAN.md @@ -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(UNLIMITED) ──► StreamController (unbounded) + single writer coroutine ──► single async drain loop (batch 25 / 2s) + Mutex ──► package:synchronized Lock + Room + @Transaction ──► Drift + transaction() + Flow ──► Stream (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 +flutter test integration_test -d +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*.