Two copies for two jobs. rippr-src/ is a browsable git archive export of the tracked tree at 2f76983 - no build outputs, no local.properties, no nested .git - which is convenient to read in gitea but carries no history and will drift. rippr-full-history.bundle is the real backup: all 18 commits, verified as "records a complete history" and test-cloned before committing. This matters because ~/dojo/rippr has no git remote and otherwise exists only on one machine. rippr-src/SNAPSHOT.md explains the difference and how to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
4.3 KiB
Markdown
119 lines
4.3 KiB
Markdown
# T15 — GPX + GeoJSON writers
|
|
|
|
**Phase** 6 · **Depends on** T04 · **Status** Done
|
|
|
|
## Goal
|
|
|
|
Serialise a trip to GPX 1.1 and GeoJSON as pure strings, with pause structure preserved.
|
|
Done when a generated GPX opens correctly in Strava, Garmin Connect, or Google Earth, and
|
|
both formats are covered by unit tests running without a device.
|
|
|
|
## Context
|
|
|
|
Export is the hedge against Rippr's own map ever being limiting — it makes the data
|
|
portable into tooling that already exists. It is also cheap: pure string generation with
|
|
no Android dependency, fully unit-testable, and parallelisable with the entire UI track.
|
|
|
|
Everything needed is already recorded: `latitude`, `longitude`, `altitudeM`, `timestamp`,
|
|
`speedKmh` per point, and segments delimiting pauses.
|
|
|
|
## Design
|
|
|
|
### GPX 1.1
|
|
|
|
```xml
|
|
<gpx version="1.1" creator="Rippr" xmlns="http://www.topografix.com/GPX/1/1">
|
|
<metadata><name>…</name><time>…</time></metadata>
|
|
<trk>
|
|
<name>…</name>
|
|
<trkseg> <!-- one per Segment -->
|
|
<trkpt lat="51.04470" lon="-114.07190">
|
|
<ele>1045.0</ele>
|
|
<time>2026-08-10T13:48:50Z</time>
|
|
</trkpt>
|
|
</trkseg>
|
|
</trk>
|
|
</gpx>
|
|
```
|
|
|
|
**One `<trkseg>` per Segment** is the whole reason the Segment model exists — it is
|
|
exactly how GPX represents a recording gap, so pauses survive the round-trip into Strava
|
|
or Garmin rather than becoming a straight line across town.
|
|
|
|
Speed goes in a `<extensions>` block. Speed is not part of core GPX 1.1; consumers that
|
|
do not understand the extension ignore it, which is the correct degradation.
|
|
|
|
Formatting rules:
|
|
- Timestamps ISO 8601 **UTC with a `Z` suffix** — `Location.time` is already UTC epoch
|
|
millis, so no zone conversion, and local time here would be silently wrong
|
|
- Coordinates at 7 decimal places (~1 cm — beyond GPS precision but standard practice)
|
|
- Elevation at 1 decimal
|
|
- **XML-escape** any user-supplied trip name; a name containing `&` or `<` otherwise
|
|
produces a malformed file
|
|
|
|
### GeoJSON
|
|
|
|
`FeatureCollection`, one `LineString` `Feature` per segment, with trip metadata in
|
|
`properties`. Coordinates are `[lon, lat, ele]` — **longitude first**, which is the
|
|
opposite order to GPX and the most common mistake in GeoJSON output.
|
|
|
|
### API
|
|
|
|
```kotlin
|
|
object GpxWriter {
|
|
fun write(trip: Trip, segments: List<Segment>, points: List<TrackPoint>): String
|
|
}
|
|
object GeoJsonWriter {
|
|
fun write(trip: Trip, segments: List<Segment>, points: List<TrackPoint>): String
|
|
}
|
|
```
|
|
|
|
Pure functions over already-loaded data — no repository, no context, no I/O.
|
|
|
|
## Implementation
|
|
|
|
1. Create `export/GpxWriter.kt` and `export/GeoJsonWriter.kt`.
|
|
2. Group points by `segmentId`, preserving `segmentId, id` order.
|
|
3. ISO 8601 UTC formatting via `java.time.Instant`.
|
|
4. XML escaping for all interpolated text.
|
|
5. Build with `StringBuilder` — a full XML DOM is unnecessary for this shape.
|
|
6. Unit tests.
|
|
|
|
## Acceptance criteria
|
|
|
|
- [x] GPX is well-formed XML and validates against the GPX 1.1 schema
|
|
- [x] `<trkseg>` count equals segment count
|
|
- [x] Every raw point is present — **no decimation** (the T14 guard)
|
|
- [x] Timestamps are UTC with `Z`
|
|
- [x] A trip name containing `&`, `<`, `"` produces valid XML
|
|
- [x] GeoJSON coordinates are `[lon, lat, ele]`
|
|
- [x] Empty trip produces a valid document with no track points, not a crash
|
|
|
|
## Tests
|
|
|
|
JVM unit tests:
|
|
- Parse generated GPX with a real XML parser and assert structure
|
|
- Segment count matches; point count matches input exactly
|
|
- Timestamp format assertion against a known epoch
|
|
- XML-escaping test with a hostile trip name
|
|
- GeoJSON coordinate order — explicitly assert lon-first
|
|
- Empty and single-point trips
|
|
|
|
Manual: export a real ride and open it in Google Earth or Strava. This is the acceptance
|
|
test that actually matters — schema validity does not guarantee a consumer accepts it.
|
|
|
|
## Risks / gotchas
|
|
|
|
- **Coordinate order differs between the two formats.** GPX is lat/lon attributes;
|
|
GeoJSON is lon-first arrays. Easy to get backwards, and the result silently plots in
|
|
the wrong hemisphere.
|
|
- **Decimation must not appear here.** Assert full point counts in tests.
|
|
- **Unescaped names produce malformed XML** — the failure is invisible until an import
|
|
fails.
|
|
- **Memory**: a 21,600-point ride as one `String` is a few MB. Acceptable, but if trips
|
|
grow much larger, stream to the output instead.
|
|
|
|
## Out of scope
|
|
|
|
File writing, sharing, SAF (all T16).
|