GPX share-sheet import: parser, simplification, intent filter (#24) #78

Merged
robert merged 1 commit from area/gpx-share-import into main 2026-09-03 23:55:05 +02:00
Owner

Summary

Implements #24: GPX share-sheet import intent, parser and simplification, including the acceptance
criteria added in the 2026-09-01 edge-case update.

:companion:core — the parser (pure Kotlin/JVM)

  • GpxImporter.kt: a streaming SAX parser handling both <trk>/<trkpt> tracks and <rte>/<rtept>
    routes (bikerouter.de and RideWithGPS emit routes, not tracks). Multiple <trkseg> are flattened into
    one continuous polyline in document order, so the seam is never mistaken for a turn (there is no
    per-point turn detection at import time at all — that is the enrichment chain in #63). A second <trk>
    or <rte> element is rejected by name and count rather than silently truncated to the first. Missing
    <ele> and missing/duplicate <time> are tolerated. Waypoints (<wpt>) are preserved separately from
    the polyline (FR-N12, #63). Malformed XML and GPX with no usable geometry are rejected with a message
    naming the specific problem.
  • PolylineSimplifier.kt: iterative (non-recursive, so a pathological zigzag input can't blow the
    stack) Ramer-Douglas-Peucker simplification at a 5 m perpendicular-distance tolerance — corner-preserving
    by construction, which is what "roughly 5 m spacing, preserving corners" actually calls for.
  • Security posture (per Bastion's review scope): a pre-parse DOCTYPE/entity byte-scan, a no-op
    EntityResolver as the real XXE guarantee (not reliant on Xerces-only feature strings, since Android's
    built-in parser is Expat-backed and won't necessarily recognise them — those are applied best-effort,
    wrapped so an unsupported feature doesn't fail parser setup), and hard caps on input size (32 MiB),
    point count (200,000) and element nesting depth (40) so a pathological file aborts mid-parse instead of
    exhausting memory.
  • 20 JVM unit tests (Kotest) over fixture files covering every edge case from the issue (track vs. route,
    multi-segment, multi-track/route rejection, missing elevation, missing/duplicate timestamps, waypoints,
    malformed/empty) plus the XXE, oversize, point-cap and nesting-depth guards, and a dedicated
    PolylineSimplifierTest proving straight runs collapse and corners survive. ./gradlew :companion:core:test
    is green (21 tests total including the pre-existing skeleton test, 0 skipped/failed).

:companion:route — the share-sheet landing point

  • AndroidManifest.xml: GpxShareImportActivity registered for ACTION_SEND and ACTION_VIEW, on both
    the correct application/gpx+xml MIME type and a */* + *.gpx pathPattern fallback for sources
    that share a .gpx under a generic or missing MIME type.
  • GpxShareImportActivity.kt: reads the shared Uri via ContentResolver on Dispatchers.IO, calls
    into GpxImporter, and shows the outcome. Deliberately plain android.app.Activity rather than AndroidX
    ComponentActivity (not worth pulling AndroidX Activity/Lifecycle into this module for one screen), and
    Dispatchers.IO rather than .Main — kotlinx-coroutines-android isn't on this module's classpath, so
    Dispatchers.Main would throw at runtime; the result is posted back via runOnUiThread instead.
  • de/en string resources for the status messages.

Left out of scope, tracked separately

  • Real-device verification against actual komoot, bikerouter.de and RideWithGPS exports, and Android
    11+ package-visibility behaviour for the intent filters (D36) — needs Robert's phone, not something a
    JVM test can prove.
  • Saving an imported route into a persistent library — that's #25. This screen's job ends at "did the
    file parse, and if not, why".
  • <rtept> name/desc turn-cue text (bikerouter.de's tier-0 cues, FR-N15/D26) is deliberately not
    extracted here — it belongs to the enrichment chain in #63, which reads the source GPX itself.
  • The */* + pathPattern intent-filter fallback only matches when the shared Uri's path is
    filename-shaped, which isn't guaranteed for every content provider (some hand back opaque document ids)
    — documented as a known limitation in the activity's kdoc, not something fixable without a real share
    from a real app.

docs/REQUIREMENTS.md already listed FR-N1 as "Covered — #24"; verified still accurate, no change made.

https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt

## Summary Implements #24: GPX share-sheet import intent, parser and simplification, including the acceptance criteria added in the 2026-09-01 edge-case update. ## :companion:core — the parser (pure Kotlin/JVM) - `GpxImporter.kt`: a streaming SAX parser handling both `<trk>`/`<trkpt>` tracks and `<rte>`/`<rtept>` routes (bikerouter.de and RideWithGPS emit routes, not tracks). Multiple `<trkseg>` are flattened into one continuous polyline in document order, so the seam is never mistaken for a turn (there is no per-point turn detection at import time at all — that is the enrichment chain in #63). A second `<trk>` or `<rte>` element is rejected by name and count rather than silently truncated to the first. Missing `<ele>` and missing/duplicate `<time>` are tolerated. Waypoints (`<wpt>`) are preserved separately from the polyline (FR-N12, #63). Malformed XML and GPX with no usable geometry are rejected with a message naming the specific problem. - `PolylineSimplifier.kt`: iterative (non-recursive, so a pathological zigzag input can't blow the stack) Ramer-Douglas-Peucker simplification at a 5 m perpendicular-distance tolerance — corner-preserving by construction, which is what "roughly 5 m spacing, preserving corners" actually calls for. - Security posture (per Bastion's review scope): a pre-parse DOCTYPE/entity byte-scan, a no-op `EntityResolver` as the real XXE guarantee (not reliant on Xerces-only feature strings, since Android's built-in parser is Expat-backed and won't necessarily recognise them — those are applied best-effort, wrapped so an unsupported feature doesn't fail parser setup), and hard caps on input size (32 MiB), point count (200,000) and element nesting depth (40) so a pathological file aborts mid-parse instead of exhausting memory. - 20 JVM unit tests (Kotest) over fixture files covering every edge case from the issue (track vs. route, multi-segment, multi-track/route rejection, missing elevation, missing/duplicate timestamps, waypoints, malformed/empty) plus the XXE, oversize, point-cap and nesting-depth guards, and a dedicated `PolylineSimplifierTest` proving straight runs collapse and corners survive. `./gradlew :companion:core:test` is green (21 tests total including the pre-existing skeleton test, 0 skipped/failed). ## :companion:route — the share-sheet landing point - `AndroidManifest.xml`: `GpxShareImportActivity` registered for `ACTION_SEND` and `ACTION_VIEW`, on both the correct `application/gpx+xml` MIME type and a `*/*` + `*.gpx` `pathPattern` fallback for sources that share a `.gpx` under a generic or missing MIME type. - `GpxShareImportActivity.kt`: reads the shared `Uri` via `ContentResolver` on `Dispatchers.IO`, calls into `GpxImporter`, and shows the outcome. Deliberately plain `android.app.Activity` rather than AndroidX `ComponentActivity` (not worth pulling AndroidX Activity/Lifecycle into this module for one screen), and `Dispatchers.IO` rather than `.Main` — `kotlinx-coroutines-android` isn't on this module's classpath, so `Dispatchers.Main` would throw at runtime; the result is posted back via `runOnUiThread` instead. - `de`/`en` string resources for the status messages. ## Left out of scope, tracked separately - **Real-device verification** against actual komoot, bikerouter.de and RideWithGPS exports, and Android 11+ package-visibility behaviour for the intent filters (D36) — needs Robert's phone, not something a JVM test can prove. - **Saving an imported route into a persistent library** — that's #25. This screen's job ends at "did the file parse, and if not, why". - **`<rtept>` name/desc turn-cue text** (bikerouter.de's tier-0 cues, FR-N15/D26) is deliberately not extracted here — it belongs to the enrichment chain in #63, which reads the source GPX itself. - The `*/*` + `pathPattern` intent-filter fallback only matches when the shared `Uri`'s path is filename-shaped, which isn't guaranteed for every content provider (some hand back opaque document ids) — documented as a known limitation in the activity's kdoc, not something fixable without a real share from a real app. `docs/REQUIREMENTS.md` already listed FR-N1 as "Covered — #24"; verified still accurate, no change made. https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
:companion:core gains a hardened, pure-Kotlin GPX importer:

- GpxImporter.kt: streaming SAX parser handling both <trk>/<trkpt> tracks
  and <rte>/<rtept> routes (bikerouter.de, RideWithGPS emit routes, not
  tracks). Multiple <trkseg> are flattened into one continuous polyline in
  document order, so a segment seam is never mistaken for a turn. A second
  <trk> or <rte> element is rejected by name and count rather than silently
  truncating to the first. Missing <ele> and missing/duplicate <time> are
  tolerated; waypoints are preserved separately from the polyline (FR-N12,
  #63). Malformed XML and GPX with no usable geometry are rejected with a
  message naming the specific problem.
- PolylineSimplifier.kt: iterative (non-recursive) Ramer-Douglas-Peucker
  simplification at a 5 m tolerance, corner-preserving by construction.
- Security posture per Bastion's review: a pre-parse DOCTYPE/entity scan, a
  no-op EntityResolver as the actual XXE guarantee (not reliant on
  Xerces-only feature strings, since Android's parser is Expat-backed), and
  hard caps on input size, point count and element nesting depth so a
  pathological file aborts mid-parse instead of exhausting memory.
- 20 JVM unit tests (Kotest) over fixtures covering every edge case in the
  issue's 2026-09-01 update, plus the XXE/oversize/point-cap/nesting-depth
  guards. `./gradlew :companion:core:test` is green.

:companion:route gains the actual share-sheet landing point:

- AndroidManifest.xml: GpxShareImportActivity registered for ACTION_SEND
  and ACTION_VIEW, both on the correct application/gpx+xml MIME type and
  on a */* + *.gpx pathPattern fallback for sources that share a .gpx
  under a generic or missing MIME type.
- GpxShareImportActivity.kt: reads the shared Uri via ContentResolver on
  Dispatchers.IO, calls into GpxImporter, and shows the outcome. Deliberately
  plain android.app.Activity (no AndroidX Activity/Lifecycle pulled in for
  one screen) and Dispatchers.IO rather than .Main (kotlinx-coroutines-android
  isn't a dependency of this module). What happens after a successful parse
  — saving into a library — is #25's job, out of scope here.
- de/en string resources for the status messages (docs/TEAM.md — user-visible
  text carries both locales from the moment it's written).

Left out of scope, tracked separately:
- Real-device / share-sheet verification against actual komoot, bikerouter.de
  and RideWithGPS exports, and Android 11+ package-visibility behaviour for
  the intent filters (D36) — needs Robert's phone.
- Saving an imported route into a persistent library (#25).
- Extracting <rtept> name/desc turn-cue text (#63/D26) — this importer's
  scope ends at geometry and waypoints.

docs/REQUIREMENTS.md already listed FR-N1 as covered by #24; no change needed.

Claude-Session: https://claude.ai/code/session_01DAoXbRmJUf2uxNYBfdAXPt
robert merged commit b42752c658 into main 2026-09-03 23:55:05 +02:00
Sign in to join this conversation.
No description provided.