Refresh Rippr snapshot and bundle with full project documentation
Re-exported at 46a0726, which adds README.md plus docs/ARCHITECTURE, DEVELOPMENT, TESTING, v1 history including the original brief, and a v3 backlog. 112 files, and the bundle now carries 19 commits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
116
rippr-src/README.md
Normal file
116
rippr-src/README.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Rippr
|
||||||
|
|
||||||
|
A native Android GPS ride recorder for motorcycles. Records telemetry from an
|
||||||
|
un-killable foreground service into a local SQLite database, renders the traversed path
|
||||||
|
on a map afterwards, and exports to GPX/GeoJSON.
|
||||||
|
|
||||||
|
Built for one specific use: **hit start, put the phone in a pocket, ride, hit stop.**
|
||||||
|
Every design decision below follows from that.
|
||||||
|
|
||||||
|
```
|
||||||
|
Package com.rippr minSdk 26 (Android 8.0) targetSdk 35
|
||||||
|
Kotlin 2.0.21 AGP 8.7.3 Gradle 8.11.1
|
||||||
|
~5,800 lines Kotlin 84 unit tests 46 instrumented tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current state — v2.0.1
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
|---|---|
|
||||||
|
| GPS recording via foreground service | Working, validated on real rides |
|
||||||
|
| Trips with pause/resume/stop/discard | Working |
|
||||||
|
| Live speed + elapsed clock | Working (fixed in 2.0.1) |
|
||||||
|
| Path rendered on OpenStreetMap | Working (fixed in 2.0.1) |
|
||||||
|
| Ride statistics + charts | Working; elevation gain has known drift |
|
||||||
|
| Rename / delete / merge rides | Working |
|
||||||
|
| GPX + GeoJSON export | Working, validated against an external parser |
|
||||||
|
| Upload to a REST endpoint | Implemented, **no UI** — see below |
|
||||||
|
| Live map during recording | Deliberately absent — see below |
|
||||||
|
| Group ride view | Deferred to v3 |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Requires **JDK 17–21** (AGP does not support 25) and the Android SDK with platform 35 and
|
||||||
|
build-tools 35. Full setup in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
|
||||||
|
./gradlew assembleDebug testDebugUnitTest lintDebug
|
||||||
|
./gradlew connectedDebugAndroidTest # needs a device or emulator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture in one page
|
||||||
|
|
||||||
|
```
|
||||||
|
TrackingService (foreground)
|
||||||
|
└─ FusedLocation callback ──trySend──► unbounded Channel
|
||||||
|
│
|
||||||
|
single writer coroutine (batches of 25, ~2s)
|
||||||
|
│
|
||||||
|
┌───────────────────┴────────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
Room database Accumulator
|
||||||
|
Trip → Segment → TrackPoint distance / moving time /
|
||||||
|
│ elevation, folded per batch
|
||||||
|
▼
|
||||||
|
TripRepository (all lifecycle transitions)
|
||||||
|
│
|
||||||
|
┌─────────────────┼──────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
RecordScreen TripsScreen TripDetailScreen
|
||||||
|
└─ RideMap (osmdroid)
|
||||||
|
```
|
||||||
|
|
||||||
|
Three decisions carry most of the weight:
|
||||||
|
|
||||||
|
**The location callback never blocks.** Fixes go into an unbounded `Channel`; a single
|
||||||
|
writer coroutine drains it in batches. Slow disk stalls the writer, never GPS, and no fix
|
||||||
|
is dropped under back pressure.
|
||||||
|
|
||||||
|
**Recording state lives in the database, not memory.** A row in `trips` with
|
||||||
|
`endedAt IS NULL` *is* the fact of an in-progress ride. It survives process death, which
|
||||||
|
an in-memory flag cannot — `START_STICKY` restarts the service with a null intent and a
|
||||||
|
flag would come back `false` mid-ride.
|
||||||
|
|
||||||
|
**Segments make pause correct rather than cosmetic.** Without them, pausing at a gas
|
||||||
|
station and resuming across town draws a straight line through terrain never ridden, and
|
||||||
|
counts it as distance. Segments propagate all the way through: distance accumulation, map
|
||||||
|
polylines, and GPX `<trkseg>`.
|
||||||
|
|
||||||
|
Full reasoning in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## Two deliberate absences
|
||||||
|
|
||||||
|
**No live map while recording.** The phone is in a pocket; nobody is looking at it. A live
|
||||||
|
map would burn battery on top of GPS and a wake lock for nothing. The map is a post-ride
|
||||||
|
artifact, gated behind a toggle, and `TrackingService` holds no reference to any map type.
|
||||||
|
|
||||||
|
**No UI for the upload endpoint.** The REST uploader works and is tested, but is only
|
||||||
|
reachable via `Config.setUploadEndpoint()`. It has no server to talk to yet; the UI arrives
|
||||||
|
when group-ride streaming does.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Contents |
|
||||||
|
|---|---|
|
||||||
|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Durable design decisions and the reasoning behind them |
|
||||||
|
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Toolchain setup, build/test commands, emulator harness |
|
||||||
|
| [docs/TESTING.md](docs/TESTING.md) | What is covered, what is not, and what the emulator cannot verify |
|
||||||
|
| [docs/v1/](docs/v1/) | Original spec, what shipped, and how it deviated |
|
||||||
|
| [docs/v2/](docs/v2/) | 18-task plan, per-task docs, and the progress log with every bug found |
|
||||||
|
| [docs/v3/BACKLOG.md](docs/v3/BACKLOG.md) | Known gaps, deferred work, and ideas for next |
|
||||||
|
|
||||||
|
**Start here for v3:** [docs/v3/BACKLOG.md](docs/v3/BACKLOG.md), then
|
||||||
|
[docs/v2/PROGRESS.md](docs/v2/PROGRESS.md) for the bugs that were found and why.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
1. **Elevation gain drifts** ~30 m per ten stationary minutes against synthetic noise.
|
||||||
|
Real GPS error is correlated rather than uniform, so the true figure needs a real ride.
|
||||||
|
Flat ground should read near zero.
|
||||||
|
2. **No Compose UI tests** for any of the six screens. Verification was manual.
|
||||||
|
3. **Speed colouring on the map is unvalidated** — the emulator reports zero velocity, so
|
||||||
|
the path renders uniformly there.
|
||||||
|
4. **Migrations are now mandatory.** `fallbackToDestructiveMigration()` was removed in v2;
|
||||||
|
any schema change must ship a `Migration` against
|
||||||
|
`app/schemas/com.rippr.data.AppDatabase/2.json`.
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Rippr — source snapshot
|
|
||||||
|
|
||||||
Point-in-time export of the Rippr working tree, exported with `git archive HEAD`
|
|
||||||
(tracked files only — no build outputs, no `local.properties`, no nested `.git`).
|
|
||||||
|
|
||||||
**Commit:** `2f76983` — "Fix live readout and blank map, both found on the first real ride"
|
|
||||||
|
|
||||||
## Two copies live here, for different jobs
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `rippr-src/` | Browsable source. No history — a snapshot that will drift. |
|
|
||||||
| `rippr-full-history.bundle` | Full git history (18 commits). The real backup. |
|
|
||||||
|
|
||||||
The canonical repo is `~/dojo/rippr`, which **has no git remote** — it exists only on that
|
|
||||||
machine. The bundle is therefore the only off-machine copy of the history.
|
|
||||||
|
|
||||||
## Restoring from the bundle
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone rippr-full-history.bundle rippr
|
|
||||||
cd rippr
|
|
||||||
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
|
|
||||||
./gradlew assembleDebug
|
|
||||||
```
|
|
||||||
|
|
||||||
Verified: the bundle reports "records a complete history" and clones cleanly to all 18
|
|
||||||
commits.
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
Needs JDK 17–21 (AGP does not support 25) and the Android SDK with platform 35 and
|
|
||||||
build-tools 35. See `docs/v2/01-baseline.md`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew assembleDebug testDebugUnitTest lintDebug # 84 unit tests
|
|
||||||
./gradlew connectedDebugAndroidTest # 46, needs a device
|
|
||||||
```
|
|
||||||
|
|
||||||
## Where to start reading
|
|
||||||
|
|
||||||
- `docs/v2/README.md` — task index and architecture decisions
|
|
||||||
- `docs/v2/PROGRESS.md` — per-task outcomes, bugs found, and known gaps
|
|
||||||
- `app/src/main/java/com/rippr/TrackingService.kt` — the recording pipeline
|
|
||||||
288
rippr-src/docs/ARCHITECTURE.md
Normal file
288
rippr-src/docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
The decisions that shaped Rippr, and — more usefully — *why*. Several exist because
|
||||||
|
something specific went wrong; those are marked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The recording pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
onLocationResult (main looper)
|
||||||
|
│ stamp tripId + segmentId, sanitize speed, reject bad-accuracy fixes
|
||||||
|
▼
|
||||||
|
Channel(UNLIMITED) ──► single writer coroutine (Dispatchers.IO)
|
||||||
|
│ batches of 25, flushed every ~2s, under writeMutex
|
||||||
|
├─► insertPoints()
|
||||||
|
└─► Accumulator.fold() → updateAggregates() on the Trip row
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why an unbounded Channel
|
||||||
|
|
||||||
|
The location callback must never block. Writing to SQLite from inside `onLocationResult`
|
||||||
|
would couple GPS delivery to disk latency, and a slow flash write would drop fixes. The
|
||||||
|
channel decouples them: the callback's `trySend` never suspends, and back pressure stalls
|
||||||
|
the *writer*, not the sensor.
|
||||||
|
|
||||||
|
**Do not replace this with a direct write.** It is the single most load-bearing decision in
|
||||||
|
the recording path.
|
||||||
|
|
||||||
|
### Why points are stamped at creation
|
||||||
|
|
||||||
|
Each `TrackPoint` carries its `tripId` and `segmentId` from the moment it is built in the
|
||||||
|
callback, not looked up at write time.
|
||||||
|
|
||||||
|
This makes pausing safe: a fix already queued when the rider pauses lands in the segment it
|
||||||
|
was actually recorded during, whatever order the writes happen in.
|
||||||
|
|
||||||
|
> **Correction worth remembering.** The v2 plan originally claimed that closing a segment
|
||||||
|
> before draining the channel would misfile points. That was wrong — stamping at creation
|
||||||
|
> already prevents it. Draining before closing is still done, but as robustness (not
|
||||||
|
> leaving points unwritten while a ride idles), not correctness. If anyone ever refactors
|
||||||
|
> stamping into a write-time lookup, this guarantee disappears.
|
||||||
|
|
||||||
|
### Why batched writes
|
||||||
|
|
||||||
|
A 2 Hz stream would otherwise mean two transactions per second for hours. Batching to 25
|
||||||
|
points or 2 seconds — whichever comes first — cuts that to one write per two seconds.
|
||||||
|
|
||||||
|
### Why WAL journal mode
|
||||||
|
|
||||||
|
A ride is unrecoverable if writes are lost to a crash, but `fsync` on every insert at 2 Hz
|
||||||
|
burns battery. WAL with `NORMAL` sync survives app crashes; only an OS-level crash can lose
|
||||||
|
the last few points. That is the accepted trade.
|
||||||
|
|
||||||
|
### Why the wake lock
|
||||||
|
|
||||||
|
A foreground service alone does not guarantee CPU time with the screen off on all OEMs. A
|
||||||
|
`PARTIAL_WAKE_LOCK` keeps the writer coroutine running. It is **released on pause** — a
|
||||||
|
lunch stop has no business holding the CPU awake — and re-acquired on resume.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
```
|
||||||
|
Trip 1───* Segment 1───* TrackPoint
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
Trip(id, startedAt, endedAt?, name?, state,
|
||||||
|
distanceM, movingMillis, maxSpeedKmh, elevationGainM, pointCount)
|
||||||
|
Segment(id, tripId, startedAt, endedAt?)
|
||||||
|
TrackPoint(id, tripId, segmentId, timestamp, lat, lon, speedKmh, altitudeM,
|
||||||
|
accuracyM, bearingDeg, synced)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Segment exists
|
||||||
|
|
||||||
|
Three of the four things v1 lacked — reset, pause, and trips — were the same missing
|
||||||
|
concept. Segments are what make pause *correct*: they represent a pause-free stretch of
|
||||||
|
recording, so every consumer naturally leaves a gap where the rider stopped.
|
||||||
|
|
||||||
|
They propagate into three places, and all three would be wrong without them:
|
||||||
|
- **Distance** accumulates only within a segment
|
||||||
|
- **Map polylines** are drawn one per segment, never joined
|
||||||
|
- **GPX** emits one `<trkseg>` per segment, which is exactly how GPX represents a gap
|
||||||
|
|
||||||
|
### Why both `endedAt` and `state`
|
||||||
|
|
||||||
|
`endedAt == null` distinguishes active from finished, but cannot distinguish RECORDING from
|
||||||
|
PAUSED. The service needs that difference to decide what to do when the OS restarts it
|
||||||
|
mid-ride.
|
||||||
|
|
||||||
|
### Why aggregates are denormalised onto Trip
|
||||||
|
|
||||||
|
**minSdk 26 means SQLite 3.18, which has no window functions** — no `LAG`, no `OVER`. There
|
||||||
|
is simply no SQL expression for "distance from the previous point". So consecutive-point
|
||||||
|
maths happens in Kotlin, in the writer loop that already touches every point, and the
|
||||||
|
result is persisted on the Trip row.
|
||||||
|
|
||||||
|
This also keeps the trips list fast: it reads only `Trip` rows, never the point table, so
|
||||||
|
it stays responsive with hundreds of rides.
|
||||||
|
|
||||||
|
### Ordering is by `(segmentId, id)`, never `timestamp`
|
||||||
|
|
||||||
|
`timestamp` comes from `Location.time`, which is GPS-derived and can jump. The autoincrement
|
||||||
|
`id` is genuinely monotonic in write order.
|
||||||
|
|
||||||
|
### Migrations are mandatory
|
||||||
|
|
||||||
|
`fallbackToDestructiveMigration()` was used during v2 development, when the only data was
|
||||||
|
throwaway test rides, and **removed in T18**. Any schema change from here must ship a
|
||||||
|
`Migration` against `app/schemas/com.rippr.data.AppDatabase/2.json`. Reinstating the
|
||||||
|
fallback would silently delete every stored ride on the next version bump.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State ownership
|
||||||
|
|
||||||
|
Recording state is derived from the database, never held in memory.
|
||||||
|
|
||||||
|
v1 kept an in-memory flag. It lied after process death: `START_STICKY` restarts the service
|
||||||
|
with a null intent, and the flag came back `false` while a ride was genuinely underway.
|
||||||
|
This was verified fixed by force-stopping the app mid-ride — the button correctly still
|
||||||
|
read STOP.
|
||||||
|
|
||||||
|
`TripRepository` owns every transition, each in a `withTransaction`, and **each is
|
||||||
|
idempotent**:
|
||||||
|
|
||||||
|
| Transition | Behaviour on an unexpected state |
|
||||||
|
|---|---|
|
||||||
|
| `startTrip` | **Adopts** an already-active trip rather than creating a second |
|
||||||
|
| `pauseTrip` | No-op if already paused |
|
||||||
|
| `resumeTrip` | No-op if already recording — never opens a duplicate segment |
|
||||||
|
| `completeTrip` / `discardTrip` | No-op with no active trip |
|
||||||
|
|
||||||
|
Idempotency is not defensive padding: the OS can restart the service at any moment, and
|
||||||
|
these are the states it can restart into.
|
||||||
|
|
||||||
|
### Ephemeral state is separate and deliberate
|
||||||
|
|
||||||
|
Two objects hold genuinely transient state that *should* be lost on restart:
|
||||||
|
`UploadStatus` (last upload error) and `LiveTelemetry` (current speed, published from the
|
||||||
|
GPS callback at sensor rate so the speedo does not wait on the 2-second flush).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
|
||||||
|
Computed twice, deliberately, by the same code:
|
||||||
|
|
||||||
|
- **Live**, folded per batch into `Accumulator`, so the screen shows moving numbers
|
||||||
|
- **Authoritatively**, via `RideStatistics.compute()` over stored points when a trip
|
||||||
|
completes, overwriting the live estimate
|
||||||
|
|
||||||
|
A mid-ride process kill loses the in-memory accumulator, so the live figure can drift.
|
||||||
|
Recomputing at completion with the *same functions* keeps the two consistent by
|
||||||
|
construction rather than by discipline.
|
||||||
|
|
||||||
|
### The cross-batch anchor
|
||||||
|
|
||||||
|
`Accumulator` keeps the last point of the *previous* batch. Without it, distance restarts
|
||||||
|
at every flush boundary and under-reports by roughly one hop per batch — a few percent,
|
||||||
|
invisible until compared against an odometer. A unit test asserts that chunked folding
|
||||||
|
equals single-batch folding.
|
||||||
|
|
||||||
|
### Elevation gain needs two mechanisms
|
||||||
|
|
||||||
|
This is the subtlest maths in the codebase, and it was measured, not assumed.
|
||||||
|
|
||||||
|
A naive "sum every delta above a 3 m threshold" reported **1498 m of climbing over a parked
|
||||||
|
bike** with ±8 m altitude noise, because noise crosses any small threshold constantly.
|
||||||
|
|
||||||
|
The shipped version combines:
|
||||||
|
1. A **15-sample moving average**, cutting noise by roughly √window
|
||||||
|
2. **Reversal hysteresis** — a climb banks only once altitude turns back *down* past the
|
||||||
|
threshold from its peak
|
||||||
|
|
||||||
|
That brings the same fixture to ~30 m. Smoothing then clipped real terrain (a 100 m climb
|
||||||
|
measured 93 m, since a moving average lags by half a window), so `finish()` reconciles the
|
||||||
|
final run against the last raw reading.
|
||||||
|
|
||||||
|
**~30 m of drift per ten stationary minutes remains.** The regression test guards against
|
||||||
|
returning to 1498 m; it is not a claim of accuracy.
|
||||||
|
|
||||||
|
### Other definitions worth knowing
|
||||||
|
|
||||||
|
- **Average speed is distance ÷ moving time**, not the mean of speed samples. The mean
|
||||||
|
over-weights time spent stopped.
|
||||||
|
- **Sample gaps are capped at 10 s.** Without it, a two-minute tunnel counts as two minutes
|
||||||
|
of moving time at the last known speed.
|
||||||
|
- **The speed histogram is weighted by time**, not sample count.
|
||||||
|
- **Division guards everywhere** — a NaN reaching Compose renders as the literal text "NaN".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
`MainActivity` (74 lines) owns only runtime permissions and the battery-optimisation
|
||||||
|
prompt. Everything else is `ui/{theme,components,record,trips,detail}` with one ViewModel
|
||||||
|
per screen behind a single factory. No DI framework — three screens do not earn Hilt.
|
||||||
|
|
||||||
|
### Two hard-won UI lessons
|
||||||
|
|
||||||
|
**`Surface` is load-bearing, not decoration.** Moving the UI out of the old `Surface`
|
||||||
|
wrapper made `LocalContentColor` default to black, rendering the 64sp speed figure
|
||||||
|
invisible against the near-black ground. Text with an explicit colour still showed, so the
|
||||||
|
screen looked merely odd rather than broken. `RipprTheme` now wraps content in a `Surface`.
|
||||||
|
**No test would have caught this** — only looking at a screenshot did.
|
||||||
|
|
||||||
|
**Show current speed, not max.** The first real ride reported the screen as frozen. A max
|
||||||
|
figure only moves when you beat it, so riding steadily leaves it motionless. The headline
|
||||||
|
is now current speed, with max demoted to the stats card, and a wall-clock elapsed timer
|
||||||
|
ticks every second independent of any database write.
|
||||||
|
|
||||||
|
### Control layout
|
||||||
|
|
||||||
|
| State | Primary | Secondary |
|
||||||
|
|---|---|---|
|
||||||
|
| Idle | START RECORDING | — |
|
||||||
|
| Recording | PAUSE | STOP |
|
||||||
|
| Paused | RESUME | STOP · DISCARD |
|
||||||
|
|
||||||
|
**Discard appears only while paused.** A destructive control next to Pause during a live
|
||||||
|
ride invites a gloved mis-tap at speed. Buttons are 72dp/56dp because they get pressed with
|
||||||
|
gloves on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Map
|
||||||
|
|
||||||
|
osmdroid, chosen over MapLibre and Google Maps because it needs no API key, no billing
|
||||||
|
account, and no GCP project, and it caches tiles for offline use — which matters on
|
||||||
|
mountain rides.
|
||||||
|
|
||||||
|
Three things that will break it if disturbed:
|
||||||
|
|
||||||
|
1. **The user agent must be set before any `MapView` is constructed.** osmdroid's default is
|
||||||
|
rejected by OSM's tile servers with a 403, and the failure presents as an empty map
|
||||||
|
rather than an error. Set in `RipprApp.onCreate`.
|
||||||
|
2. **`onDetach()` is not optional.** Without it, tile handles and the downloader thread
|
||||||
|
outlive the composable and the leak compounds. The `MapView` is hoisted into a
|
||||||
|
`remember` so the lifecycle observer can reach the same instance the `AndroidView`
|
||||||
|
shows — an earlier version observed the lifecycle and did nothing, because it could not
|
||||||
|
see the view.
|
||||||
|
3. **Zoom must be clamped.** `zoomToBoundingBox` ignores `maxZoomLevel`. A 50 m ride zooms
|
||||||
|
past OSM Mapnik's maximum published zoom of 19, where no tile exists, and the map renders
|
||||||
|
as an empty grid. This shipped in v2.0 and was found on the first real ride.
|
||||||
|
|
||||||
|
Rendering uses **bucketed polylines** — runs of similar speed drawn as separate monochrome
|
||||||
|
lines, overlapping by one point — rather than osmdroid's per-vertex `PolyChromaticPaintList`,
|
||||||
|
which is fiddly and gains little at real viewing zoom.
|
||||||
|
|
||||||
|
**Decimation is render-only.** `Geo.simplify` is applied when building overlays and nowhere
|
||||||
|
else. Storage and export always use raw points; the export tests assert exact counts
|
||||||
|
specifically to catch a leak.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
Pure string generation with no Android dependency, so the format logic is unit-tested on
|
||||||
|
the JVM.
|
||||||
|
|
||||||
|
- **GPX 1.1**, one `<trkseg>` per segment, speed in `<extensions>` in **m/s** per the spec
|
||||||
|
- **GeoJSON**, coordinates `[lon, lat, ele]` — **longitude first**, the opposite of GPX and
|
||||||
|
the classic silent error; a test asserts it explicitly
|
||||||
|
- **Timestamps inside files are UTC**; **filenames use local time**, because a human reads
|
||||||
|
the filename and a machine reads the contents
|
||||||
|
- Names are XML- and JSON-escaped — a ride called `Sam & Dave's <ride>` would otherwise
|
||||||
|
produce a malformed file, and the failure is invisible until an import rejects it
|
||||||
|
|
||||||
|
Delivery is via `FileProvider` and the share sheet. A raw `file://` URI throws
|
||||||
|
`FileUriExposedException` on Android 7+, and the receiving app needs
|
||||||
|
`FLAG_GRANT_READ_URI_PERMISSION` or it reports a SecurityException that looks like a bug in
|
||||||
|
*that* app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upload
|
||||||
|
|
||||||
|
`TelemetryUploader` is deliberately subordinate to recording: every failure is swallowed and
|
||||||
|
retried, and nothing in it can stop the location pipeline. Points carry `synced = 0` until
|
||||||
|
the server acknowledges them, so a dead endpoint costs only a growing backlog.
|
||||||
|
|
||||||
|
`trip_id` and `segment_id` are stamped **per point, not per batch**, because
|
||||||
|
`getUnsyncedPoints()` draws by id and a batch can straddle a segment or — after a
|
||||||
|
discard-and-restart — a trip boundary.
|
||||||
188
rippr-src/docs/DEVELOPMENT.md
Normal file
188
rippr-src/docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
Everything needed to build, test, and drive Rippr without Android Studio. This was all
|
||||||
|
done from the command line; the GUI is not required at any point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Toolchain
|
||||||
|
|
||||||
|
### JDK — must be 17–21
|
||||||
|
|
||||||
|
**Not 25.** AGP 8.7 does not support it, and the failure is confusing. This machine runs
|
||||||
|
JDK 25 by default via `mise`, so builds need an explicit `JAVA_HOME`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=~/.local/share/mise/installs/java/temurin-21.0.12+8.0.LTS
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Install with `mise install java@temurin-21` if it is missing.
|
||||||
|
|
||||||
|
### Android SDK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANDROID_HOME=$HOME/Library/Android/sdk
|
||||||
|
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs platform 35 and build-tools 35. `sdkmanager` and `avdmanager` come from
|
||||||
|
`brew install android-commandlinetools`, but note the brew binaries resolve their own SDK
|
||||||
|
root and **will not see** `~/Library/Android/sdk` — `avdmanager create` fails with
|
||||||
|
"Package path is not valid" even when the image is installed. Hand-editing
|
||||||
|
`~/.android/avd/*.ini` is more reliable than fighting it.
|
||||||
|
|
||||||
|
### Disk space — the surprise blocker
|
||||||
|
|
||||||
|
The emulator enforces a **fixed ~7.4 GB free-space minimum** before it will boot. This is
|
||||||
|
not tunable: shrinking the AVD's `disk.dataPartition.size` and passing `-partition-size`
|
||||||
|
both leave the requirement unchanged.
|
||||||
|
|
||||||
|
A full build needs roughly 3–5 GB on top of that. Reclaimable without touching source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew cleanup -s
|
||||||
|
rm -rf ~/Library/Caches/Homebrew/* ~/Library/Caches/ms-playwright
|
||||||
|
pip cache purge; go clean -cache
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build and test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug # debug APK
|
||||||
|
./gradlew assembleRelease # release APK (unsigned — will not install)
|
||||||
|
./gradlew testDebugUnitTest # 84 tests, no device needed
|
||||||
|
./gradlew lintDebug
|
||||||
|
./gradlew connectedDebugAndroidTest # 46 tests, needs a device
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the **debug** APK installs directly; release is unsigned.
|
||||||
|
|
||||||
|
### Reading results without the HTML report
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
import re,glob
|
||||||
|
t=f=0
|
||||||
|
for p in glob.glob('app/build/test-results/testDebugUnitTest/*.xml'):
|
||||||
|
m=re.search(r'tests=\"(\d+)\".*?failures=\"(\d+)\".*?errors=\"(\d+)\"',open(p).read())
|
||||||
|
t+=int(m.group(1)); f+=int(m.group(2))+int(m.group(3))
|
||||||
|
print(f'{t} run, {f} failed')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Room schema export
|
||||||
|
|
||||||
|
Schemas land in `app/schemas/com.rippr.data.AppDatabase/`. **The directory name follows the
|
||||||
|
`@Database` class package** — when the class moved from `com.rippr` to `com.rippr.data`,
|
||||||
|
the export path moved with it, which initially looked like the schema had not generated at
|
||||||
|
all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Emulator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emulator -avd Medium_Phone_API_35 -no-window -no-audio -no-boot-anim \
|
||||||
|
-gpu swiftshader_indirect
|
||||||
|
adb emu kill # shut down
|
||||||
|
```
|
||||||
|
|
||||||
|
Grant permissions up front so the runtime dialog does not eat your taps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION POST_NOTIFICATIONS; do
|
||||||
|
adb shell pm grant com.rippr android.permission.$p
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feeding a synthetic ride
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb emu geo fix <lon> <lat> <altitude>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Coordinate order is longitude first.** See [TESTING.md](TESTING.md) for what this
|
||||||
|
cannot simulate — it matters more than it sounds.
|
||||||
|
|
||||||
|
### Driving the UI
|
||||||
|
|
||||||
|
The service is `exported=false`, so `adb shell am start-service` is **correctly refused**.
|
||||||
|
Drive through the UI, or from an instrumented test running in the app's own process.
|
||||||
|
|
||||||
|
Locating a control by its label:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tapText() {
|
||||||
|
adb shell uiautomator dump /sdcard/u.xml >/dev/null 2>&1
|
||||||
|
adb shell cat /sdcard/u.xml | python3 -c "
|
||||||
|
import sys,re
|
||||||
|
d=sys.stdin.read()
|
||||||
|
m=re.search(r'text=\"$1\"[^>]*bounds=\"\[(\d+),(\d+)\]\[(\d+),(\d+)\]\"', d)
|
||||||
|
print(f'{(int(m.group(1))+int(m.group(3)))//2} {(int(m.group(2))+int(m.group(4)))//2}' if m else 'NONE')
|
||||||
|
"
|
||||||
|
}
|
||||||
|
read x y <<< "$(tapText 'START RECORDING')"; adb shell input tap $x $y
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inspecting the database
|
||||||
|
|
||||||
|
`sqlite3` is **not present** on the emulator image. Pull the files instead — and take the
|
||||||
|
`-wal` too, or recent writes are missing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in rippr_db rippr_db-wal rippr_db-shm; do
|
||||||
|
adb exec-out run-as com.rippr cat databases/$f > /tmp/db/$f
|
||||||
|
done
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3; c=sqlite3.connect('/tmp/db/rippr_db')
|
||||||
|
print(c.execute('SELECT id,state,ROUND(distanceM),pointCount FROM trips').fetchall())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hard-won harness lessons
|
||||||
|
|
||||||
|
These cost real time. All were false alarms that looked like app bugs.
|
||||||
|
|
||||||
|
**Never `sleep` and assume a tap landed.** A cold start took 8.7 s once; a 4-second sleep
|
||||||
|
produced a silently-missed tap and a false "the service didn't start" conclusion. Poll
|
||||||
|
`uiautomator dump` for the expected text, then tap.
|
||||||
|
|
||||||
|
**Even a confirmed-present control can swallow a tap** right after a fresh install.
|
||||||
|
`uiautomator` reported the button present, the tap returned success, and nothing happened —
|
||||||
|
no `databases/`, no service. Repeating it moments later worked.
|
||||||
|
|
||||||
|
**Verify database state between UI steps.** Every emulator false alarm in this project came
|
||||||
|
from trusting a tap instead of checking what actually happened. Checking for the *absence
|
||||||
|
of the data directory* is what finally made one of them obvious.
|
||||||
|
|
||||||
|
**Suspiciously identical results mean a broken harness.** A constant-sweep loop returned
|
||||||
|
four results identical to six decimal places. The cause was a shell quoting bug that
|
||||||
|
corrupted the source file while the compile error hid behind `/dev/null`. Never redirect a
|
||||||
|
build to `/dev/null` inside a measurement loop.
|
||||||
|
|
||||||
|
**The notification shade stays open between runs** and will cover the app, making
|
||||||
|
`uiautomator` report quick-settings tiles instead of your UI. `adb shell cmd statusbar
|
||||||
|
collapse` first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design assets
|
||||||
|
|
||||||
|
Logos and icons are generated, not hand-drawn:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 design/gen_logos.py # SVG concepts + PNG previews (needs rsvg-convert)
|
||||||
|
python3 design/to_vector_drawable.py # → app/src/main/res/drawable/
|
||||||
|
python3 design/build_preview.py # review page
|
||||||
|
```
|
||||||
|
|
||||||
|
`design/material/` vendors Google Material Symbols (Apache-2.0) as reference geometry.
|
||||||
|
|
||||||
|
The launcher icon lives on Android's 108-unit adaptive canvas. **Content must stay inside
|
||||||
|
the centre 66-unit safe circle** — the first version drew its ring at r=43 and the launcher
|
||||||
|
mask cropped it clean off, taking the accent segment with it.
|
||||||
139
rippr-src/docs/TESTING.md
Normal file
139
rippr-src/docs/TESTING.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
What is covered, what is not, and — most importantly — **what the emulator structurally
|
||||||
|
cannot verify**. Read the last section before trusting any green build.
|
||||||
|
|
||||||
|
```
|
||||||
|
84 unit tests (JVM, no device) 46 instrumented tests (device required)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit tests — `app/src/test/`
|
||||||
|
|
||||||
|
Pure logic is kept free of Android imports specifically so it can be tested here. This is
|
||||||
|
the pattern `Telemetry.kt` established in v1 and every later module follows.
|
||||||
|
|
||||||
|
| Suite | Covers |
|
||||||
|
|---|---|
|
||||||
|
| `TelemetryTest` | Speed conversion, noise floor, accuracy gate, duration formatting, upload payload shape |
|
||||||
|
| `TelemetryUploaderTest` | Upload success, failure, retry, disabled endpoint — against `MockWebServer` and an in-memory fake DAO |
|
||||||
|
| `GeoTest` | Haversine against known references, Douglas–Peucker, bounds, degenerate cases |
|
||||||
|
| `RideStatisticsTest` | Distance, moving time, elevation hysteresis, histogram, profile |
|
||||||
|
| `AccumulatorTest` | Live accumulation, the cross-batch anchor, restore, segment boundaries |
|
||||||
|
| `RideExportTest` | GPX/GeoJSON structure parsed with real parsers, escaping, exact point counts |
|
||||||
|
|
||||||
|
### The tests that matter most
|
||||||
|
|
||||||
|
A few exist because something specific went wrong and must not return:
|
||||||
|
|
||||||
|
- **`stationary noisy altitude yields near-zero elevation gain`** — a naive implementation
|
||||||
|
reported **1498 m of climbing over a parked bike**. The bound is a regression guard, not
|
||||||
|
an accuracy claim.
|
||||||
|
- **`distance across many small batches matches one big batch`** — guards the cross-batch
|
||||||
|
anchor, whose absence under-reports distance by a few percent, invisibly.
|
||||||
|
- **`every raw point is exported with no decimation`** — catches render-side simplification
|
||||||
|
leaking into exports.
|
||||||
|
- **`geojson coordinates are longitude first`** — the classic silent error; wrong order
|
||||||
|
plots in the wrong hemisphere.
|
||||||
|
- **`a hostile trip name still produces valid xml`** — uses `Sam & Dave's <ride> "fast"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instrumented tests — `app/src/androidTest/`
|
||||||
|
|
||||||
|
| Suite | Covers |
|
||||||
|
|---|---|
|
||||||
|
| `SchemaTest` | CASCADE deletes, active-trip flow, ordering, COALESCE guards, upload backlog |
|
||||||
|
| `TripRepositoryTest` | All lifecycle transitions, idempotency, process-death simulation |
|
||||||
|
| `TrackingServiceLifecycleTest` | The real service driven through all five actions |
|
||||||
|
| `MergeTest` | Re-parenting, recomputed aggregates, rejections, atomicity |
|
||||||
|
|
||||||
|
### These tests must use in-memory databases
|
||||||
|
|
||||||
|
`TrackingServiceLifecycleTest` originally ran against the **production** `AppDatabase`
|
||||||
|
singleton and called `deleteAll()` in setUp/tearDown. Harmless on a throwaway emulator, but
|
||||||
|
it would have destroyed every recorded ride had the suite ever been run against a personal
|
||||||
|
phone. It now substitutes an in-memory database through `AppDatabase.overrideForTest()` and
|
||||||
|
`TripRepository.overrideForTest()`, restoring the singletons afterwards.
|
||||||
|
|
||||||
|
**Any new test that touches the service must do the same.**
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
Service tests poll with a 25 s timeout. This was raised from 10 s when the suite grew —
|
||||||
|
but note the flakiness that prompted it turned out to have a **real bug** behind it
|
||||||
|
(`stopSelf()` racing a queued START). Raise timeouts only after ruling out a defect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the emulator cannot verify
|
||||||
|
|
||||||
|
This is the most important section in this document.
|
||||||
|
|
||||||
|
### It reports zero velocity, always
|
||||||
|
|
||||||
|
`adb emu geo fix` teleports the device. `Location.speed` is therefore **always 0**, and
|
||||||
|
every recorded `speedKmh` is `0.0`.
|
||||||
|
|
||||||
|
Unverifiable on the emulator:
|
||||||
|
- Max speed, average moving speed
|
||||||
|
- Moving time (everything falls below the 1.5 km/h noise floor, so it stays 00:00:00)
|
||||||
|
- **Speed colouring on the map** — the path renders uniformly in the low-speed colour
|
||||||
|
|
||||||
|
A green suite says nothing about any of these.
|
||||||
|
|
||||||
|
### It hides UI consequences of that limitation
|
||||||
|
|
||||||
|
This is subtler and it bit us. v2.0 shipped with **max speed** as the headline figure on the
|
||||||
|
recording screen. On the emulator every value is zero, so a number that never moves looks
|
||||||
|
perfectly normal. On a real ride it read as a frozen, broken screen.
|
||||||
|
|
||||||
|
**When a value cannot change under test, question whether the UI around it can be judged
|
||||||
|
at all.**
|
||||||
|
|
||||||
|
### Synthetic fixtures hide scale-dependent bugs
|
||||||
|
|
||||||
|
The emulator ride fixtures were ~900 m. A real 50 m ride zoomed the map past OSM's maximum
|
||||||
|
tile zoom and rendered an empty grid. The bug was entirely deterministic and entirely
|
||||||
|
invisible to a fixture of the wrong size.
|
||||||
|
|
||||||
|
**Test short and long rides.**
|
||||||
|
|
||||||
|
### Correlated noise is not uniform noise
|
||||||
|
|
||||||
|
The elevation fixture uses uniform ±8 m random noise. Real GPS altitude error is
|
||||||
|
*correlated* — it wanders rather than jitters. The ~30 m residual drift measured against
|
||||||
|
synthetic noise may behave quite differently in practice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not covered at all
|
||||||
|
|
||||||
|
1. **No Compose UI tests** for any of the six screens. Verification was manual screenshots.
|
||||||
|
This is the largest single gap.
|
||||||
|
2. **No battery measurement** over a multi-hour ride.
|
||||||
|
3. **No map memory-leak measurement** across repeated navigation, despite the osmdroid
|
||||||
|
lifecycle being a known hazard.
|
||||||
|
4. **No rotation/state-retention testing.**
|
||||||
|
5. **No test that a GPX imports cleanly into Strava or Garmin** — structure is validated
|
||||||
|
against an XML parser, but schema validity does not guarantee a consumer accepts it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The real-ride checklist
|
||||||
|
|
||||||
|
The only way to validate the emulator's blind spots. Run it after any change to recording,
|
||||||
|
statistics, or the map.
|
||||||
|
|
||||||
|
1. Start, pocket the phone, ride, stop — matching actual usage
|
||||||
|
2. Max speed plausible against the speedometer
|
||||||
|
3. Distance plausible against the odometer
|
||||||
|
4. Moving time excludes stops
|
||||||
|
5. **Elevation gain near zero on flat ground** — the most likely silent bug
|
||||||
|
6. Path renders with no straight line across a pause
|
||||||
|
7. Speed colouring visibly varies along the path
|
||||||
|
8. **A short ride (under 100 m) still shows streets** — the v2.0 zoom bug
|
||||||
|
9. GPX opens correctly in Google Earth or Strava
|
||||||
|
10. Battery drain over a multi-hour ride is acceptable
|
||||||
|
11. Pause/resume survives a screen-off stretch
|
||||||
68
rippr-src/docs/v1/README.md
Normal file
68
rippr-src/docs/v1/README.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# v1 — the original recorder
|
||||||
|
|
||||||
|
v1 was specified as **MotoTrack**, a "bare-bones, highly resilient" recorder to capture GPS
|
||||||
|
telemetry during motorcycle group rides. Renamed to Rippr partway through.
|
||||||
|
|
||||||
|
The original brief is preserved verbatim in [original-spec.md](original-spec.md).
|
||||||
|
|
||||||
|
**Design priority, quoted from the brief:** *"Unbreakable execution over UI beauty."*
|
||||||
|
That framing drove every architectural choice and still holds.
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
A single-screen app with an un-killable foreground service writing GPS fixes to Room, plus
|
||||||
|
a REST uploader. Validated on a real ride — including max speed, which the emulator cannot
|
||||||
|
produce.
|
||||||
|
|
||||||
|
## Deviations from the spec, and why
|
||||||
|
|
||||||
|
The spec was written as complete, paste-ready code. Most of it was sound; these parts were
|
||||||
|
not, and were changed deliberately.
|
||||||
|
|
||||||
|
| Spec said | What shipped | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `super.onCreate()` in `MainActivity` | `super.onCreate(savedInstanceState)` | Would not compile |
|
||||||
|
| `kapt` for Room | **KSP** | kapt is 2–3× slower and unreliable on modern Kotlin/JDK |
|
||||||
|
| Hardcoded `material3:1.2.1` beside a Compose BOM | Version catalog + BOM | Version conflict |
|
||||||
|
| `insertPoint()` per fix from the callback | Unbounded `Channel` + batched writer | A coroutine per fix gives no back-pressure guarantee; disk latency could block GPS |
|
||||||
|
| Activity-local `isRecording` | Process-wide state (later, in v2, the database) | Lied after process death |
|
||||||
|
| 1 Hz polling of two suspend DAO queries | Room `Flow` | Push, not poll |
|
||||||
|
| No stop action on the notification | Stop action + tap-to-open | |
|
||||||
|
| Nothing countering Doze / OEM killers | Battery-optimisation exemption prompt | A multi-hour ride must survive |
|
||||||
|
| No `Theme.MotoTrack`, no launcher icon | Generated both | Referenced by the manifest but never defined |
|
||||||
|
| "Streams over HTTP/REST" in the goal, no task for it | Full uploader: `synced` column, batched POST, retry, offline-safe | Stated as a goal, so it was built |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The emulator harness built here was reused throughout v2:
|
||||||
|
|
||||||
|
- Merged manifest checked inside the APK — `foregroundServiceType=0x8` (location) confirmed
|
||||||
|
- 25 synthetic GPS fixes fed; final stored coordinate matched the fed value **exactly**
|
||||||
|
- Foreground service confirmed via `dumpsys` (`isForeground=true`, ongoing notification)
|
||||||
|
- Stop path verified: service gone, notification removed, final flush landed, 81 contiguous
|
||||||
|
point ids
|
||||||
|
|
||||||
|
**Speed was never verified in v1's automated testing** — the emulator reports zero
|
||||||
|
velocity. It was confirmed only on Dylan's first real ride.
|
||||||
|
|
||||||
|
## What v1 lacked
|
||||||
|
|
||||||
|
Feedback after that ride, which became the v2 brief:
|
||||||
|
|
||||||
|
> "app is simple and clean. I like that but it's missing stuff. There is no reset or pause
|
||||||
|
> button, there is no concept of a trip, it captures speed data but not path data."
|
||||||
|
|
||||||
|
Three of those four were the same missing concept — no `Trip` boundary. The fourth was a
|
||||||
|
misconception worth recording: **path data was already being captured.** Every point stored
|
||||||
|
latitude, longitude, altitude and bearing at 1–2 Hz from day one. What was missing was a
|
||||||
|
map to draw it on.
|
||||||
|
|
||||||
|
## Icon work
|
||||||
|
|
||||||
|
The launcher icon was designed in this phase. Five motorcycle-themed concepts were
|
||||||
|
generated locally as SVG, using Google Material Symbols (Apache-2.0) as reference geometry;
|
||||||
|
Dylan picked **Route** — a switchback trace inside a ring.
|
||||||
|
|
||||||
|
Lesson from that round: the first attempt hand-authored bezier paths without ever rendering
|
||||||
|
them, and they were poor. Building a rasterise-and-look loop (`rsvg-convert`) changed the
|
||||||
|
output quality completely. Generators live in `design/`.
|
||||||
91
rippr-src/docs/v1/original-spec.md
Normal file
91
rippr-src/docs/v1/original-spec.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Original v1 brief (verbatim)
|
||||||
|
|
||||||
|
Preserved as written, before any of it was implemented or corrected. See
|
||||||
|
[README.md](README.md) for which parts were changed and why — several would not have
|
||||||
|
compiled or would have dropped GPS fixes under load.
|
||||||
|
|
||||||
|
The project was called **MotoTrack** at this point, with package `com.example.mototrack`.
|
||||||
|
Both were renamed to Rippr / `com.rippr` during development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Specification: MotoTrack Single-Session Android Recorder
|
||||||
|
|
||||||
|
**Goal:** Build a bare-bones, highly resilient Native Android app (Kotlin) to record GPS
|
||||||
|
telemetry during motorcycle group rides today.
|
||||||
|
|
||||||
|
**Design Priority:** Unbreakable execution over UI beauty. Must run as an un-killable
|
||||||
|
**Foreground Service** that records telemetry to a local SQLite database and streams
|
||||||
|
updates over HTTP/REST when online.
|
||||||
|
|
||||||
|
### 1. Setup & Installation
|
||||||
|
|
||||||
|
Prerequisites: Android Studio (Koala/Ladybug+), a physical Android phone on 8.0+ (API 26+)
|
||||||
|
with USB debugging on, and a cable.
|
||||||
|
|
||||||
|
Project setup: New Project → Empty Activity (Jetpack Compose); Name `MotoTrack`; package
|
||||||
|
`com.example.mototrack`; Minimum SDK API 26; Kotlin.
|
||||||
|
|
||||||
|
Dependencies specified:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
id("kotlin-kapt")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("com.google.android.gms:play-services-location:21.2.0")
|
||||||
|
val roomVersion = "2.6.1"
|
||||||
|
implementation("androidx.room:room-runtime:$roomVersion")
|
||||||
|
implementation("androidx.room:room-ktx:$roomVersion")
|
||||||
|
kapt("androidx.room:room-compiler:$roomVersion")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
|
||||||
|
implementation("androidx.compose.material3:material3:1.2.1")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Task breakdown
|
||||||
|
|
||||||
|
**Task 1 — Manifest & permissions.** `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`,
|
||||||
|
`FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_LOCATION`, `POST_NOTIFICATIONS`, `INTERNET`;
|
||||||
|
`MainActivity` exported with a LAUNCHER intent filter; `TrackingService` declared with
|
||||||
|
`android:foregroundServiceType="location"`.
|
||||||
|
|
||||||
|
**Task 2 — Local storage (Room).** `TrackPoint` entity with `id`, `timestamp`, `latitude`,
|
||||||
|
`longitude`, `speedKmh`, `altitudeM`. `TrackPointDao` with `insertPoint`, `getAllPoints`,
|
||||||
|
`getMaxSpeed`, `getPointCount`. `AppDatabase` at version 1 with a `@Volatile` singleton.
|
||||||
|
|
||||||
|
**Task 3 — Un-killable foreground service.** `TrackingService` holding a
|
||||||
|
`FusedLocationProviderClient`, a `CoroutineScope(Dispatchers.IO + SupervisorJob())`, and a
|
||||||
|
`LocationCallback` that converts `loc.speed * 3.6f` to km/h and launches a coroutine per
|
||||||
|
fix to `insertPoint`. `startForeground` with `FOREGROUND_SERVICE_TYPE_LOCATION` on Q+,
|
||||||
|
`START_STICKY`, a `LocationRequest` at `PRIORITY_HIGH_ACCURACY` / 1000 ms with a 500 ms
|
||||||
|
minimum interval, and an `IMPORTANCE_LOW` notification channel.
|
||||||
|
|
||||||
|
**Task 4 — Simple UI & controller.** `MainActivity` with `mutableStateOf` fields for
|
||||||
|
`isRecording`, `maxSpeed` and `pointCount`; a `RequestMultiplePermissions` launcher; a
|
||||||
|
`LaunchedEffect` polling `getMaxSpeed()` and `getPointCount()` every second; and a single
|
||||||
|
button toggling between START and STOP RECORDING.
|
||||||
|
|
||||||
|
### 3. Verification & deployment
|
||||||
|
|
||||||
|
1. Attach device, click Run 'app' in Android Studio
|
||||||
|
2. Grant location and notification permissions
|
||||||
|
3. Tap START RECORDING; confirm the persistent notification appears
|
||||||
|
4. Lock the screen and take a 1-minute test walk/drive
|
||||||
|
5. Unlock: verify Max Speed and Points Captured are updating
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notable in hindsight
|
||||||
|
|
||||||
|
- **"Streams updates over HTTP/REST"** appears in the goal but no task defines it. The
|
||||||
|
uploader was built anyway, since it was clearly intended.
|
||||||
|
- **The 1 Hz polling loop** in Task 4 was replaced by a Room `Flow`.
|
||||||
|
- **A coroutine launched per GPS fix** (Task 3) offers no back-pressure guarantee; this
|
||||||
|
became the unbounded `Channel` and single batched writer that still underpins recording.
|
||||||
|
- **`super.onCreate()`** in Task 4 is missing its argument and would not compile.
|
||||||
|
- The verification steps assume Android Studio. Everything ended up driven from the CLI
|
||||||
|
instead — see [../DEVELOPMENT.md](../DEVELOPMENT.md).
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Rippr v2 — Task Index
|
# Rippr v2 — Task Index
|
||||||
|
|
||||||
|
> Project overview: [../../README.md](../../README.md) ·
|
||||||
|
> Architecture: [../ARCHITECTURE.md](../ARCHITECTURE.md) ·
|
||||||
|
> v1 history: [../v1/](../v1/) · Next: [../v3/BACKLOG.md](../v3/BACKLOG.md)
|
||||||
|
|
||||||
Trips, path rendering, and export. Full rationale lives in the plan; this directory
|
Trips, path rendering, and export. Full rationale lives in the plan; this directory
|
||||||
holds one document per task, each self-contained enough to implement from.
|
holds one document per task, each self-contained enough to implement from.
|
||||||
|
|
||||||
|
|||||||
131
rippr-src/docs/v3/BACKLOG.md
Normal file
131
rippr-src/docs/v3/BACKLOG.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# v3 backlog
|
||||||
|
|
||||||
|
Everything known-outstanding as of v2.0.1, with enough context to pick up cold.
|
||||||
|
Nothing here is committed to — it is a menu, roughly ordered by value.
|
||||||
|
|
||||||
|
**Before planning anything: run the real-ride checklist in
|
||||||
|
[../TESTING.md](../TESTING.md).** Several items below may turn out to be non-issues, and
|
||||||
|
others may appear that nobody has thought of.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Carried over from v2 — the honest debt
|
||||||
|
|
||||||
|
### Elevation gain accuracy · *needs real data first*
|
||||||
|
|
||||||
|
~30 m of phantom gain per ten stationary minutes against synthetic ±8 m uniform noise. Real
|
||||||
|
GPS altitude error is *correlated* rather than uniform, so the true behaviour is unknown.
|
||||||
|
|
||||||
|
The current implementation is a 15-sample moving average plus reversal hysteresis (see
|
||||||
|
[../ARCHITECTURE.md](../ARCHITECTURE.md)). A naive version reported 1498 m over a parked
|
||||||
|
bike, so the guard rails matter.
|
||||||
|
|
||||||
|
**Do not tune this blind.** Record a flat ride, check whether the reported gain is
|
||||||
|
plausible, and only then adjust. If it needs work, options are a longer smoothing window, a
|
||||||
|
larger threshold, or using barometric pressure where available (much more accurate than GPS
|
||||||
|
altitude, and most phones have the sensor).
|
||||||
|
|
||||||
|
### Compose UI tests · *the largest coverage gap*
|
||||||
|
|
||||||
|
Zero UI tests across six screens. Everything was verified by manual screenshot. Worth
|
||||||
|
covering: navigation record→trips→detail→back, rotation/state retention, empty states,
|
||||||
|
`NotFound`, chart degradation below two points, selection mode enabling Merge only at two.
|
||||||
|
|
||||||
|
### Unmeasured, and probably should be
|
||||||
|
|
||||||
|
- **Battery drain** over a multi-hour ride — never measured, and it is the thing most likely
|
||||||
|
to make the app unusable in practice
|
||||||
|
- **Map memory across repeated navigation** — the osmdroid lifecycle is a known hazard and
|
||||||
|
the wiring was never leak-tested
|
||||||
|
- **GPX import into Strava/Garmin** — validated against an XML parser, but schema validity
|
||||||
|
does not guarantee a consumer accepts it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The original v3 candidate — live group ride view
|
||||||
|
|
||||||
|
Deferred from v2 as "needs real server work". This was in the **v1** brief's goal
|
||||||
|
statement, so it has been the intended destination all along.
|
||||||
|
|
||||||
|
Already in place:
|
||||||
|
- `TelemetryUploader` — batched POST, retry, offline-safe, cannot stall recording
|
||||||
|
- `synced` column and backlog semantics
|
||||||
|
- `trip_id` / `segment_id` per point, so a server can reconstruct rides and pauses
|
||||||
|
- `Config.deviceId` — stable per-install id to distinguish riders
|
||||||
|
|
||||||
|
Missing:
|
||||||
|
- **A server.** Nothing exists. This is the actual work.
|
||||||
|
- **UI for the endpoint** — currently only reachable via `Config.setUploadEndpoint()`
|
||||||
|
- Other riders' positions on a map, and a live map at all (see below)
|
||||||
|
- Auth, rider identity, group membership
|
||||||
|
|
||||||
|
**Worth deciding early:** this is the point where Rippr stops being a local-only app. That
|
||||||
|
brings hosting, privacy, and location-sharing consent into scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Live map — explicitly deferred, revisit deliberately
|
||||||
|
|
||||||
|
v2 has **no live map by design**, on Dylan's reasoning:
|
||||||
|
|
||||||
|
> "we hit start, put the phone in our pocket and then stop it after the ride. So having a
|
||||||
|
> live map doesn't make sense at all honestly, we just wanna see the path render after."
|
||||||
|
|
||||||
|
`TrackingService` holds no reference to any map type, and the map exists only inside the
|
||||||
|
detail screen's Compose lifecycle. That boundary is deliberate and worth preserving unless
|
||||||
|
there is a real reason to cross it.
|
||||||
|
|
||||||
|
**Group riding is the one plausible reason** — seeing where the others are while stopped at
|
||||||
|
a junction. If it happens, keep it screen-on-only and never let the service touch it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Smaller items
|
||||||
|
|
||||||
|
| Item | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **Trip splitting** | Merge exists; split does not. The natural counterpart. |
|
||||||
|
| **SAF export** | Dropped in T16 as unnecessary — share sheet covers it. Add if a real need appears. |
|
||||||
|
| **Auto-pause** | Detect a stop and pause automatically. Rejected in v2 as unreliable in traffic; revisit only with real ride data showing it would help. |
|
||||||
|
| **Distance units** | Metric only, hardcoded. Trivial to add a preference. |
|
||||||
|
| **Settings screen** | None exists. `Config` has endpoint, deviceId, mapEnabled — the map toggle currently lives on trip detail because one switch did not justify a screen. |
|
||||||
|
| **Offline tile pre-download** | osmdroid caches what it renders; a mountain ride with no signal shows blank tiles. Respect OSM's usage policy — no bulk prefetch of their public servers. |
|
||||||
|
| **Notification live stats** | Show distance/duration in the ongoing notification, readable without unlocking. |
|
||||||
|
| **Crash reporting** | None. A recorder that dies mid-ride currently leaves no trace beyond logcat. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Things that must not regress
|
||||||
|
|
||||||
|
Hard-won and easy to undo by accident. Each has a comment in the code explaining why.
|
||||||
|
|
||||||
|
1. **The unbounded `Channel` + single batched writer.** Do not write to the database from
|
||||||
|
the location callback.
|
||||||
|
2. **Points stamped with `tripId`/`segmentId` at creation.** Refactoring this into a
|
||||||
|
write-time lookup breaks the pause guarantee silently.
|
||||||
|
3. **Recording state derived from the database.** Never reintroduce an in-memory flag.
|
||||||
|
4. **`fallbackToDestructiveMigration()` stays removed.** Any schema change ships a
|
||||||
|
`Migration` against `app/schemas/com.rippr.data.AppDatabase/2.json`.
|
||||||
|
5. **Decimation is render-only.** It must never reach storage or export.
|
||||||
|
6. **`RipprTheme`'s `Surface`.** It sets `LocalContentColor`; without it, text without an
|
||||||
|
explicit colour renders black-on-black and disappears.
|
||||||
|
7. **The map zoom clamp.** `zoomToBoundingBox` ignores `maxZoomLevel`; a short ride will
|
||||||
|
render an empty grid without it.
|
||||||
|
8. **Instrumented tests use in-memory databases.** One previously wiped the real device
|
||||||
|
database in `setUp`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Reading order for picking this up cold
|
||||||
|
|
||||||
|
1. [../../README.md](../../README.md) — what the app is and its current state
|
||||||
|
2. [../ARCHITECTURE.md](../ARCHITECTURE.md) — why it is built this way
|
||||||
|
3. [../v2/PROGRESS.md](../v2/PROGRESS.md) — every bug found during v2 and how
|
||||||
|
4. [../TESTING.md](../TESTING.md) — **especially "What the emulator cannot verify"**
|
||||||
|
5. [../DEVELOPMENT.md](../DEVELOPMENT.md) — when you actually need to build something
|
||||||
|
|
||||||
|
The v2 planning approach worked well and is worth repeating: one document per task with
|
||||||
|
goal, context, design, acceptance criteria and risks, written *before* implementing, plus a
|
||||||
|
running progress log recording what actually went wrong. Several bugs were caught precisely
|
||||||
|
because the risk had been written down first — and one (the osmdroid lifecycle) was written
|
||||||
|
down and then walked into anyway, which is its own lesson.
|
||||||
BIN
rippr-src/rippr-full-history.bundle
Normal file
BIN
rippr-src/rippr-full-history.bundle
Normal file
Binary file not shown.
Reference in New Issue
Block a user