# 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 … … 1045.0 ``` **One `` 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 `` 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, points: List): String } object GeoJsonWriter { fun write(trip: Trip, segments: List, points: List): 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] `` 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).