# 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).