Record rides to GPX, including HR embedding capability (issue #20) #111

Merged
robert merged 1 commit from area/gpx-recording into main 2026-09-05 12:36:27 +02:00
Owner

Closes #20.

What this builds

  • GpxRecorder/GpxWriter (:companion:core, pure JVM, no android.*) — an incremental GPX 1.1
    writer that flushes after every <trkpt>, plus the pause/resume segment-splitting policy driven by
    MovementState (D35), the same fact SpeedPipeline/StopDetector (#17/#69) already produce.
  • RideGpxFiles (:companion:ride) — the thin Android adapter that opens a real file under
    getExternalFilesDir("gpx") and gives GpxRecorder a real java.io.Writer.
  • RideService now feeds every SpeedPipeline-accepted fix into the recorder and finishes the file
    on stop/destroy. Recording is best-effort: an open/write failure logs and gives up on recording for
    the rest of the ride rather than affecting the ride itself.

Re-assessment context

This issue was judged mostly blocked earlier tonight (HR needs #66→#16→#4's hardware chain; live
speed needed #17/#69, which didn't exist yet). #17/#69 have since merged (PRs #101/#100) — a real
SpeedPipeline/StopDetector pipeline now exists and RideService already runs real fixes through
it — so the core GPX-writing scope is real and buildable. HR embedding is still genuinely blocked on
data (see below) and is scoped out explicitly, same honest pattern as #65's un-fed accumulators.

Acceptance criteria, checked off honestly

  • Track points written incrementally — yes. Every addTrackPoint() flushes immediately; a crash
    loses at most the points since the last flush. This is a stronger bound than #12's watch-side
    precedent (a 30 s periodic full-snapshot rewrite, throttled for flash-write endurance): GPX is an
    append-only text format, so appending each point already is the incremental write, with no
    snapshot-rewrite cost to throttle against.
  • Heart rate embedded using the standard Garmin TrackPointExtension namespace — the capability
    is built and tested: GpxWriter embeds <gpxtpx:hr> under
    http://www.garmin.com/xmlschemas/TrackPointExtension/v1, verified against Garmin's own published
    XSD (https://www8.garmin.com/xmlschemas/TrackPointExtensionv1.xsd) rather than recalled from
    memory — hr is BeatsPerMinute_t, an xsd:unsignedByte with minInclusive 1, which
    GpxTrackPoint's own init block enforces. No real HR value exists anywhere in companion/
    today
    (checked by grep for HR_SAMPLES/HR_BPM/heart.?rate across the module, same check
    DerivedMetrics.kt's AvgHrAccumulator KDoc already documents) — HR_SAMPLES decoding is #66,
    itself blocked on #16/#4's hardware chain. heartRateBpm is nullable end-to-end and RideService
    always passes null today; no fake HR source was fabricated to exercise the embedding path. The
    2026-09-01 update's batched-decode/gap/backlog requirements are #66's ordering problem to solve, not
    this writer's — GpxRecorder.onFix() makes no assumption that calls arrive in wall-clock order, so
    once #66 exists, feeding it fix-by-fix in true timestamp order is enough.
  • Elevation included where available — the writer supports it fully (nullable, embedded as
    <ele> when present, omitted when not, tested both ways), but it is never available today:
    GpsFix/LocationFix (:companion:core/:companion:location, #17/#19's own types) carry no
    altitude field at all, even though android.location.Location.getAltitude() exists on the platform.
    Plumbing that through is real, separate scope outside this issue's Files: list — RideService
    passes null until it happens. Documented in GpxRecorder's KDoc, not fabricated.
  • Paused segments handled sensibly — real logic: GpxRecorder drops every fix StopDetector
    reports STOPPED (no jitter points from a parked phone), closes the current <trkseg> the instant
    a stop is confirmed, and opens a fresh one on the first fix reported MOVING again — a stop becomes
    a segment seam, mirroring a real GPS head unit's own auto-pause export. Proven by
    GpxRecorderTest's stop/resume/multi-cycle/starts-already-stopped cases, asserted against the raw
    <trkseg> count (import-side flattening erases this, see below).
  • Resulting file validates and re-imports cleanly — proven the way this sandbox actually can:
    GpxRecorderTest's round-trip test writes a full recording (two segments, elevation, HR) and
    re-imports it through this project's own GpxImporter (#24), asserting geometry/elevation/
    timestamps come back exactly as written. The fixture geometry deliberately mirrors
    GpxImporterTest's own zigzag pattern, since GpxImporter runs everything through
    PolylineSimplifier (~5 m RDP tolerance) before returning it — a straight-line fixture would have
    interior points legitimately simplified away, proving nothing. Segment-seam and HR-embedding
    structure are checked against the raw XML separately, since GpxImporter deliberately flattens
    <trkseg> boundaries on read and its own GpxPoint has no HR field.

Tests, run for real

  • ./gradlew :companion:core:test — passes, including 13 new GpxWriterTest cases and 7 new
    GpxRecorderTest cases (the round trip among them).
  • ./gradlew :companion:assembleDebug — passes (confirms the RideService/RideGpxFiles Android
    wiring compiles for real).

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

Closes #20. ## What this builds - `GpxRecorder`/`GpxWriter` (`:companion:core`, pure JVM, no `android.*`) — an incremental GPX 1.1 writer that flushes after every `<trkpt>`, plus the pause/resume segment-splitting policy driven by `MovementState` (D35), the same fact `SpeedPipeline`/`StopDetector` (#17/#69) already produce. - `RideGpxFiles` (`:companion:ride`) — the thin Android adapter that opens a real file under `getExternalFilesDir("gpx")` and gives `GpxRecorder` a real `java.io.Writer`. - `RideService` now feeds every `SpeedPipeline`-accepted fix into the recorder and finishes the file on stop/destroy. Recording is best-effort: an open/write failure logs and gives up on recording for the rest of the ride rather than affecting the ride itself. ## Re-assessment context This issue was judged mostly blocked earlier tonight (HR needs #66→#16→#4's hardware chain; live speed needed #17/#69, which didn't exist yet). #17/#69 have since merged (PRs #101/#100) — a real `SpeedPipeline`/`StopDetector` pipeline now exists and `RideService` already runs real fixes through it — so the core GPX-writing scope is real and buildable. HR embedding is still genuinely blocked on data (see below) and is scoped out explicitly, same honest pattern as #65's un-fed accumulators. ## Acceptance criteria, checked off honestly - **Track points written incrementally** — yes. Every `addTrackPoint()` flushes immediately; a crash loses at most the points since the last flush. This is a *stronger* bound than #12's watch-side precedent (a 30 s periodic full-snapshot rewrite, throttled for flash-write endurance): GPX is an append-only text format, so appending each point already *is* the incremental write, with no snapshot-rewrite cost to throttle against. - **Heart rate embedded using the standard Garmin TrackPointExtension namespace** — the *capability* is built and tested: `GpxWriter` embeds `<gpxtpx:hr>` under `http://www.garmin.com/xmlschemas/TrackPointExtension/v1`, verified against Garmin's own published XSD (`https://www8.garmin.com/xmlschemas/TrackPointExtensionv1.xsd`) rather than recalled from memory — `hr` is `BeatsPerMinute_t`, an `xsd:unsignedByte` with `minInclusive` 1, which `GpxTrackPoint`'s own `init` block enforces. **No real HR value exists anywhere in `companion/` today** (checked by grep for `HR_SAMPLES`/`HR_BPM`/`heart.?rate` across the module, same check `DerivedMetrics.kt`'s `AvgHrAccumulator` KDoc already documents) — `HR_SAMPLES` decoding is #66, itself blocked on #16/#4's hardware chain. `heartRateBpm` is nullable end-to-end and `RideService` always passes `null` today; no fake HR source was fabricated to exercise the embedding path. The 2026-09-01 update's batched-decode/gap/backlog requirements are #66's ordering problem to solve, not this writer's — `GpxRecorder.onFix()` makes no assumption that calls arrive in wall-clock order, so once #66 exists, feeding it fix-by-fix in true timestamp order is enough. - **Elevation included where available** — the writer supports it fully (nullable, embedded as `<ele>` when present, omitted when not, tested both ways), but **it is never available today**: `GpsFix`/`LocationFix` (`:companion:core`/`:companion:location`, #17/#19's own types) carry no altitude field at all, even though `android.location.Location.getAltitude()` exists on the platform. Plumbing that through is real, separate scope outside this issue's `Files:` list — `RideService` passes `null` until it happens. Documented in `GpxRecorder`'s KDoc, not fabricated. - **Paused segments handled sensibly** — real logic: `GpxRecorder` drops every fix `StopDetector` reports `STOPPED` (no jitter points from a parked phone), closes the current `<trkseg>` the instant a stop is confirmed, and opens a fresh one on the first fix reported `MOVING` again — a stop becomes a segment seam, mirroring a real GPS head unit's own auto-pause export. Proven by `GpxRecorderTest`'s stop/resume/multi-cycle/starts-already-stopped cases, asserted against the raw `<trkseg>` count (import-side flattening erases this, see below). - **Resulting file validates and re-imports cleanly** — proven the way this sandbox actually can: `GpxRecorderTest`'s round-trip test writes a full recording (two segments, elevation, HR) and re-imports it through this project's own `GpxImporter` (#24), asserting geometry/elevation/ timestamps come back exactly as written. The fixture geometry deliberately mirrors `GpxImporterTest`'s own zigzag pattern, since `GpxImporter` runs everything through `PolylineSimplifier` (~5 m RDP tolerance) before returning it — a straight-line fixture would have interior points legitimately simplified away, proving nothing. Segment-seam and HR-embedding structure are checked against the raw XML separately, since `GpxImporter` deliberately flattens `<trkseg>` boundaries on read and its own `GpxPoint` has no HR field. ## Tests, run for real - `./gradlew :companion:core:test` — passes, including 13 new `GpxWriterTest` cases and 7 new `GpxRecorderTest` cases (the round trip among them). - `./gradlew :companion:assembleDebug` — passes (confirms the `RideService`/`RideGpxFiles` Android wiring compiles for real). https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
Record rides to GPX, including HR embedding capability (issue #20)
Some checks failed
dev-artifact / build-pbw (push) Failing after 0s
dev-artifact / build-apk (push) Failing after 0s
dev-artifact / publish (push) Has been skipped
fast-lane / host-c-tests (push) Failing after 0s
fast-lane / jvm-tests (push) Failing after 0s
fast-lane / pebble-build (push) Failing after 0s
fast-lane / lint-and-secrets (push) Failing after 0s
fast-lane / meta-declares-required-jobs (push) Failing after 0s
fast-lane / host-c-tests (pull_request) Failing after 0s
fast-lane / jvm-tests (pull_request) Failing after 0s
fast-lane / pebble-build (pull_request) Failing after 0s
fast-lane / lint-and-secrets (pull_request) Failing after 0s
fast-lane / meta-declares-required-jobs (pull_request) Failing after 0s
069bf30265
Adds GpxRecorder/GpxWriter (:companion:core, pure JVM) and wires them into RideService
(:companion:ride) so a ride is written to an incrementally-flushed GPX file as fixes
arrive from the now-real SpeedPipeline/StopDetector pipeline (#17/#69, merged tonight).

- GpxWriter: incremental GPX 1.1 writer over a java.io.Writer. Every addTrackPoint()
  flushes immediately (issue #20's "a crash loses at most a few seconds" criterion) -
  a stronger bound than #12's watch-side periodic-snapshot precedent, since GPX's
  append-only text format makes the incremental write the natural one, with no flash-wear
  throttle to weigh against it.
- Heart rate: embedded using Garmin's TrackPointExtension v1 namespace
  (http://www.garmin.com/xmlschemas/TrackPointExtension/v1), verified against Garmin's
  published XSD (https://www8.garmin.com/xmlschemas/TrackPointExtensionv1.xsd) rather than
  recalled from memory. heartRateBpm is nullable and simply omitted when absent - checked
  by grep that nothing in companion/ produces a live bpm value yet (HR_SAMPLES decoding is
  #66, blocked on #16/#4's hardware chain), so no fake HR source was fabricated to exercise
  this path.
- Elevation: wired through as a nullable parameter end-to-end, but GpsFix/LocationFix carry
  no altitude field today even though Location.getAltitude() exists on the platform - every
  caller in this codebase passes null until that's plumbed through #17/#19's own types,
  which is out of this issue's scope. Documented as a real, separate gap in GpxRecorder's
  KDoc rather than fabricated.
- Paused segments: GpxRecorder drops every fix reported MovementState.STOPPED (the same
  D35 decision StopDetector already makes, not a second threshold), closes the current
  <trkseg> the instant a stop is confirmed, and opens a fresh one on the first fix reported
  MOVING again - a stop becomes a segment seam, mirroring a real GPS head unit's own
  auto-pause export.
- Round trip: GpxRecorderTest writes a full recording (multiple segments, elevation, HR)
  and re-imports it through this project's own GpxImporter (#24), asserting geometry/
  elevation/timestamps match exactly what was written. Segment-seam and HR-embedding
  structure are asserted against the raw XML separately, since GpxImporter deliberately
  flattens trkseg boundaries and has no HR field in its own GpxPoint type.
- RideGpxFiles (:companion:ride): the thin Android adapter that opens a real file under
  getExternalFilesDir("gpx") (no runtime permission needed) and gives GpxRecorder a real
  java.io.Writer, mirroring the pure-core/thin-adapter split LocationFixMapping already
  uses. Recording is best-effort: a failed open or a write failure logs and gives up on
  recording for the rest of the ride rather than affecting the ride itself.

./gradlew :companion:core:test and :companion:assembleDebug both pass.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit ab19598240 into main 2026-09-05 12:36:27 +02:00
Sign in to join this conversation.
No description provided.