diff --git a/rippr-full-history.bundle b/rippr-full-history.bundle index 5f9f6c9..0fcdba3 100644 Binary files a/rippr-full-history.bundle and b/rippr-full-history.bundle differ diff --git a/rippr-src/README.md b/rippr-src/README.md new file mode 100644 index 0000000..7f5e088 --- /dev/null +++ b/rippr-src/README.md @@ -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 ``. + +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`. diff --git a/rippr-src/SNAPSHOT.md b/rippr-src/SNAPSHOT.md deleted file mode 100644 index 811f670..0000000 --- a/rippr-src/SNAPSHOT.md +++ /dev/null @@ -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 diff --git a/rippr-src/docs/ARCHITECTURE.md b/rippr-src/docs/ARCHITECTURE.md new file mode 100644 index 0000000..da364a3 --- /dev/null +++ b/rippr-src/docs/ARCHITECTURE.md @@ -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 `` 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 `` per segment, speed in `` 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 ` 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. diff --git a/rippr-src/docs/DEVELOPMENT.md b/rippr-src/docs/DEVELOPMENT.md new file mode 100644 index 0000000..717e61b --- /dev/null +++ b/rippr-src/docs/DEVELOPMENT.md @@ -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 +``` + +**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. diff --git a/rippr-src/docs/TESTING.md b/rippr-src/docs/TESTING.md new file mode 100644 index 0000000..1f8571e --- /dev/null +++ b/rippr-src/docs/TESTING.md @@ -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 "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 diff --git a/rippr-src/docs/v1/README.md b/rippr-src/docs/v1/README.md new file mode 100644 index 0000000..34abe2a --- /dev/null +++ b/rippr-src/docs/v1/README.md @@ -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/`. diff --git a/rippr-src/docs/v1/original-spec.md b/rippr-src/docs/v1/original-spec.md new file mode 100644 index 0000000..3d3b0be --- /dev/null +++ b/rippr-src/docs/v1/original-spec.md @@ -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). diff --git a/rippr-src/docs/v2/README.md b/rippr-src/docs/v2/README.md index e3559ba..9d1edb8 100644 --- a/rippr-src/docs/v2/README.md +++ b/rippr-src/docs/v2/README.md @@ -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. diff --git a/rippr-src/docs/v3/BACKLOG.md b/rippr-src/docs/v3/BACKLOG.md new file mode 100644 index 0000000..23d55d9 --- /dev/null +++ b/rippr-src/docs/v3/BACKLOG.md @@ -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. diff --git a/rippr-src/rippr-full-history.bundle b/rippr-src/rippr-full-history.bundle new file mode 100644 index 0000000..5f9f6c9 Binary files /dev/null and b/rippr-src/rippr-full-history.bundle differ