Files
rippr/docs/port/PROGRESS.md
Dylan ee3ef74331 T12-T14: platform liveness via geolocator; drop flutter_foreground_task
geolocator_android's ForegroundNotificationConfig raises a service with
foregroundServiceType=location and holds a wake lock for as long as the position
stream is subscribed, and the plugin declares that service in its own manifest.
That covers everything the native TrackingService used a foreground service and
a PARTIAL_WAKE_LOCK for, so flutter_foreground_task is gone -- and with it both
deprecation warnings that were recorded under T01.

Known parity gap for T27: geolocator's notification cannot carry actions, so the
native Pause/Resume buttons in the shade are not reproduced.

iOS: UIBackgroundModes plus allowBackgroundLocationUpdates keep the isolate and
the writer timer alive while updates flow. Two settings matter more than they
look -- pauseLocationUpdatesAutomatically is false because CoreLocation does not
reliably restart, and activityType is otherNavigation rather than
automotiveNavigation, which would snap fixes to the road network and silently
falsify the recorded path. Usage strings are specific, not generic.

Android deliberately does not request ACCESS_BACKGROUND_LOCATION: a foreground
service with a visible notification is exactly the supported case, and asking
would trigger the harder "Allow all the time" flow for no benefit.

T14 process-death resume is implemented and called at startup, covered by four
tests including the 111 km crash-gap guard.

Verified by building both platforms and launching on the iOS simulator: Drift
opened, Riverpod resolved, restore found no active trip, controls correct.
lib/main.dart is a harness so the pipeline can be exercised on hardware before
Phase 4 builds screens; T15 replaces it.

Phase 3 done: 145 tests passing, analyze clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:14:37 -05:00

26 KiB
Raw Blame History

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 · 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:

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.

// 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

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):

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.