T05 — ride_accumulator.dart with the cross-batch anchor intact (12 tests). T06 — ride_export.dart, GPX 1.1 and GeoJSON (15 tests), parsed with real parsers rather than substring matching. T07 — parity harness extended to cover export byte output. GPX and GeoJSON are byte-identical across Kotlin and Dart: same length, same FNV hash, including the escaped hostile name and all %.7f/%.1f formatting. Two harness bugs found and fixed while building it. String.hashCode is not comparable across Java and Dart, so text comparison used FNV-1a instead. And a missing jar let a failed Kotlin compile pass as a green run -- run.sh now checks for the artifact and exits non-zero, the same failure mode as the v2 sweep that hid a compile error behind /dev/null. Phase 1 done: 79 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
13 KiB
Markdown
280 lines
13 KiB
Markdown
# 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.
|