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