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:
799
rippr-flutter-src/docs/port/PROGRESS.md
Normal file
799
rippr-flutter-src/docs/port/PROGRESS.md
Normal 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).
|
||||
Reference in New Issue
Block a user