Refresh the native Rippr snapshot to match the claimed commit
RIPPR.md said the native snapshot was at ba57a92, but rippr-src and the bundle were still at d418920 -- so the superseded notice and the two recorded bugs were missing from the copy. Re-exported both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
|
||||
349
rippr-src/docs/port/PLAN.md
Normal file
349
rippr-src/docs/port/PLAN.md
Normal 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*.
|
||||
Reference in New Issue
Block a user