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:
2026-08-11 08:44:48 -05:00
parent 280fd7f988
commit 247b9cdb3f
11 changed files with 1025 additions and 44 deletions

116
rippr-src/README.md Normal file
View 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`.

View File

@@ -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

View 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.

View 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
View 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

View 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/`.

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

View File

@@ -1,5 +1,9 @@
# 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
holds one document per task, each self-contained enough to implement from.

View 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.

Binary file not shown.