Compare commits

..

6 Commits

Author SHA1 Message Date
43bb7f4fa1 Add RIPPR.md explaining the source snapshot and history bundle
Lives at the repo root rather than inside rippr-src/, because refreshing
the snapshot deletes and re-exports that directory wholesale - which
already ate an earlier copy of this note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:45:12 -05:00
247b9cdb3f 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>
2026-08-11 08:44:48 -05:00
280fd7f988 Add Rippr source snapshot and full-history bundle
Two copies for two jobs. rippr-src/ is a browsable git archive export of
the tracked tree at 2f76983 - no build outputs, no local.properties, no
nested .git - which is convenient to read in gitea but carries no history
and will drift.

rippr-full-history.bundle is the real backup: all 18 commits, verified as
"records a complete history" and test-cloned before committing. This
matters because ~/dojo/rippr has no git remote and otherwise exists only
on one machine.

rippr-src/SNAPSHOT.md explains the difference and how to restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:30:25 -05:00
4debdfa518 Add tunie/docs (full context + open-source roadmap); refresh README
docs/CONTEXT.md captures the entire reverse-engineering effort in one place:
KWP2000 protocol, AES-128 seed/key, the map format (dc decrypt -> flat-ROM
unpack -> directory -> fe table pointers), the fuel/ignition table map, the
validated SAI/O2 device flags, checksums, hardware, and a per-finding confidence
table.

docs/ROADMAP.md covers open-sourcing (legal/IP posture on proprietary maps and
the AES keys, repo hygiene, packaging/CI, community) and the technical path to
tuning the bike (first contact -> ROM dump -> calibration model -> write path ->
editor), with immediate next steps and a risk register.

Top-level README refreshed to describe the whole project (tool + viewer +
research) and point at docs/.
2026-08-11 08:21:16 -05:00
80c9373ded Mark device-flag confidence honestly (SAI confirmed, O2 probable)
The NO-SAI-NO-O2 reference map bundles SAI + O2 + airbox deletes, so it validates
the three flag bytes as a GROUP but doesn't isolate O2 individually; no single-mod
map exists and the x.a() device-layout branch for this ECU is too nested to trace.

- SAI @ 0x53801: confirmed (code lc() -> Devices[0] via fe[33], + the delete map).
- O2 @ 0x53818/0x53819: probable (2 O2 sensors declared off <-> 2 adjacent bytes
  cleared; 865 has no air-flap so airbox removal is fuel-only).

Viewer now shows confirmed/probable badges and a warning that flags alone are not
a tune: O2 delete forces open-loop and needs fuel enrichment, so prefer flashing a
complete matching delete map over hand-toggling a stock one. See DEVICES.md.
2026-08-11 08:16:22 -05:00
eaa804fc35 Add validated SAI/O2 device-flag checkbox editor
Reversed the device-flag mechanism (l.java lc() + ee bit logic) and validated the
flag locations against a real reference map: 20188Map2009AIRBOXBONNY (explicitly
"NO SAI, NO O2 SENSORS"). Diffing its flat ROM vs stock 20188 isolated exactly
three byte-boolean flags that flip 1->0:

  0x53801  SAI            (= base + fe[33] + 0x00)
  0x53818  O2 sensor      (+ 0x17)
  0x53819  O2 sensor (2)  (+ 0x18)

1 = enabled, 0 = disabled. Neither stock map could reveal these (both have SAI+O2
on); the delete map was the key. See research/reference-maps/DEVICES.md.

Viewer: the Triumph-tables tab now has a Device flags panel — checkboxes reflect
the loaded map's real state (stock: all on; delete map: all off), toggling flips
the byte, and "Export edited .hex" re-encodes the distribution format. The
decode->edit->encode round-trip is byte-exact (verified). The caXX header bytes
are map-ID metadata, not a cal checksum; the ECU-flash checksum is applied at
write time.
2026-08-11 08:04:13 -05:00
122 changed files with 12039 additions and 150 deletions

54
RIPPR.md Normal file
View File

@@ -0,0 +1,54 @@
# Rippr — copies held here
Rippr is a native Android GPS ride recorder for motorcycles. Its canonical repo is
`~/dojo/rippr`, which **has no git remote** — it exists only on that machine, so the
bundle below is the only off-machine copy of its history.
**Snapshot taken at:** `46a0726` — "Document the whole project: README, architecture, v1 history, v3 backlog" (19 commits)
## What is here
| File | Purpose |
|---|---|
| `rippr-src/` | Browsable source, exported with `git archive HEAD`. No history; will drift. |
| `rippr-full-history.bundle` | Full git history. The actual backup. |
| `rippr-2.0.1-debug.apk` | Latest installable build (debug-signed) |
| `rippr-2.0-debug.apk`, `rippr-1.0-debug.apk` | Previous builds |
`rippr-src/` contains tracked files only — no build outputs, no `local.properties`, no
nested `.git`.
## Restoring the full repo
```bash
git clone rippr-full-history.bundle rippr
cd rippr
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
./gradlew assembleDebug
```
Needs **JDK 17–21** — AGP does not support 25 — and the Android SDK with platform 35 and
build-tools 35.
## Where to start reading
Inside `rippr-src/`:
- `README.md` — what the app is and its current state
- `docs/ARCHITECTURE.md` — the design decisions and why
- `docs/v3/BACKLOG.md` — known gaps, deferred work, what must not regress
- `docs/TESTING.md` — especially "What the emulator cannot verify"
- `docs/v2/PROGRESS.md` — every bug found during v2 development
## Keeping these current
```bash
cd ~/dojo/samplez
rm -rf rippr-src && mkdir rippr-src
git -C ~/dojo/rippr archive HEAD | tar -x -C rippr-src
(cd ~/dojo/rippr && git bundle create /tmp/rb.bundle --all) && mv /tmp/rb.bundle rippr-full-history.bundle
git bundle verify rippr-full-history.bundle
```
Note the export wipes `rippr-src/` wholesale, which is why this file lives at the repo
root rather than inside it.

BIN
rippr-full-history.bundle Normal file

Binary file not shown.

13
rippr-src/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
*.iml
.gradle/
local.properties
.idea/
.DS_Store
build/
captures/
.externalNativeBuild
.cxx
.kotlin/
__pycache__/
*.pyc
design/preview/

116
rippr-src/README.md Normal file
View File

@@ -0,0 +1,116 @@
# Rippr
A native Android GPS ride recorder for motorcycles. Records telemetry from an
un-killable foreground service into a local SQLite database, renders the traversed path
on a map afterwards, and exports to GPX/GeoJSON.
Built for one specific use: **hit start, put the phone in a pocket, ride, hit stop.**
Every design decision below follows from that.
```
Package com.rippr minSdk 26 (Android 8.0) targetSdk 35
Kotlin 2.0.21 AGP 8.7.3 Gradle 8.11.1
~5,800 lines Kotlin 84 unit tests 46 instrumented tests
```
## Current state — v2.0.1
| Feature | Status |
|---|---|
| GPS recording via foreground service | Working, validated on real rides |
| Trips with pause/resume/stop/discard | Working |
| Live speed + elapsed clock | Working (fixed in 2.0.1) |
| Path rendered on OpenStreetMap | Working (fixed in 2.0.1) |
| Ride statistics + charts | Working; elevation gain has known drift |
| Rename / delete / merge rides | Working |
| GPX + GeoJSON export | Working, validated against an external parser |
| Upload to a REST endpoint | Implemented, **no UI** — see below |
| Live map during recording | Deliberately absent — see below |
| Group ride view | Deferred to v3 |
## Quick start
Requires **JDK 17–21** (AGP does not support 25) and the Android SDK with platform 35 and
build-tools 35. Full setup in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
```bash
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
./gradlew assembleDebug testDebugUnitTest lintDebug
./gradlew connectedDebugAndroidTest # needs a device or emulator
```
## Architecture in one page
```
TrackingService (foreground)
└─ FusedLocation callback ──trySend──► unbounded Channel
│
single writer coroutine (batches of 25, ~2s)
│
┌───────────────────┴────────────────────┐
▼ ▼
Room database Accumulator
Trip → Segment → TrackPoint distance / moving time /
│ elevation, folded per batch
▼
TripRepository (all lifecycle transitions)
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
RecordScreen TripsScreen TripDetailScreen
└─ RideMap (osmdroid)
```
Three decisions carry most of the weight:
**The location callback never blocks.** Fixes go into an unbounded `Channel`; a single
writer coroutine drains it in batches. Slow disk stalls the writer, never GPS, and no fix
is dropped under back pressure.
**Recording state lives in the database, not memory.** A row in `trips` with
`endedAt IS NULL` *is* the fact of an in-progress ride. It survives process death, which
an in-memory flag cannot — `START_STICKY` restarts the service with a null intent and a
flag would come back `false` mid-ride.
**Segments make pause correct rather than cosmetic.** Without them, pausing at a gas
station and resuming across town draws a straight line through terrain never ridden, and
counts it as distance. Segments propagate all the way through: distance accumulation, map
polylines, and GPX `<trkseg>`.
Full reasoning in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Two deliberate absences
**No live map while recording.** The phone is in a pocket; nobody is looking at it. A live
map would burn battery on top of GPS and a wake lock for nothing. The map is a post-ride
artifact, gated behind a toggle, and `TrackingService` holds no reference to any map type.
**No UI for the upload endpoint.** The REST uploader works and is tested, but is only
reachable via `Config.setUploadEndpoint()`. It has no server to talk to yet; the UI arrives
when group-ride streaming does.
## Documentation
| Document | Contents |
|---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Durable design decisions and the reasoning behind them |
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Toolchain setup, build/test commands, emulator harness |
| [docs/TESTING.md](docs/TESTING.md) | What is covered, what is not, and what the emulator cannot verify |
| [docs/v1/](docs/v1/) | Original spec, what shipped, and how it deviated |
| [docs/v2/](docs/v2/) | 18-task plan, per-task docs, and the progress log with every bug found |
| [docs/v3/BACKLOG.md](docs/v3/BACKLOG.md) | Known gaps, deferred work, and ideas for next |
**Start here for v3:** [docs/v3/BACKLOG.md](docs/v3/BACKLOG.md), then
[docs/v2/PROGRESS.md](docs/v2/PROGRESS.md) for the bugs that were found and why.
## Known gaps
1. **Elevation gain drifts** ~30 m per ten stationary minutes against synthetic noise.
Real GPS error is correlated rather than uniform, so the true figure needs a real ride.
Flat ground should read near zero.
2. **No Compose UI tests** for any of the six screens. Verification was manual.
3. **Speed colouring on the map is unvalidated** — the emulator reports zero velocity, so
the path renders uniformly there.
4. **Migrations are now mandatory.** `fallbackToDestructiveMigration()` was removed in v2;
any schema change must ship a `Migration` against
`app/schemas/com.rippr.data.AppDatabase/2.json`.

View File

@@ -0,0 +1,112 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
namespace = "com.rippr"
compileSdk = 35
defaultConfig {
applicationId = "com.rippr"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
// Ride recording is the whole point; keep the debug build usable in the field.
isDebuggable = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
testOptions {
unitTests {
// android.util.Log is an unimplemented stub in the JVM test runtime and
// throws on call. The uploader logs on its failure paths, so without this
// the error-handling tests fail for the wrong reason.
isReturnDefaultValues = true
}
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
constraints {
// play-services drags in androidx.fragment 1.1.0 transitively. Nothing here
// uses Fragments, but that version predates the ActivityResult contract fixes
// and lint rejects it. Force a modern one rather than suppressing the check.
implementation(libs.androidx.fragment) {
because("ActivityResult APIs require fragment >= 1.3.0")
}
}
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Map rendering (trip detail only — never referenced by TrackingService).
implementation(libs.osmdroid.android)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.play.services.location)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.okhttp)
testImplementation(libs.junit)
testImplementation(libs.json)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
}

0
rippr-src/app/proguard-rules.pro vendored Normal file
View File

View File

@@ -0,0 +1,285 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "44319b598198c5ba41e78c9b5e9da907",
"entities": [
{
"tableName": "trips",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, `name` TEXT, `state` TEXT NOT NULL, `distanceM` REAL NOT NULL DEFAULT 0, `movingMillis` INTEGER NOT NULL DEFAULT 0, `maxSpeedKmh` REAL NOT NULL DEFAULT 0, `elevationGainM` REAL NOT NULL DEFAULT 0, `pointCount` INTEGER NOT NULL DEFAULT 0)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "distanceM",
"columnName": "distanceM",
"affinity": "REAL",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "movingMillis",
"columnName": "movingMillis",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "maxSpeedKmh",
"columnName": "maxSpeedKmh",
"affinity": "REAL",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "elevationGainM",
"columnName": "elevationGainM",
"affinity": "REAL",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "pointCount",
"columnName": "pointCount",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "segments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tripId` INTEGER NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, FOREIGN KEY(`tripId`) REFERENCES `trips`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tripId",
"columnName": "tripId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_segments_tripId",
"unique": false,
"columnNames": [
"tripId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_segments_tripId` ON `${TABLE_NAME}` (`tripId`)"
}
],
"foreignKeys": [
{
"table": "trips",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"tripId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "track_points",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tripId` INTEGER NOT NULL, `segmentId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `speedKmh` REAL NOT NULL, `altitudeM` REAL NOT NULL, `accuracyM` REAL NOT NULL, `bearingDeg` REAL NOT NULL, `synced` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`tripId`) REFERENCES `trips`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`segmentId`) REFERENCES `segments`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tripId",
"columnName": "tripId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "segmentId",
"columnName": "segmentId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "latitude",
"columnName": "latitude",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "longitude",
"columnName": "longitude",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "speedKmh",
"columnName": "speedKmh",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "altitudeM",
"columnName": "altitudeM",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "accuracyM",
"columnName": "accuracyM",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "bearingDeg",
"columnName": "bearingDeg",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "synced",
"columnName": "synced",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_track_points_tripId",
"unique": false,
"columnNames": [
"tripId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_track_points_tripId` ON `${TABLE_NAME}` (`tripId`)"
},
{
"name": "index_track_points_segmentId",
"unique": false,
"columnNames": [
"segmentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_track_points_segmentId` ON `${TABLE_NAME}` (`segmentId`)"
},
{
"name": "index_track_points_synced",
"unique": false,
"columnNames": [
"synced"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_track_points_synced` ON `${TABLE_NAME}` (`synced`)"
}
],
"foreignKeys": [
{
"table": "trips",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"tripId"
],
"referencedColumns": [
"id"
]
},
{
"table": "segments",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"segmentId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '44319b598198c5ba41e78c9b5e9da907')"
]
}
}

View File

@@ -0,0 +1,243 @@
package com.rippr
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import com.rippr.data.AppDatabase
import com.rippr.data.TripRepository
import com.rippr.data.TripState
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Drives the real foreground service through its five actions and asserts the trip and
* segment structure that results.
*
* The service is `exported=false`, so `adb shell am start-service` is correctly refused —
* this is the only way to exercise the lifecycle without tapping notification actions.
* No GPS is injected, so no points are recorded; what is under test here is the segment
* bookkeeping, which is what T06 changed.
*/
@RunWith(AndroidJUnit4::class)
class TrackingServiceLifecycleTest {
@get:Rule
val permissions: GrantPermissionRule = GrantPermissionRule.grant(
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
)
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
private lateinit var db: AppDatabase
private lateinit var repo: TripRepository
@Before
fun setUp() {
// Substitute an in-memory database for the singleton the service resolves.
//
// This test previously ran against the *real* app database and wiped it in
// setUp/tearDown. Harmless on a throwaway emulator, but running the suite
// against a personal phone would have destroyed every recorded ride.
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
.build()
AppDatabase.overrideForTest(db)
repo = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
TripRepository.overrideForTest(repo)
}
@After
fun tearDown() {
send(TrackingService.ACTION_STOP)
awaitOrNull(timeoutMs = 15_000) { repo.activeTrip() == null }
// Let the service finish tearing down before the next test starts one, so a
// stale instance cannot service the next START.
Thread.sleep(500)
db.close()
// Restore the real singletons so no other test inherits the in-memory database.
AppDatabase.overrideForTest(null)
TripRepository.overrideForTest(null)
}
private fun send(action: String) {
val intent = Intent(context, TrackingService::class.java).setAction(action)
if (action == TrackingService.ACTION_START) {
ContextCompat.startForegroundService(context, intent)
} else {
context.startService(intent)
}
}
/** The service works asynchronously; poll rather than guessing at a sleep. */
private fun await(timeoutMs: Long = 25_000, condition: suspend () -> Boolean) {
assertTrue(
"condition not met within ${timeoutMs}ms",
awaitOrNull(timeoutMs, condition),
)
}
private fun awaitOrNull(timeoutMs: Long, condition: suspend () -> Boolean): Boolean =
runBlocking {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
if (condition()) return@runBlocking true
Thread.sleep(200)
}
false
}
private suspend fun openSegments(tripId: Long) =
db.segmentDao().forTrip(tripId).count { it.isOpen }
@Test
fun startOpensATripAndASegment() {
send(TrackingService.ACTION_START)
await { repo.activeTrip()?.state == TripState.RECORDING }
runBlocking {
val trip = repo.activeTrip()!!
assertNull("an active trip has no end time", trip.endedAt)
assertEquals(1, db.segmentDao().countForTrip(trip.id))
assertEquals(1, openSegments(trip.id))
}
}
@Test
fun pauseClosesTheSegmentButKeepsTheTripActive() {
send(TrackingService.ACTION_START)
await { repo.activeTrip()?.state == TripState.RECORDING }
send(TrackingService.ACTION_PAUSE)
await { repo.activeTrip()?.state == TripState.PAUSED }
runBlocking {
val trip = repo.activeTrip()!!
assertNull("pause must not end the trip", trip.endedAt)
assertEquals("no segment may stay open while paused", 0, openSegments(trip.id))
assertEquals(1, db.segmentDao().countForTrip(trip.id))
}
}
@Test
fun resumeOpensASecondSegmentSoThePauseLeavesAGap() {
send(TrackingService.ACTION_START)
await { repo.activeTrip()?.state == TripState.RECORDING }
send(TrackingService.ACTION_PAUSE)
await { repo.activeTrip()?.state == TripState.PAUSED }
send(TrackingService.ACTION_RESUME)
await { repo.activeTrip()?.state == TripState.RECORDING }
runBlocking {
val trip = repo.activeTrip()!!
assertEquals("resume must not reuse the closed segment", 2, db.segmentDao().countForTrip(trip.id))
assertEquals(1, openSegments(trip.id))
}
}
@Test
fun repeatedPauseDoesNotAccumulateSegments() {
send(TrackingService.ACTION_START)
await { repo.activeTrip()?.state == TripState.RECORDING }
repeat(3) {
send(TrackingService.ACTION_PAUSE)
await { repo.activeTrip()?.state == TripState.PAUSED }
}
runBlocking {
assertEquals(1, db.segmentDao().countForTrip(repo.activeTrip()!!.id))
}
}
@Test
fun stopClosesTheTripAndEverySegment() {
send(TrackingService.ACTION_START)
await { repo.activeTrip()?.state == TripState.RECORDING }
val tripId = runBlocking { repo.activeTrip()!!.id }
runBlocking { seedPoint(tripId) }
send(TrackingService.ACTION_STOP)
await { repo.activeTrip() == null }
runBlocking {
val trip = db.tripDao().getById(tripId)!!
assertEquals(TripState.COMPLETED, trip.state)
assertNotNull(trip.endedAt)
assertEquals(0, openSegments(tripId))
}
}
@Test
fun stoppingARideThatCapturedNothingDiscardsIt() {
send(TrackingService.ACTION_START)
await { repo.activeTrip()?.state == TripState.RECORDING }
val tripId = runBlocking { repo.activeTrip()!!.id }
// No GPS is injected here, so the trip has no points.
send(TrackingService.ACTION_STOP)
await { repo.activeTrip() == null }
runBlocking {
assertNull("an empty ride is clutter, not history", db.tripDao().getById(tripId))
}
}
/** Gives a trip one point so stop treats it as a real ride rather than empty. */
private suspend fun seedPoint(tripId: Long) {
val segmentId = db.segmentDao().openSegment(tripId)?.id ?: return
db.trackPointDao().insertPoint(
com.rippr.data.TrackPoint(
tripId = tripId, segmentId = segmentId, timestamp = 1_000,
latitude = 51.0, longitude = -114.0, speedKmh = 40f, altitudeM = 1000.0,
)
)
}
@Test
fun discardDeletesTheTripEntirely() {
send(TrackingService.ACTION_START)
await { repo.activeTrip()?.state == TripState.RECORDING }
val tripId = runBlocking { repo.activeTrip()!!.id }
send(TrackingService.ACTION_DISCARD)
await { repo.activeTrip() == null }
runBlocking {
assertNull("discard must remove the row, not close it", db.tripDao().getById(tripId))
assertEquals(0, db.trackPointDao().countForTrip(tripId))
}
}
@Test
fun aFullPauseResumeCycleProducesExactlyTwoSegments() {
send(TrackingService.ACTION_START)
await { repo.activeTrip()?.state == TripState.RECORDING }
val tripId = runBlocking { repo.activeTrip()!!.id }
runBlocking { seedPoint(tripId) }
send(TrackingService.ACTION_PAUSE)
await { repo.activeTrip()?.state == TripState.PAUSED }
send(TrackingService.ACTION_RESUME)
await { repo.activeTrip()?.state == TripState.RECORDING }
send(TrackingService.ACTION_STOP)
await { repo.activeTrip() == null }
runBlocking {
assertEquals(2, db.segmentDao().countForTrip(tripId))
assertEquals(0, openSegments(tripId))
assertEquals(TripState.COMPLETED, db.tripDao().getById(tripId)!!.state)
}
}
}

View File

@@ -0,0 +1,197 @@
package com.rippr.data
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.rippr.stats.RideStatistics
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MergeTest {
private lateinit var db: AppDatabase
private lateinit var repo: TripRepository
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java,
).build()
repo = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
}
@After
fun tearDown() = db.close()
/** A completed trip with one segment and [points] fixes walking north. */
private suspend fun completedTrip(
startedAt: Long,
points: Int,
startLat: Double,
name: String? = null,
): Long {
val tripId = db.tripDao().insert(
Trip(startedAt = startedAt, endedAt = startedAt + points * 1_000L,
name = name, state = TripState.COMPLETED)
)
val segmentId = db.segmentDao().insert(
Segment(tripId = tripId, startedAt = startedAt, endedAt = startedAt + points * 1_000L)
)
db.trackPointDao().insertPoints(
(0 until points).map {
TrackPoint(
tripId = tripId,
segmentId = segmentId,
timestamp = startedAt + it * 1_000L,
latitude = startLat + it * 0.0001,
longitude = -114.0,
speedKmh = 40f,
altitudeM = 1000.0,
)
}
)
repo.recomputeAggregates(tripId)
return tripId
}
@Test
fun mergeReparentsEverythingAndDeletesTheAbsorbedRow() = runTest {
val early = completedTrip(startedAt = 1_000, points = 10, startLat = 51.0)
val late = completedTrip(startedAt = 900_000, points = 15, startLat = 52.0)
val survivor = repo.mergeTrips(early, late)
assertEquals("the earlier trip must survive", early, survivor)
assertNull("absorbed row must be gone", db.tripDao().getById(late))
assertEquals(2, db.segmentDao().countForTrip(early))
assertEquals("no point may be lost", 25, db.trackPointDao().countForTrip(early))
assertEquals(25, db.trackPointDao().getAllPoints().size)
assertEquals(1, db.tripDao().count())
}
@Test
fun selectionOrderDoesNotDecideTheSurvivor() = runTest {
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0)
// Deliberately passed later-first.
assertEquals(early, repo.mergeTrips(late, early))
}
@Test
fun mergedAggregatesAreRecomputedNotSummed() = runTest {
val early = completedTrip(startedAt = 1_000, points = 10, startLat = 51.0)
val late = completedTrip(startedAt = 900_000, points = 15, startLat = 52.0)
val distanceBefore = db.tripDao().getById(early)!!.distanceM +
db.tripDao().getById(late)!!.distanceM
repo.mergeTrips(early, late)
val merged = db.tripDao().getById(early)!!
val expected = RideStatistics.compute(
db.trackPointDao().forTrip(early),
db.segmentDao().forTrip(early),
)
assertEquals(expected.distanceM, merged.distanceM, 0.01)
assertEquals(25, merged.pointCount)
// The ~111 km between the two rides must not appear anywhere.
assertEquals(
"summing would have added the inter-ride gap",
distanceBefore,
merged.distanceM,
1.0,
)
}
@Test
fun theJoinRemainsASegmentBoundary() = runTest {
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0)
repo.mergeTrips(early, late)
val segments = db.segmentDao().forTrip(early)
assertEquals("segments must never be joined", 2, segments.size)
val points = db.trackPointDao().forTrip(early)
assertEquals(2, points.map { it.segmentId }.distinct().size)
}
@Test
fun endedAtBecomesTheLaterOfTheTwo() = runTest {
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0)
val lateEnd = db.tripDao().getById(late)!!.endedAt!!
repo.mergeTrips(early, late)
assertEquals(lateEnd, db.tripDao().getById(early)!!.endedAt)
}
@Test
fun anUnnamedSurvivorInheritsTheOtherName() = runTest {
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0, name = null)
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0, name = "Kananaskis")
repo.mergeTrips(early, late)
assertEquals("Kananaskis", db.tripDao().getById(early)!!.name)
}
@Test
fun anExistingSurvivorNameIsKept() = runTest {
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0, name = "Morning loop")
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0, name = "Evening")
repo.mergeTrips(early, late)
assertEquals("Morning loop", db.tripDao().getById(early)!!.name)
}
// --- Rejections -----------------------------------------------------------
@Test
fun mergingATripWithItselfIsRejected() = runTest {
val trip = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
assertNull(repo.mergeTrips(trip, trip))
assertEquals(5, db.trackPointDao().countForTrip(trip))
}
@Test
fun mergingAnActiveTripIsRejected() = runTest {
val completed = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
val active = repo.startTrip(900_000)
assertNull(repo.mergeTrips(completed, active.tripId))
assertNotNull("the active trip must survive untouched", repo.activeTrip())
assertEquals(2, db.tripDao().count())
}
@Test
fun mergingAMissingTripIsRejected() = runTest {
val trip = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
assertNull(repo.mergeTrips(trip, 9_999))
assertEquals(1, db.tripDao().count())
}
@Test
fun mergeIsAtomicAndLeavesNoOrphans() = runTest {
val early = completedTrip(startedAt = 1_000, points = 8, startLat = 51.0)
val late = completedTrip(startedAt = 900_000, points = 8, startLat = 52.0)
repo.mergeTrips(early, late)
val orphanSegments = db.segmentDao().forTrip(late)
assertTrue("segments left behind on the deleted trip", orphanSegments.isEmpty())
assertEquals(0, db.trackPointDao().countForTrip(late))
assertEquals(16, db.trackPointDao().getAllPoints().size)
}
}

View File

@@ -0,0 +1,225 @@
package com.rippr.data
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SchemaTest {
private lateinit var db: AppDatabase
private lateinit var trips: TripDao
private lateinit var segments: SegmentDao
private lateinit var points: TrackPointDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java,
).build()
trips = db.tripDao()
segments = db.segmentDao()
points = db.trackPointDao()
}
@After
fun tearDown() = db.close()
private suspend fun seedTrip(startedAt: Long = 1_000): Pair<Long, Long> {
val tripId = trips.insert(Trip(startedAt = startedAt))
val segmentId = segments.insert(Segment(tripId = tripId, startedAt = startedAt))
return tripId to segmentId
}
private fun point(tripId: Long, segmentId: Long, ts: Long, speed: Float = 50f) = TrackPoint(
tripId = tripId,
segmentId = segmentId,
timestamp = ts,
latitude = 51.0447,
longitude = -114.0719,
speedKmh = speed,
altitudeM = 1045.0,
)
// --- Cascade -----------------------------------------------------------
@Test
fun deletingATripCascadesToSegmentsAndPoints() = runTest {
val (tripId, segmentId) = seedTrip()
points.insertPoints(List(5) { point(tripId, segmentId, 1_000L + it) })
assertEquals(5, points.countForTrip(tripId))
trips.deleteById(tripId)
assertEquals(0, segments.countForTrip(tripId))
assertEquals(0, points.countForTrip(tripId))
assertTrue(points.getAllPoints().isEmpty())
}
// --- Active trip -------------------------------------------------------
@Test
fun observeActiveEmitsNullOnAnEmptyDatabase() = runTest {
assertNull(trips.observeActive().first())
}
@Test
fun observeActiveTracksTheOpenTripAndClearsWhenClosed() = runTest {
val (tripId, _) = seedTrip()
assertNotNull(trips.observeActive().first())
trips.close(tripId, endedAt = 9_000)
assertNull("a closed trip must not read as active", trips.observeActive().first())
assertEquals(TripState.COMPLETED, trips.getById(tripId)?.state)
}
@Test
fun completedTripsAreListedNewestFirstAndExcludeTheActiveOne() = runTest {
val older = trips.insert(Trip(startedAt = 1_000))
val newer = trips.insert(Trip(startedAt = 5_000))
trips.close(older, endedAt = 2_000)
trips.close(newer, endedAt = 6_000)
trips.insert(Trip(startedAt = 9_000)) // still active
val listed = trips.observeCompleted().first()
assertEquals(listOf(newer, older), listed.map { it.id })
}
// --- Segments ----------------------------------------------------------
@Test
fun openSegmentReturnsOnlyTheUnclosedOne() = runTest {
val (tripId, first) = seedTrip()
segments.close(first, endedAt = 2_000)
assertNull(segments.openSegment(tripId))
val second = segments.insert(Segment(tripId = tripId, startedAt = 3_000))
assertEquals(second, segments.openSegment(tripId)?.id)
}
@Test
fun pointsAreOrderedBySegmentThenId() = runTest {
val (tripId, firstSegment) = seedTrip()
val secondSegment = segments.insert(Segment(tripId = tripId, startedAt = 5_000))
// Interleave the inserts so ordering cannot pass by luck of insertion order.
points.insertPoint(point(tripId, firstSegment, 1_000))
points.insertPoint(point(tripId, secondSegment, 5_000))
points.insertPoint(point(tripId, firstSegment, 2_000))
points.insertPoint(point(tripId, secondSegment, 6_000))
val ordered = points.forTrip(tripId)
assertEquals(
listOf(firstSegment, firstSegment, secondSegment, secondSegment),
ordered.map { it.segmentId },
)
assertEquals(listOf(1_000L, 2_000L, 5_000L, 6_000L), ordered.map { it.timestamp })
}
@Test
fun lastInSegmentReturnsTheMostRecentlyWrittenPoint() = runTest {
val (tripId, segmentId) = seedTrip()
points.insertPoints(List(3) { point(tripId, segmentId, 1_000L + it) })
assertEquals(1_002L, points.lastInSegment(segmentId)?.timestamp)
assertNull(points.lastInSegment(999))
}
// --- Aggregates --------------------------------------------------------
@Test
fun statsForATripWithNoPointsAreZeroNotNull() = runTest {
val (tripId, _) = seedTrip()
// COALESCE guards: without them Room throws on RideStats' non-null fields.
val stats = points.observeTripStats(tripId).first()
assertEquals(0, stats.pointCount)
assertEquals(0f, stats.maxSpeedKmh, 0.001f)
assertEquals(0L, stats.durationMillis)
assertEquals(0, stats.pendingUpload)
}
@Test
fun statsAreScopedToOneTrip() = runTest {
val (tripA, segA) = seedTrip(startedAt = 1_000)
val (tripB, segB) = seedTrip(startedAt = 5_000)
points.insertPoints(listOf(point(tripA, segA, 1_000, 40f), point(tripA, segA, 3_000, 120f)))
points.insertPoints(listOf(point(tripB, segB, 5_000, 200f)))
val a = points.observeTripStats(tripA).first()
assertEquals(2, a.pointCount)
assertEquals("trip B must not leak into trip A", 120f, a.maxSpeedKmh, 0.001f)
assertEquals(2_000L, a.durationMillis)
}
@Test
fun aggregateColumnsRoundTripOnTheTripRow() = runTest {
val (tripId, _) = seedTrip()
trips.updateAggregates(
id = tripId,
distanceM = 47_231.5,
movingMillis = 4_320_000,
maxSpeedKmh = 118.4f,
elevationGainM = 612.0,
pointCount = 8_640,
)
val trip = trips.getById(tripId)!!
assertEquals(47_231.5, trip.distanceM, 0.001)
assertEquals(4_320_000L, trip.movingMillis)
assertEquals(118.4f, trip.maxSpeedKmh, 0.001f)
assertEquals(612.0, trip.elevationGainM, 0.001)
assertEquals(8_640, trip.pointCount)
}
// --- Upload backlog ----------------------------------------------------
@Test
fun syncedFlagStillDrivesTheUploadBacklog() = runTest {
val (tripId, segmentId) = seedTrip()
points.insertPoints(List(4) { point(tripId, segmentId, 1_000L + it) })
val pending = points.getUnsyncedPoints(100)
assertEquals(4, pending.size)
points.markSynced(pending.take(2).map { it.id })
assertEquals(2, points.countUnsynced())
assertEquals(2, points.observePendingUpload(tripId).first())
}
// --- Merge support -----------------------------------------------------
@Test
fun reparentMovesSegmentsAndPointsToTheSurvivingTrip() = runTest {
val (survivor, survivorSeg) = seedTrip(startedAt = 1_000)
val (absorbed, absorbedSeg) = seedTrip(startedAt = 5_000)
points.insertPoints(listOf(point(survivor, survivorSeg, 1_000)))
points.insertPoints(List(3) { point(absorbed, absorbedSeg, 5_000L + it) })
segments.reparent(oldTripId = absorbed, newTripId = survivor)
points.reparent(oldTripId = absorbed, newTripId = survivor)
trips.deleteById(absorbed)
assertEquals(2, segments.countForTrip(survivor))
assertEquals("no point may be lost to the CASCADE", 4, points.countForTrip(survivor))
assertEquals(4, points.getAllPoints().size)
}
}

View File

@@ -0,0 +1,244 @@
package com.rippr.data
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TripRepositoryTest {
private lateinit var db: AppDatabase
private lateinit var repo: TripRepository
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java,
).build()
repo = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
}
@After
fun tearDown() = db.close()
private suspend fun addPoint(handle: TripHandle, ts: Long) =
db.trackPointDao().insertPoint(
TrackPoint(
tripId = handle.tripId,
segmentId = handle.segmentId,
timestamp = ts,
latitude = 51.0,
longitude = -114.0,
speedKmh = 50f,
altitudeM = 1000.0,
)
)
// --- Happy path --------------------------------------------------------
@Test
fun fullLifecycleProducesOneTripAndTwoSegments() = runTest {
val first = repo.startTrip(1_000)
addPoint(first, 1_100)
repo.pauseTrip(2_000)
val second = repo.resumeTrip(3_000)!!
addPoint(second, 3_100)
repo.completeTrip(4_000)
assertNotEquals("resume must open a new segment", first.segmentId, second.segmentId)
assertEquals(first.tripId, second.tripId)
val segments = repo.segmentsForTrip(first.tripId)
assertEquals(2, segments.size)
assertTrue("every segment must be closed", segments.all { !it.isOpen })
val trip = db.tripDao().getById(first.tripId)!!
assertEquals(TripState.COMPLETED, trip.state)
assertEquals(4_000L, trip.endedAt)
assertNull(repo.activeTrip())
}
@Test
fun pauseClosesTheSegmentButLeavesTheTripOpen() = runTest {
val handle = repo.startTrip(1_000)
repo.pauseTrip(2_000)
val trip = repo.activeTrip()
assertNotNull("a paused trip is still active", trip)
assertEquals(TripState.PAUSED, trip!!.state)
assertNull("endedAt belongs to completeTrip, not pause", trip.endedAt)
assertNull(db.segmentDao().openSegment(handle.tripId))
}
// --- Idempotency: the OS can restart the service at any moment ----------
@Test
fun startTripAdoptsAnAlreadyActiveTripInsteadOfCreatingASecond() = runTest {
val first = repo.startTrip(1_000)
val second = repo.startTrip(2_000)
assertEquals("must adopt, not duplicate", first.tripId, second.tripId)
assertEquals("must reuse the open segment", first.segmentId, second.segmentId)
assertEquals(1, db.tripDao().count())
}
@Test
fun resumeWhileAlreadyRecordingIsANoOp() = runTest {
val first = repo.startTrip(1_000)
val again = repo.resumeTrip(2_000)!!
assertEquals(first.segmentId, again.segmentId)
assertEquals("no duplicate segment", 1, db.segmentDao().countForTrip(first.tripId))
}
@Test
fun doublePauseIsANoOp() = runTest {
val handle = repo.startTrip(1_000)
assertTrue(repo.pauseTrip(2_000))
assertTrue(repo.pauseTrip(3_000))
assertEquals(1, db.segmentDao().countForTrip(handle.tripId))
}
@Test
fun transitionsWithNoActiveTripAreSafeNoOps() = runTest {
assertFalse(repo.pauseTrip(1_000))
assertNull(repo.resumeTrip(1_000))
assertNull(repo.completeTrip(1_000))
assertFalse(repo.discardTrip())
assertEquals(0, db.tripDao().count())
}
@Test
fun startAfterCompleteBeginsAFreshTrip() = runTest {
val first = repo.startTrip(1_000)
repo.completeTrip(2_000)
val second = repo.startTrip(3_000)
assertNotEquals(first.tripId, second.tripId)
assertEquals(2, db.tripDao().count())
}
// --- Discard -----------------------------------------------------------
@Test
fun discardRemovesTheTripAndAllItsPoints() = runTest {
val handle = repo.startTrip(1_000)
repeat(5) { addPoint(handle, 1_000L + it) }
assertTrue(repo.discardTrip())
assertNull(repo.activeTrip())
assertEquals(0, db.tripDao().count())
assertTrue(db.trackPointDao().getAllPoints().isEmpty())
}
@Test
fun discardLeavesEarlierCompletedTripsAlone() = runTest {
val keep = repo.startTrip(1_000)
addPoint(keep, 1_100)
repo.completeTrip(2_000)
val throwaway = repo.startTrip(3_000)
addPoint(throwaway, 3_100)
repo.discardTrip()
assertEquals(1, db.tripDao().count())
assertEquals(1, db.trackPointDao().countForTrip(keep.tripId))
}
// --- Reactive state ----------------------------------------------------
@Test
fun activeTripFlowTracksTheLifecycle() = runTest {
assertNull(repo.observeActiveTrip().first())
repo.startTrip(1_000)
assertNotNull(repo.observeActiveTrip().first())
repo.pauseTrip(2_000)
assertEquals(
"a paused ride must still read as active",
TripState.PAUSED,
repo.observeActiveTrip().first()?.state,
)
repo.completeTrip(3_000)
assertNull(repo.observeActiveTrip().first())
}
@Test
fun completedTripsFlowExcludesTheActiveTrip() = runTest {
repo.startTrip(1_000)
repo.completeTrip(2_000)
repo.startTrip(3_000)
assertEquals(1, repo.observeCompletedTrips().first().size)
}
// --- Rename ------------------------------------------------------------
@Test
fun renameStoresNullRatherThanAnEmptyString() = runTest {
val handle = repo.startTrip(1_000)
repo.renameTrip(handle.tripId, "Kananaskis loop")
assertEquals("Kananaskis loop", db.tripDao().getById(handle.tripId)?.name)
repo.renameTrip(handle.tripId, " ")
assertNull(
"blank must collapse to null or the date-label branch diverges",
db.tripDao().getById(handle.tripId)?.name,
)
}
@Test
fun renameTrimsSurroundingWhitespace() = runTest {
val handle = repo.startTrip(1_000)
repo.renameTrip(handle.tripId, " Bow Valley ")
assertEquals("Bow Valley", db.tripDao().getById(handle.tripId)?.name)
}
// --- Process-death simulation ------------------------------------------
@Test
fun aFreshRepositoryOverTheSameDatabaseSeesTheActiveTrip() = runTest {
val handle = repo.startTrip(1_000)
addPoint(handle, 1_100)
// Stands in for the service being killed and restarted: new object graph, same
// database. An in-memory flag would have come back false here.
val restarted = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
val adopted = restarted.startTrip(5_000)
assertEquals(handle.tripId, adopted.tripId)
assertEquals("the open segment is resumed, not replaced", handle.segmentId, adopted.segmentId)
assertEquals(1, db.tripDao().count())
}
@Test
fun restartAfterPauseResumesIntoANewSegment() = runTest {
val handle = repo.startTrip(1_000)
repo.pauseTrip(2_000)
val restarted = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
val resumed = restarted.resumeTrip(5_000)!!
assertEquals(handle.tripId, resumed.tripId)
assertNotEquals(handle.segmentId, resumed.segmentId)
assertEquals(TripState.RECORDING, restarted.activeTrip()?.state)
}
}

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.location.gps" android:required="true" />
<application
android:name=".RipprApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Rippr">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/Theme.Rippr">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Required since Android 7: a raw file:// URI in an Intent throws
FileUriExposedException at share time, not at build time. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".TrackingService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="location" />
</application>
</manifest>

View File

@@ -0,0 +1,48 @@
package com.rippr
import android.content.Context
import java.util.UUID
/**
* Runtime configuration. The upload endpoint is intentionally empty by default —
* recording must work with no server at all, and uploading is opt-in.
*/
object Config {
private const val PREFS = "rippr_prefs"
private const val KEY_ENDPOINT = "upload_endpoint"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_MAP_ENABLED = "map_enabled"
fun uploadEndpoint(context: Context): String =
prefs(context).getString(KEY_ENDPOINT, "").orEmpty()
fun setUploadEndpoint(context: Context, url: String) {
prefs(context).edit().putString(KEY_ENDPOINT, url.trim()).apply()
}
/**
* Whether trip detail renders a map at all.
*
* When off the map composable is never created, so no tile is ever requested — a real
* short-circuit, not a hidden view. The map only ever exists inside the detail
* screen's Compose lifecycle; the recording service never touches it.
*/
fun mapEnabled(context: Context): Boolean =
prefs(context).getBoolean(KEY_MAP_ENABLED, true)
fun setMapEnabled(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean(KEY_MAP_ENABLED, enabled).apply()
}
/** Stable per-install id so the server can distinguish riders in a group. */
fun deviceId(context: Context): String {
val p = prefs(context)
return p.getString(KEY_DEVICE_ID, null) ?: UUID.randomUUID().toString().also {
p.edit().putString(KEY_DEVICE_ID, it).apply()
}
}
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}

View File

@@ -0,0 +1,30 @@
package com.rippr
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* The most recent fix, published straight from the location callback.
*
* The Trip row is only written every ~2 s and carries *max* speed, not current, so the
* recording screen cannot show a live speedo from it. This is deliberately ephemeral —
* losing it on restart is correct, and nothing durable depends on it.
*/
object LiveTelemetry {
private val _speedKmh = MutableStateFlow(0f)
val speedKmh: StateFlow<Float> = _speedKmh.asStateFlow()
private val _accuracyM = MutableStateFlow(0f)
val accuracyM: StateFlow<Float> = _accuracyM.asStateFlow()
fun update(speedKmh: Float, accuracyM: Float) {
_speedKmh.value = speedKmh
_accuracyM.value = accuracyM
}
fun clear() {
_speedKmh.value = 0f
_accuracyM.value = 0f
}
}

View File

@@ -0,0 +1,75 @@
package com.rippr
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.rippr.ui.RipprNavHost
import com.rippr.ui.theme.RipprTheme
/**
* Owns only what has to live on an Activity: runtime permissions and the
* battery-optimisation prompt. Screen state belongs to the ViewModels.
*/
class MainActivity : ComponentActivity() {
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { granted ->
if (granted[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
granted[Manifest.permission.ACCESS_COARSE_LOCATION] == true
) {
// Granting the permission should begin the ride the user already asked for.
startRecording()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
RipprTheme {
RipprNavHost(
onRequestPermissions = ::requestPermissions,
hasLocationPermission = ::hasLocationPermission,
isBatteryExempt = ::isIgnoringBatteryOptimizations,
onRequestBatteryExemption = ::requestBatteryExemption,
)
}
}
}
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
private fun requestPermissions() {
val permissions = mutableListOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions += Manifest.permission.POST_NOTIFICATIONS
}
permissionLauncher.launch(permissions.toTypedArray())
}
private fun startRecording() {
ContextCompat.startForegroundService(
this,
Intent(this, TrackingService::class.java).setAction(TrackingService.ACTION_START),
)
}
private fun isIgnoringBatteryOptimizations(): Boolean =
getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName)
private fun requestBatteryExemption() {
runCatching { startActivity(TrackingService.batteryOptimizationIntent(packageName)) }
}
}

View File

@@ -0,0 +1,114 @@
package com.rippr
import com.rippr.data.TrackPoint
import com.rippr.geo.Geo
import com.rippr.stats.ElevationAccumulator
import com.rippr.stats.RideStatistics
import kotlin.math.max
/**
* Running totals for the ride in progress.
*
* Exists because SQLite 3.18 (minSdk 26) has no window functions, so distance and
* elevation — both of which need consecutive-row differences — cannot be computed in SQL.
* They are folded in here as points are written and persisted onto the `Trip` row once
* per flush, so the recording screen can show live numbers without rescanning the point
* table.
*
* The values this produces are an *estimate*. They are overwritten with
* [RideStatistics.compute] when the trip completes, which is also why this class reuses
* `ElevationAccumulator` rather than reimplementing hysteresis: a naive version measured
* 1498 m of phantom climbing on a parked bike.
*
* Not thread-safe — the writer loop owns it, under the service's write mutex.
*/
class Accumulator {
/**
* The last point of the *previous* batch.
*
* Without this, distance restarts at every flush boundary and silently under-reports
* by roughly one inter-point hop per batch — a few percent, which nobody notices
* until they compare against an odometer.
*/
private var lastPoint: TrackPoint? = null
private var elevation = ElevationAccumulator()
private var restoredElevationM: Double = 0.0
var distanceM: Double = 0.0
private set
var movingMillis: Long = 0
private set
var maxSpeedKmh: Float = 0f
private set
var pointCount: Int = 0
private set
fun fold(points: List<TrackPoint>) {
for (point in points) {
pointCount++
maxSpeedKmh = max(maxSpeedKmh, point.speedKmh)
elevation.add(point.altitudeM)
val prev = lastPoint
// Distance and moving time only accrue *within* a segment. Points either side
// of a pause can be kilometres apart and that gap was never ridden.
if (prev != null && prev.segmentId == point.segmentId) {
distanceM += Geo.haversineMeters(
prev.latitude, prev.longitude, point.latitude, point.longitude,
)
val dt = point.timestamp - prev.timestamp
if (dt in 1..RideStatistics.MAX_SAMPLE_GAP_MILLIS &&
point.speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH
) {
movingMillis += dt
}
}
lastPoint = point
}
}
/**
* Elevation gain including the run still in progress.
*
* Reads a snapshot rather than mutating, so it is safe to call on every flush while
* recording continues.
*/
fun elevationGain(): Double = restoredElevationM + elevation.gainIncludingPending()
/** Seeds from a trip already in progress after the service is restarted. */
fun restore(
distanceM: Double,
movingMillis: Long,
maxSpeedKmh: Float,
elevationGainM: Double,
pointCount: Int,
lastPoint: TrackPoint?,
) {
this.distanceM = distanceM
this.movingMillis = movingMillis
this.maxSpeedKmh = maxSpeedKmh
this.pointCount = pointCount
this.lastPoint = lastPoint
// Elevation cannot be resumed mid-run from a scalar total, so the accumulator
// restarts and only gain from here on is added to the persisted figure. The
// authoritative recomputation at trip completion corrects any drift.
this.elevation = ElevationAccumulator()
this.restoredElevationM = elevationGainM
}
fun reset() {
lastPoint = null
elevation = ElevationAccumulator()
distanceM = 0.0
movingMillis = 0
maxSpeedKmh = 0f
pointCount = 0
restoredElevationM = 0.0
}
/** Clears the cross-segment anchor so a resumed ride does not span the pause. */
fun onSegmentChanged() {
lastPoint = null
}
}

View File

@@ -0,0 +1,25 @@
package com.rippr
import android.app.Application
import com.rippr.export.ExportManager
import org.osmdroid.config.Configuration
class RipprApp : Application() {
override fun onCreate() {
super.onCreate()
// Must happen before any MapView is constructed. osmdroid's default user agent
// is rejected by OSM's tile servers with a 403, and the failure looks like an
// empty map rather than an error.
Configuration.getInstance().apply {
userAgentValue = BuildConfig.APPLICATION_ID
// App-private storage. Older osmdroid guidance points at external storage,
// which would drag in a storage permission for no reason.
osmdroidBasePath = filesDir.resolve("osmdroid").apply { mkdirs() }
osmdroidTileCache = cacheDir.resolve("osmdroid-tiles").apply { mkdirs() }
}
// Stale exports from previous sessions are dead weight.
ExportManager.clearCache(this)
}
}

View File

@@ -0,0 +1,66 @@
package com.rippr
import com.rippr.data.TrackPoint
import org.json.JSONArray
import org.json.JSONObject
/**
* Pure functions and process-wide recording state. Kept free of Android framework
* types so the conversion and serialization logic is covered by JVM unit tests.
*/
object Telemetry {
const val MS_TO_KMH = 3.6f
fun msToKmh(metersPerSecond: Float): Float = metersPerSecond * MS_TO_KMH
/**
* A parked bike still emits jittering fixes. Anything under this is reported as
* zero so "max speed" is not set by GPS noise while the phone sits in a pocket.
*/
const val SPEED_NOISE_FLOOR_KMH = 1.5f
fun sanitizeSpeedKmh(raw: Float): Float =
if (!raw.isFinite() || raw < SPEED_NOISE_FLOOR_KMH) 0f else raw
/** Drop fixes too imprecise to be worth storing. 0 means "accuracy unknown". */
fun isUsableFix(accuracyMeters: Float, maxAccuracyMeters: Float = 50f): Boolean =
accuracyMeters <= 0f || accuracyMeters <= maxAccuracyMeters
fun formatDuration(millis: Long): String {
if (millis <= 0) return "00:00:00"
val totalSeconds = millis / 1000
return "%02d:%02d:%02d".format(
totalSeconds / 3600,
(totalSeconds % 3600) / 60,
totalSeconds % 60
)
}
fun encodeBatch(deviceId: String, points: List<TrackPoint>): String {
val array = JSONArray()
points.forEach { p ->
array.put(
JSONObject().apply {
put("id", p.id)
// Per point, not per batch: getUnsyncedPoints() draws by id and can
// straddle a segment or, after a discard-and-restart, a trip
// boundary. Hoisting these to batch level would silently mislabel.
put("trip_id", p.tripId)
put("segment_id", p.segmentId)
put("ts", p.timestamp)
put("lat", p.latitude)
put("lon", p.longitude)
put("speed_kmh", p.speedKmh.toDouble())
put("alt_m", p.altitudeM)
put("acc_m", p.accuracyM.toDouble())
put("bearing", p.bearingDeg.toDouble())
}
)
}
return JSONObject().apply {
put("device_id", deviceId)
put("points", array)
}.toString()
}
}

View File

@@ -0,0 +1,99 @@
package com.rippr
import com.rippr.data.TrackPoint
import com.rippr.data.TrackPointDao
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Streams recorded points to a REST endpoint when the network allows it.
*
* Upload is strictly secondary to recording: every failure path here is swallowed
* and retried later, and nothing in this class can stop the location pipeline.
* Points stay in the database with `synced = 0` until the server acknowledges them,
* so a dead endpoint costs nothing but a growing backlog.
*/
class TelemetryUploader(
private val dao: TrackPointDao,
private val endpoint: String,
private val deviceId: String,
private val client: OkHttpClient = defaultClient()
) {
suspend fun uploadPending(): Result = withContext(Dispatchers.IO) {
if (endpoint.isBlank()) return@withContext Result.Disabled
var uploaded = 0
repeat(MAX_BATCHES_PER_RUN) {
val batch = dao.getUnsyncedPoints(BATCH_SIZE)
if (batch.isEmpty()) return@withContext Result.Success(uploaded)
val ok = postBatch(batch)
if (!ok) {
return@withContext if (uploaded > 0) Result.Partial(uploaded) else Result.Failed
}
dao.markSynced(batch.map { it.id })
uploaded += batch.size
// Server was fine but there may be more; brief pause so a long backlog
// does not saturate a weak mobile link.
if (batch.size == BATCH_SIZE) delay(250)
}
Result.Partial(uploaded)
}
private fun postBatch(batch: List<TrackPoint>): Boolean {
val body = Telemetry.encodeBatch(deviceId, batch).toRequestBody(JSON)
val request = Request.Builder()
.url(endpoint)
.post(body)
.header("Content-Type", "application/json")
.build()
return try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
UploadStatus.setError(null)
true
} else {
UploadStatus.setError("HTTP ${response.code}")
Log.w(TAG, "Upload rejected: HTTP ${response.code}")
false
}
}
} catch (e: IOException) {
UploadStatus.setError(e.message ?: "network error")
Log.w(TAG, "Upload failed, will retry", e)
false
}
}
sealed interface Result {
data object Disabled : Result
data object Failed : Result
data class Partial(val uploaded: Int) : Result
data class Success(val uploaded: Int) : Result
}
companion object {
private const val TAG = "TelemetryUploader"
const val BATCH_SIZE = 200
private const val MAX_BATCHES_PER_RUN = 10
private val JSON = "application/json; charset=utf-8".toMediaType()
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
}

View File

@@ -0,0 +1,526 @@
package com.rippr
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.rippr.data.AppDatabase
import com.rippr.data.TrackPoint
import com.rippr.data.TripRepository
import com.rippr.data.TripState
import com.rippr.stats.ElevationAccumulator
import com.rippr.stats.RideStatistics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Foreground service that owns the entire recording pipeline.
*
* Resilience notes:
* - Location callbacks push into an unlimited [Channel] and a single writer coroutine
* drains it in batches. A slow disk therefore stalls the writer, never the
* location callback, and no fix is dropped under back pressure.
* - Every point is stamped with its trip and segment id at creation, so a fix still in
* flight when a pause happens is written to the segment it actually belongs to.
* - START_STICKY plus a self-contained restart path means the service resumes recording
* after a process kill without needing the original Intent — the active trip is read
* back from the database.
* - Upload runs on its own loop; network problems cannot interrupt recording.
*/
class TrackingService : Service() {
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var locationCallback: LocationCallback
private lateinit var db: AppDatabase
private lateinit var uploader: TelemetryUploader
private lateinit var trips: TripRepository
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val pointChannel = Channel<TrackPoint>(Channel.UNLIMITED)
/** Serialises the writer loop against explicit drains at pause/stop/discard. */
private val writeMutex = Mutex()
private var wakeLock: PowerManager.WakeLock? = null
/**
* The most recent start command. Passed to `stopSelf(startId)` so a teardown cannot
* kill the service out from under a START that arrived while it was stopping — which
* happens whenever a rider stops and immediately starts again.
*/
@Volatile private var latestStartId = 0
// Written by the lifecycle path, read by the location callback on another thread.
@Volatile private var currentTripId: Long = 0
@Volatile private var currentSegmentId: Long = 0
/**
* Running totals for the active trip.
*
* SQLite 3.18 on minSdk 26 has no window functions, so "distance from the previous
* point" cannot be expressed in SQL at all. It is accumulated here instead, in the
* writer loop that already touches every point, and persisted once per flush.
*
* Only ever touched from inside [writeMutex].
*/
private var accumulator = Accumulator()
override fun onCreate() {
super.onCreate()
db = AppDatabase.getDatabase(this)
trips = TripRepository.get(this)
uploader = TelemetryUploader(
dao = db.trackPointDao(),
endpoint = Config.uploadEndpoint(this),
deviceId = Config.deviceId(this)
)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
createNotificationChannel()
locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
val loc = result.lastLocation ?: return
if (!Telemetry.isUsableFix(loc.accuracy)) return
// Stamped at creation, which is what makes a pause safe: a fix already in
// flight lands in the segment it was recorded during, not the next one.
val tripId = currentTripId
val segmentId = currentSegmentId
if (tripId == 0L || segmentId == 0L) return
val point = TrackPoint(
tripId = tripId,
segmentId = segmentId,
timestamp = loc.time,
latitude = loc.latitude,
longitude = loc.longitude,
speedKmh = Telemetry.sanitizeSpeedKmh(Telemetry.msToKmh(loc.speed)),
altitudeM = loc.altitude,
accuracyM = loc.accuracy,
bearingDeg = loc.bearing
)
// Published at GPS rate so the screen has a live speedo; the Trip row
// only flushes every ~2 s and carries max speed, not current.
LiveTelemetry.update(point.speedKmh, loc.accuracy)
// Never suspends: the channel is unlimited, so the GPS thread is
// decoupled from database latency.
pointChannel.trySend(point)
}
}
startWriterLoop()
startUploadLoop()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Promote to foreground before anything else. Android allows roughly five seconds
// from startForegroundService() before it kills the process.
startForegroundCompat()
latestStartId = startId
when (intent?.action) {
null -> serviceScope.launch { restoreAfterProcessDeath() }
ACTION_START -> serviceScope.launch { beginRecording() }
ACTION_PAUSE -> serviceScope.launch { pause() }
ACTION_RESUME -> serviceScope.launch { beginRecording() }
ACTION_STOP -> serviceScope.launch { finish(discard = false, startId = startId) }
ACTION_DISCARD -> serviceScope.launch { finish(discard = true, startId = startId) }
else -> Log.w(TAG, "Unknown action ${intent.action}")
}
return START_STICKY
}
// --- Lifecycle ----------------------------------------------------------
/**
* Starts a ride or resumes a paused one. The repository adopts an already-active trip
* rather than creating a second, so this is safe to call from either state.
*/
private suspend fun beginRecording() {
val now = System.currentTimeMillis()
val active = trips.activeTrip()
val handle = if (active?.state == TripState.PAUSED) {
trips.resumeTrip(now)
} else {
trips.startTrip(now)
} ?: return
writeMutex.withLock {
if (handle.tripId != currentTripId) {
accumulator.reset()
seedAccumulator(handle.tripId)
}
// A new segment must not measure distance back to the pre-pause position.
accumulator.onSegmentChanged()
}
currentTripId = handle.tripId
currentSegmentId = handle.segmentId
acquireWakeLock()
startLocationUpdates()
updateNotification(TripState.RECORDING)
}
/**
* Stops consuming GPS but keeps the service and its notification alive, so resuming
* is instant. The wake lock is released — a paused ride at a lunch stop has no reason
* to hold the CPU awake.
*/
private suspend fun pause() {
// Stop the source first, then drain, then close. Points already queued carry the
// old segment id, so they remain correct whichever order they are written in;
// draining here is about not leaving them unwritten while the ride sits idle.
stopLocationUpdates()
LiveTelemetry.clear()
drainChannel()
trips.pauseTrip(System.currentTimeMillis())
currentSegmentId = 0
writeMutex.withLock { accumulator.onSegmentChanged() }
releaseWakeLock()
updateNotification(TripState.PAUSED)
}
private suspend fun finish(discard: Boolean, startId: Int = latestStartId) {
stopLocationUpdates()
LiveTelemetry.clear()
if (discard) {
// Throw away anything still queued; it belongs to a trip about to be deleted.
while (pointChannel.tryReceive().getOrNull() != null) { /* drop */ }
trips.discardTrip()
} else {
drainChannel()
val finishedTripId = currentTripId
// A ride that captured nothing - START then STOP, or no GPS fix ever - is
// noise in the history list, so it is dropped rather than saved.
if (finishedTripId != 0L && db.trackPointDao().countForTrip(finishedTripId) == 0) {
Log.i(TAG, "Discarding empty trip $finishedTripId")
trips.discardTrip()
} else {
trips.completeTrip(System.currentTimeMillis())
if (finishedTripId != 0L) reconcileAggregates(finishedTripId)
}
}
writeMutex.withLock { accumulator.reset() }
currentTripId = 0
currentSegmentId = 0
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
// Only stops if no newer start command has arrived in the meantime.
stopSelf(startId)
}
/**
* START_STICKY redelivers a null intent after an OS-initiated kill. The active trip
* in the database says what to do — an in-memory flag could not have survived.
*/
private suspend fun restoreAfterProcessDeath() {
val trip = trips.activeTrip()
when (trip?.state) {
TripState.RECORDING -> {
Log.i(TAG, "Restoring trip ${trip.id} after process death")
// Resume into a *new* segment: the time the process was dead is a real
// gap in the recording and should render as one.
trips.resumeTrip(System.currentTimeMillis())?.let { handle ->
writeMutex.withLock {
accumulator.reset()
seedAccumulator(handle.tripId)
accumulator.onSegmentChanged()
}
currentTripId = handle.tripId
currentSegmentId = handle.segmentId
acquireWakeLock()
startLocationUpdates()
updateNotification(TripState.RECORDING)
}
}
TripState.PAUSED -> {
currentTripId = trip.id
updateNotification(TripState.PAUSED)
}
else -> {
Log.i(TAG, "No active trip to restore; stopping")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf(latestStartId)
}
}
}
/**
* Restores running totals from the persisted row so a restarted service continues
* accumulating rather than counting the ride from zero.
*/
private suspend fun seedAccumulator(tripId: Long) {
val trip = db.tripDao().getById(tripId) ?: return
accumulator.restore(
distanceM = trip.distanceM,
movingMillis = trip.movingMillis,
maxSpeedKmh = trip.maxSpeedKmh,
elevationGainM = trip.elevationGainM,
pointCount = trip.pointCount,
// Deliberately null: the anchor is only valid within a segment, and a restart
// always opens a new one.
lastPoint = null,
)
}
/**
* Replaces the live estimate with the authoritative computation over stored points.
*
* The incremental totals can drift — a process kill loses the in-memory accumulator
* mid-segment — so a finished ride is always recomputed from what was actually
* written, using the same code the detail screen uses.
*/
private suspend fun reconcileAggregates(tripId: Long) {
try {
val points = db.trackPointDao().forTrip(tripId)
val segments = db.segmentDao().forTrip(tripId)
val summary = RideStatistics.compute(points, segments)
db.tripDao().updateAggregates(
id = tripId,
distanceM = summary.distanceM,
movingMillis = summary.movingMillis,
maxSpeedKmh = summary.maxSpeedKmh,
elevationGainM = summary.elevationGainM,
pointCount = summary.pointCount,
)
} catch (e: Exception) {
Log.e(TAG, "Failed to reconcile aggregates for trip $tripId", e)
}
}
// --- Writing ------------------------------------------------------------
/** Single writer, batching drains so a 2 Hz stream is not 2 transactions/second. */
private fun startWriterLoop() = serviceScope.launch {
while (isActive) {
// Block until at least one point exists, then take whatever else is queued.
val first = pointChannel.receive()
writeMutex.withLock {
val buffer = ArrayList<TrackPoint>(FLUSH_SIZE)
buffer.add(first)
while (buffer.size < FLUSH_SIZE) {
buffer.add(pointChannel.tryReceive().getOrNull() ?: break)
}
persist(buffer)
}
// Coalesce bursts without letting points sit unwritten for long.
delay(FLUSH_INTERVAL_MS)
}
}
/** Writes everything currently queued and returns once it has landed. */
private suspend fun drainChannel() = writeMutex.withLock {
val remaining = generateSequence { pointChannel.tryReceive().getOrNull() }.toList()
if (remaining.isNotEmpty()) persist(remaining)
}
private suspend fun persist(points: List<TrackPoint>) {
if (points.isEmpty()) return
val tripId = points.first().tripId
try {
db.trackPointDao().insertPoints(points)
accumulator.fold(points)
db.tripDao().updateAggregates(
id = tripId,
distanceM = accumulator.distanceM,
movingMillis = accumulator.movingMillis,
maxSpeedKmh = accumulator.maxSpeedKmh,
elevationGainM = accumulator.elevationGain(),
pointCount = accumulator.pointCount,
)
} catch (e: Exception) {
// Losing the app to a DB error mid-ride is worse than losing points.
Log.e(TAG, "Batch insert failed, dropping ${points.size} points", e)
}
}
private fun startUploadLoop() = serviceScope.launch {
while (isActive) {
delay(UPLOAD_INTERVAL_MS)
try {
uploader.uploadPending()
} catch (e: Exception) {
Log.w(TAG, "Upload cycle failed", e)
}
}
}
// --- Location -----------------------------------------------------------
private fun startLocationUpdates() {
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
.setMinUpdateIntervalMillis(500)
.setWaitForAccurateLocation(false)
.setMinUpdateDistanceMeters(0f)
.build()
try {
fusedLocationClient.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
} catch (e: SecurityException) {
Log.e(TAG, "Location permission missing; stopping service", e)
serviceScope.launch { finish(discard = false) }
}
}
private fun stopLocationUpdates() {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
// --- Wake lock ----------------------------------------------------------
/**
* A partial wake lock keeps the CPU alive so the writer coroutine still runs with
* the screen off. The foreground service alone does not guarantee this on all OEMs.
*/
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(PowerManager::class.java)
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "rippr:recording").apply {
setReferenceCounted(false)
acquire(MAX_RIDE_MILLIS)
}
}
private fun releaseWakeLock() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
}
// --- Notification -------------------------------------------------------
private fun startForegroundCompat() {
val notification = buildNotification(TripState.RECORDING)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun updateNotification(state: TripState) {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification(state))
}
private fun action(requestCode: Int, name: String) = PendingIntent.getService(
this,
requestCode,
Intent(this, TrackingService::class.java).setAction(name),
PendingIntent.FLAG_IMMUTABLE,
)
private fun buildNotification(state: TripState): Notification {
val openIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_IMMUTABLE
)
val paused = state == TripState.PAUSED
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(if (paused) "Rippr Paused" else "Rippr Active")
.setContentText(
if (paused) "Ride paused — not recording" else "Recording ride telemetry…"
)
.setSmallIcon(R.drawable.ic_stat_rippr)
.setContentIntent(openIntent)
.setOngoing(true)
.setSilent(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
if (paused) {
builder.addAction(android.R.drawable.ic_media_play, "Resume", action(2, ACTION_RESUME))
} else {
builder.addAction(android.R.drawable.ic_media_pause, "Pause", action(3, ACTION_PAUSE))
}
builder.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", action(1, ACTION_STOP))
return builder.build()
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Rippr Recording",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Persistent notification shown while a ride is being recorded"
setShowBadge(false)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
// --- Teardown -----------------------------------------------------------
override fun onDestroy() {
stopLocationUpdates()
releaseWakeLock()
// Anything still queued has to land before the scope dies. runBlocking is
// deliberate: onDestroy must not return until the write completes, and the
// survivor-scope alternative races the process going away.
val remaining = generateSequence { pointChannel.tryReceive().getOrNull() }.toList()
if (remaining.isNotEmpty()) {
runBlocking {
runCatching { db.trackPointDao().insertPoints(remaining) }
.onFailure { Log.e(TAG, "Final flush of ${remaining.size} points failed", it) }
}
}
serviceScope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
private const val TAG = "TrackingService"
const val CHANNEL_ID = "rippr_service_channel"
const val ACTION_START = "com.rippr.action.START"
const val ACTION_PAUSE = "com.rippr.action.PAUSE"
const val ACTION_RESUME = "com.rippr.action.RESUME"
const val ACTION_STOP = "com.rippr.action.STOP"
const val ACTION_DISCARD = "com.rippr.action.DISCARD"
private const val NOTIFICATION_ID = 101
private const val FLUSH_SIZE = 25
private const val FLUSH_INTERVAL_MS = 2_000L
private const val UPLOAD_INTERVAL_MS = 30_000L
private const val MAX_RIDE_MILLIS = 12L * 60 * 60 * 1000
fun batteryOptimizationIntent(packageName: String): Intent =
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
.setData(android.net.Uri.parse("package:$packageName"))
}
}

View File

@@ -0,0 +1,21 @@
package com.rippr
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Last-seen upload failure, surfaced on the recording screen.
*
* Unlike recording state — which now lives in the database because it must survive
* process death — this is genuinely ephemeral. A stale error from a previous process
* would be misleading, so losing it on restart is the correct behaviour.
*/
object UploadStatus {
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
fun setError(message: String?) {
_lastError.value = message
}
}

View File

@@ -0,0 +1,60 @@
package com.rippr.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
class Converters {
@TypeConverter
fun toTripState(value: String): TripState = TripState.valueOf(value)
@TypeConverter
fun fromTripState(state: TripState): String = state.name
}
@Database(
entities = [Trip::class, Segment::class, TrackPoint::class],
version = 2,
exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun tripDao(): TripDao
abstract fun segmentDao(): SegmentDao
abstract fun trackPointDao(): TrackPointDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"rippr_db"
)
// A ride is unrecoverable if a write is lost to a crash mid-flush,
// but full sync on every insert at 2 Hz burns battery. WAL with
// NORMAL sync is the standard compromise and survives app crashes;
// only an OS-level crash can lose the last few points.
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING)
// No destructive fallback. Rides recorded from v2 onward are real
// data, so any future schema change MUST ship a Migration against the
// committed schemas/com.rippr.data.AppDatabase/2.json baseline.
// Reinstating fallbackToDestructiveMigration() here would silently
// delete every stored ride on the next version bump.
.build()
.also { INSTANCE = it }
}
/** Test seam — lets instrumented tests substitute an in-memory database. */
fun overrideForTest(db: AppDatabase?) {
INSTANCE = db
}
}
}

View File

@@ -0,0 +1,26 @@
package com.rippr.data
import androidx.room.ColumnInfo
/**
* Cheap SQL-computed stats for the live recording screen.
*
* Deliberately limited to what plain aggregate functions can express. Distance and
* elevation gain are absent because they need consecutive-row differences, and SQLite
* 3.18 (minSdk 26) has no window functions — those are accumulated in Kotlin and stored
* on the [Trip] row instead.
*/
data class RideStats(
@ColumnInfo(name = "pointCount") val pointCount: Int,
@ColumnInfo(name = "maxSpeedKmh") val maxSpeedKmh: Float,
@ColumnInfo(name = "avgSpeedKmh") val avgSpeedKmh: Float,
@ColumnInfo(name = "firstTimestamp") val firstTimestamp: Long,
@ColumnInfo(name = "lastTimestamp") val lastTimestamp: Long,
@ColumnInfo(name = "pendingUpload") val pendingUpload: Int,
) {
val durationMillis: Long get() = if (pointCount == 0) 0L else lastTimestamp - firstTimestamp
companion object {
val EMPTY = RideStats(0, 0f, 0f, 0L, 0L, 0)
}
}

View File

@@ -0,0 +1,37 @@
package com.rippr.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* One pause-free stretch of recording within a [Trip].
*
* This layer is what makes pause correct rather than cosmetic. Without it, a rider who
* pauses at a gas station and resumes across town gets a polyline drawn straight through
* terrain they never travelled, and a distance total that includes it. Points are grouped
* by segment for rendering, distance accumulation, and GPX `<trkseg>` output, so every
* consumer naturally leaves a gap where the rider stopped.
*/
@Entity(
tableName = "segments",
foreignKeys = [
ForeignKey(
entity = Trip::class,
parentColumns = ["id"],
childColumns = ["tripId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("tripId")],
)
data class Segment(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val tripId: Long,
val startedAt: Long,
/** Null while this segment is still being recorded into. */
val endedAt: Long? = null,
) {
val isOpen: Boolean get() = endedAt == null
}

View File

@@ -0,0 +1,29 @@
package com.rippr.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@Dao
interface SegmentDao {
@Insert
suspend fun insert(segment: Segment): Long
@Query("SELECT * FROM segments WHERE tripId = :tripId ORDER BY id ASC")
suspend fun forTrip(tripId: Long): List<Segment>
/** The segment currently being recorded into, if any. */
@Query("SELECT * FROM segments WHERE tripId = :tripId AND endedAt IS NULL ORDER BY id DESC LIMIT 1")
suspend fun openSegment(tripId: Long): Segment?
@Query("UPDATE segments SET endedAt = :endedAt WHERE id = :id")
suspend fun close(id: Long, endedAt: Long)
/** Used by merge: re-parents a trip's segments onto the surviving trip. */
@Query("UPDATE segments SET tripId = :newTripId WHERE tripId = :oldTripId")
suspend fun reparent(oldTripId: Long, newTripId: Long)
@Query("SELECT COUNT(*) FROM segments WHERE tripId = :tripId")
suspend fun countForTrip(tripId: Long): Int
}

View File

@@ -0,0 +1,47 @@
package com.rippr.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* A single GPS fix.
*
* Ordering is by [id] rather than [timestamp] everywhere it matters: `timestamp` comes
* from `Location.time`, which is GPS-derived and can jump, whereas the autoincrement id
* is genuinely monotonic in write order.
*/
@Entity(
tableName = "track_points",
foreignKeys = [
ForeignKey(
entity = Trip::class,
parentColumns = ["id"],
childColumns = ["tripId"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = Segment::class,
parentColumns = ["id"],
childColumns = ["segmentId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("tripId"), Index("segmentId"), Index("synced")],
)
data class TrackPoint(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val tripId: Long,
val segmentId: Long,
val timestamp: Long,
val latitude: Double,
val longitude: Double,
val speedKmh: Float,
val altitudeM: Double,
val accuracyM: Float = 0f,
val bearingDeg: Float = 0f,
/** Set once the point has been accepted by the remote endpoint. */
@ColumnInfo(defaultValue = "0") val synced: Boolean = false,
)

View File

@@ -0,0 +1,81 @@
package com.rippr.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface TrackPointDao {
@Insert
suspend fun insertPoint(point: TrackPoint): Long
/** Batched insert — the recorder buffers points and flushes them in groups. */
@Insert
suspend fun insertPoints(points: List<TrackPoint>): List<Long>
/**
* Ordered by segment then id so consumers walk the ride in recording order with
* pause boundaries intact. Not ordered by timestamp: that value is GPS-derived and
* can jump, whereas id is monotonic in write order.
*/
@Query("SELECT * FROM track_points WHERE tripId = :tripId ORDER BY segmentId ASC, id ASC")
suspend fun forTrip(tripId: Long): List<TrackPoint>
@Query("SELECT * FROM track_points WHERE segmentId = :segmentId ORDER BY id ASC")
suspend fun forSegment(segmentId: Long): List<TrackPoint>
/** The anchor a restarted recorder needs to continue accumulating distance. */
@Query("SELECT * FROM track_points WHERE segmentId = :segmentId ORDER BY id DESC LIMIT 1")
suspend fun lastInSegment(segmentId: Long): TrackPoint?
@Query("SELECT COUNT(*) FROM track_points WHERE tripId = :tripId")
suspend fun countForTrip(tripId: Long): Int
/**
* Live stats for one trip. COALESCE keeps the row non-null for a trip with no points
* yet, which would otherwise make Room throw on RideStats' non-null fields.
*/
@Query(
"""
SELECT COUNT(*) AS pointCount,
COALESCE(MAX(speedKmh), 0.0) AS maxSpeedKmh,
COALESCE(AVG(speedKmh), 0.0) AS avgSpeedKmh,
COALESCE(MIN(timestamp), 0) AS firstTimestamp,
COALESCE(MAX(timestamp), 0) AS lastTimestamp,
COALESCE(SUM(CASE WHEN synced = 0 THEN 1 ELSE 0 END), 0) AS pendingUpload
FROM track_points
WHERE tripId = :tripId
"""
)
fun observeTripStats(tripId: Long): Flow<RideStats>
// --- Upload backlog -----------------------------------------------------
// Batches are drawn by id and can straddle a segment or trip boundary, which is why
// the payload carries trip/segment identity per point rather than per batch.
@Query("SELECT * FROM track_points WHERE synced = 0 ORDER BY id ASC LIMIT :limit")
suspend fun getUnsyncedPoints(limit: Int): List<TrackPoint>
@Query("UPDATE track_points SET synced = 1 WHERE id IN (:ids)")
suspend fun markSynced(ids: List<Long>)
@Query("SELECT COUNT(*) FROM track_points WHERE synced = 0")
suspend fun countUnsynced(): Int
@Query("SELECT COUNT(*) FROM track_points WHERE tripId = :tripId AND synced = 0")
fun observePendingUpload(tripId: Long): Flow<Int>
// --- Maintenance --------------------------------------------------------
/** Used by merge: points carry tripId directly, so they re-parent alongside segments. */
@Query("UPDATE track_points SET tripId = :newTripId WHERE tripId = :oldTripId")
suspend fun reparent(oldTripId: Long, newTripId: Long)
@Query("SELECT * FROM track_points ORDER BY id ASC")
suspend fun getAllPoints(): List<TrackPoint>
@Query("DELETE FROM track_points")
suspend fun clearAll()
}

View File

@@ -0,0 +1,48 @@
package com.rippr.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Where a ride is in its lifecycle.
*
* [Trip.endedAt] alone distinguishes active from finished, but cannot tell RECORDING
* from PAUSED — and the service needs that distinction to decide what to do when the OS
* restarts it mid-ride. Hence both.
*/
enum class TripState {
RECORDING,
PAUSED,
COMPLETED,
}
/**
* One ride, from pressing Start to pressing Stop.
*
* The aggregate columns are denormalised on purpose. They are accumulated as points
* arrive (see the recorder's writer loop) and recomputed authoritatively when the trip
* completes, so the trips list can render hundreds of rides without touching the point
* table. SQLite 3.18 on minSdk 26 has no window functions, so consecutive-point maths
* like distance cannot be expressed in SQL anyway.
*/
@Entity(tableName = "trips")
data class Trip(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startedAt: Long,
/** Null while the ride is still active. */
val endedAt: Long? = null,
/** Null means the UI derives a label from [startedAt]. Never store an empty string. */
val name: String? = null,
val state: TripState = TripState.RECORDING,
@ColumnInfo(defaultValue = "0") val distanceM: Double = 0.0,
@ColumnInfo(defaultValue = "0") val movingMillis: Long = 0,
@ColumnInfo(defaultValue = "0") val maxSpeedKmh: Float = 0f,
@ColumnInfo(defaultValue = "0") val elevationGainM: Double = 0.0,
@ColumnInfo(defaultValue = "0") val pointCount: Int = 0,
) {
val isActive: Boolean get() = endedAt == null
val elapsedMillis: Long
get() = if (endedAt == null) 0L else endedAt - startedAt
}

View File

@@ -0,0 +1,80 @@
package com.rippr.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface TripDao {
@Insert
suspend fun insert(trip: Trip): Long
@Update
suspend fun update(trip: Trip)
/**
* The in-progress ride, or null. This is the source of truth for "are we recording" —
* it survives process death, which an in-memory flag cannot.
*/
@Query("SELECT * FROM trips WHERE endedAt IS NULL ORDER BY id DESC LIMIT 1")
fun observeActive(): Flow<Trip?>
@Query("SELECT * FROM trips WHERE endedAt IS NULL ORDER BY id DESC LIMIT 1")
suspend fun getActive(): Trip?
@Query("SELECT * FROM trips WHERE endedAt IS NOT NULL ORDER BY startedAt DESC")
fun observeCompleted(): Flow<List<Trip>>
@Query("SELECT * FROM trips WHERE id = :id")
fun observeById(id: Long): Flow<Trip?>
@Query("SELECT * FROM trips WHERE id = :id")
suspend fun getById(id: Long): Trip?
@Query("UPDATE trips SET name = :name WHERE id = :id")
suspend fun rename(id: Long, name: String?)
@Query("UPDATE trips SET state = :state WHERE id = :id")
suspend fun setState(id: Long, state: TripState)
@Query("UPDATE trips SET endedAt = :endedAt, state = :state WHERE id = :id")
suspend fun close(id: Long, endedAt: Long, state: TripState = TripState.COMPLETED)
/**
* Persists the running totals. Called once per writer flush (~every 2s), so it stays
* a narrow targeted update rather than a full row rewrite.
*/
@Query(
"""
UPDATE trips
SET distanceM = :distanceM,
movingMillis = :movingMillis,
maxSpeedKmh = :maxSpeedKmh,
elevationGainM = :elevationGainM,
pointCount = :pointCount
WHERE id = :id
"""
)
suspend fun updateAggregates(
id: Long,
distanceM: Double,
movingMillis: Long,
maxSpeedKmh: Float,
elevationGainM: Double,
pointCount: Int,
)
/** Segments and points go with it via CASCADE. */
@Query("DELETE FROM trips WHERE id = :id")
suspend fun deleteById(id: Long)
@Query("SELECT COUNT(*) FROM trips")
suspend fun count(): Int
/** Test/maintenance helper. Segments and points follow via CASCADE. */
@Query("DELETE FROM trips")
suspend fun deleteAll()
}

View File

@@ -0,0 +1,192 @@
package com.rippr.data
import android.content.Context
import android.util.Log
import androidx.room.withTransaction
import com.rippr.stats.RideStatistics
import kotlinx.coroutines.flow.Flow
/**
* Owns every trip lifecycle transition.
*
* Recording state lives in the database rather than in memory. A row in `trips` with
* `endedAt IS NULL` *is* the fact of an in-progress ride, so it survives process death —
* which the previous in-memory flag could not. START_STICKY restarts the service with a
* null intent, and an in-memory flag would come back false while a ride was genuinely
* underway.
*
* Every transition is idempotent and safe to call from an unexpected state. The OS can
* restart the service at any moment, so calling resume on an already-recording trip must
* be a no-op rather than opening a duplicate segment.
*/
class TripRepository(
private val db: AppDatabase,
private val tripDao: TripDao,
private val segmentDao: SegmentDao,
private val pointDao: TrackPointDao,
) {
fun observeActiveTrip(): Flow<Trip?> = tripDao.observeActive()
fun observeTrip(id: Long): Flow<Trip?> = tripDao.observeById(id)
fun observeCompletedTrips(): Flow<List<Trip>> = tripDao.observeCompleted()
suspend fun activeTrip(): Trip? = tripDao.getActive()
suspend fun tripById(id: Long): Trip? = tripDao.getById(id)
/** The segment currently being written into, if a ride is active and not paused. */
suspend fun openSegment(): Segment? =
tripDao.getActive()?.let { segmentDao.openSegment(it.id) }
/**
* Starts a ride, or adopts one already in progress.
*
* Adoption rather than rejection is deliberate: after a process kill the trip row
* still exists, and the restarted service needs to continue it, not start a second.
*/
suspend fun startTrip(now: Long): TripHandle = db.withTransaction {
val existing = tripDao.getActive()
if (existing != null) {
Log.i(TAG, "startTrip: adopting active trip ${existing.id}")
return@withTransaction adoptOrOpenSegment(existing, now)
}
val tripId = tripDao.insert(Trip(startedAt = now, state = TripState.RECORDING))
val segmentId = segmentDao.insert(Segment(tripId = tripId, startedAt = now))
TripHandle(tripId, segmentId)
}
/**
* Closes the open segment and marks the trip paused. The trip itself stays open —
* only [completeTrip] sets `endedAt`.
*/
suspend fun pauseTrip(now: Long): Boolean = db.withTransaction {
val trip = tripDao.getActive() ?: return@withTransaction false
if (trip.state == TripState.PAUSED) return@withTransaction true
segmentDao.openSegment(trip.id)?.let { segmentDao.close(it.id, now) }
tripDao.setState(trip.id, TripState.PAUSED)
true
}
/** Opens a fresh segment so the pause leaves a real gap in the recorded path. */
suspend fun resumeTrip(now: Long): TripHandle? = db.withTransaction {
val trip = tripDao.getActive() ?: return@withTransaction null
val handle = adoptOrOpenSegment(trip, now)
tripDao.setState(trip.id, TripState.RECORDING)
handle
}
suspend fun completeTrip(now: Long): Long? = db.withTransaction {
val trip = tripDao.getActive() ?: return@withTransaction null
segmentDao.openSegment(trip.id)?.let { segmentDao.close(it.id, now) }
tripDao.close(trip.id, endedAt = now, state = TripState.COMPLETED)
trip.id
}
/** Deletes the active trip outright. Segments and points follow via CASCADE. */
suspend fun discardTrip(): Boolean = db.withTransaction {
val trip = tripDao.getActive() ?: return@withTransaction false
tripDao.deleteById(trip.id)
true
}
suspend fun renameTrip(id: Long, name: String?) {
// Empty input must collapse to null, or the UI's "derive a label from the date"
// branch and a stored "" would diverge.
tripDao.rename(id, name?.trim()?.takeIf { it.isNotEmpty() })
}
suspend fun deleteTrip(id: Long) = tripDao.deleteById(id)
/**
* Combines two completed rides into one.
*
* The earlier trip survives, the later one's segments and points are re-parented onto
* it, and the later row is deleted. Segments are **never joined** — the boundary
* between the two rides becomes a segment boundary exactly like a pause, which is
* correct: the rider genuinely was not recording in between, and joining them would
* draw a straight line across the gap.
*
* Aggregates are recomputed from scratch rather than summed, because distance is not
* additive across that gap.
*
* Returns the surviving trip id, or null if the merge was rejected.
*/
suspend fun mergeTrips(a: Long, b: Long): Long? = db.withTransaction {
if (a == b) return@withTransaction null
val first = tripDao.getById(a) ?: return@withTransaction null
val second = tripDao.getById(b) ?: return@withTransaction null
if (first.isActive || second.isActive) {
Log.w(TAG, "Refusing to merge an active trip")
return@withTransaction null
}
// Selection order is not ride order.
val (survivor, absorbed) =
if (first.startedAt <= second.startedAt) first to second else second to first
segmentDao.reparent(oldTripId = absorbed.id, newTripId = survivor.id)
pointDao.reparent(oldTripId = absorbed.id, newTripId = survivor.id)
tripDao.deleteById(absorbed.id)
val endedAt = maxOf(survivor.endedAt ?: 0L, absorbed.endedAt ?: 0L)
tripDao.close(survivor.id, endedAt = endedAt, state = TripState.COMPLETED)
if (survivor.name == null && absorbed.name != null) {
tripDao.rename(survivor.id, absorbed.name)
}
recomputeAggregates(survivor.id)
survivor.id
}
/** Recomputes a trip's stored totals from the points it actually owns. */
suspend fun recomputeAggregates(tripId: Long) {
val summary = RideStatistics.compute(
pointDao.forTrip(tripId),
segmentDao.forTrip(tripId),
)
tripDao.updateAggregates(
id = tripId,
distanceM = summary.distanceM,
movingMillis = summary.movingMillis,
maxSpeedKmh = summary.maxSpeedKmh,
elevationGainM = summary.elevationGainM,
pointCount = summary.pointCount,
)
}
suspend fun pointsForTrip(id: Long): List<TrackPoint> = pointDao.forTrip(id)
suspend fun segmentsForTrip(id: Long): List<Segment> = segmentDao.forTrip(id)
private suspend fun adoptOrOpenSegment(trip: Trip, now: Long): TripHandle {
val segment = segmentDao.openSegment(trip.id)
?: Segment(tripId = trip.id, startedAt = now)
.let { it.copy(id = segmentDao.insert(it)) }
return TripHandle(trip.id, segment.id)
}
companion object {
private const val TAG = "TripRepository"
@Volatile
private var INSTANCE: TripRepository? = null
fun get(context: Context): TripRepository =
INSTANCE ?: synchronized(this) {
INSTANCE ?: AppDatabase.getDatabase(context).let { db ->
TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
}.also { INSTANCE = it }
}
/** Test seam, mirroring [AppDatabase.overrideForTest]. */
fun overrideForTest(repo: TripRepository?) {
INSTANCE = repo
}
}
}
/** The ids the recorder stamps onto each fix. */
data class TripHandle(val tripId: Long, val segmentId: Long)

View File

@@ -0,0 +1,63 @@
package com.rippr.export
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import com.rippr.data.Segment
import com.rippr.data.TrackPoint
import com.rippr.data.Trip
import java.io.File
enum class ExportFormat(val extension: String, val mimeType: String) {
GPX("gpx", "application/gpx+xml"),
GEOJSON("geojson", "application/geo+json"),
}
/**
* Turns a ride into a shareable file.
*
* Files are written to `cacheDir/exports` and handed out through [FileProvider]. A raw
* `file://` URI in an Intent throws `FileUriExposedException` on Android 7+, and the
* receiving app needs `FLAG_GRANT_READ_URI_PERMISSION` or it sees a SecurityException
* that looks like a bug in *that* app.
*/
object ExportManager {
private const val DIR = "exports"
fun write(
context: Context,
trip: Trip,
segments: List<Segment>,
points: List<TrackPoint>,
name: String,
fileStamp: String,
format: ExportFormat,
): Uri {
val content = when (format) {
ExportFormat.GPX -> RideExport.gpx(trip, segments, points, name)
ExportFormat.GEOJSON -> RideExport.geoJson(trip, segments, points, name)
}
val dir = File(context.cacheDir, DIR).apply { mkdirs() }
// Sortable, unambiguous, no spaces. Unlike the timestamps *inside* the file,
// which are UTC, this is local time because it is read by a human.
val file = File(dir, "rippr-$fileStamp.${format.extension}")
file.writeText(content)
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
fun shareIntent(uri: Uri, format: ExportFormat): Intent =
Intent(Intent.ACTION_SEND).apply {
type = format.mimeType
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
/** Exports accumulate otherwise; they are handoffs, not storage. */
fun clearCache(context: Context) {
runCatching { File(context.cacheDir, DIR).deleteRecursively() }
}
}

View File

@@ -0,0 +1,151 @@
package com.rippr.export
import com.rippr.data.Segment
import com.rippr.data.TrackPoint
import com.rippr.data.Trip
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Serialises a ride to formats other tools understand.
*
* Pure string generation with no Android dependency, so it is fully unit-testable.
*
* **Never decimate here.** Simplification exists only in the map render path; an export
* must carry every raw point. The tests assert exact counts to catch any leak.
*/
object RideExport {
/** ISO 8601 in UTC. `Location.time` is already UTC epoch millis, so no zone maths. */
private val iso: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC)
private fun time(epochMillis: Long): String = iso.format(Instant.ofEpochMilli(epochMillis))
private fun coord(value: Double): String = String.format(Locale.ROOT, "%.7f", value)
private fun num(value: Double): String = String.format(Locale.ROOT, "%.1f", value)
/**
* XML-escapes text destined for an attribute or element body.
*
* A trip named "Sam & Dave's" would otherwise produce malformed XML, and the failure
* is invisible until an import rejects the file.
*/
internal fun escapeXml(text: String): String = buildString(text.length) {
for (c in text) {
when (c) {
'&' -> append("&amp;")
'<' -> append("&lt;")
'>' -> append("&gt;")
'"' -> append("&quot;")
'\'' -> append("&apos;")
else -> append(c)
}
}
}
/**
* GPX 1.1.
*
* One `<trkseg>` per [Segment] — this is exactly how GPX represents a recording gap,
* so pauses survive the round-trip into Strava, Garmin, or Google Earth instead of
* becoming a straight line across town.
*
* Speed goes in `<extensions>`; it is not part of core GPX 1.1, and consumers that do
* not understand the extension ignore it, which is the correct degradation.
*/
fun gpx(trip: Trip, segments: List<Segment>, points: List<TrackPoint>, name: String): String {
val bySegment = points.groupBy { it.segmentId }
// Segment order, then any points whose segment is missing, so nothing is dropped.
val ordered = segments.map { it.id }.filter { bySegment.containsKey(it) } +
bySegment.keys.filter { id -> segments.none { it.id == id } }
return buildString {
append("""<?xml version="1.0" encoding="UTF-8"?>""").append('\n')
append(
"""<gpx version="1.1" creator="Rippr" xmlns="http://www.topografix.com/GPX/1/1">"""
).append('\n')
append(" <metadata>\n")
append(" <name>").append(escapeXml(name)).append("</name>\n")
append(" <time>").append(time(trip.startedAt)).append("</time>\n")
append(" </metadata>\n")
append(" <trk>\n")
append(" <name>").append(escapeXml(name)).append("</name>\n")
for (segmentId in ordered) {
append(" <trkseg>\n")
for (p in bySegment.getValue(segmentId)) {
append(" <trkpt lat=\"").append(coord(p.latitude))
.append("\" lon=\"").append(coord(p.longitude)).append("\">\n")
append(" <ele>").append(num(p.altitudeM)).append("</ele>\n")
append(" <time>").append(time(p.timestamp)).append("</time>\n")
append(" <extensions>\n")
append(" <speed>")
.append(num(p.speedKmh / 3.6)) // GPX speed is metres per second
.append("</speed>\n")
append(" </extensions>\n")
append(" </trkpt>\n")
}
append(" </trkseg>\n")
}
append(" </trk>\n")
append("</gpx>\n")
}
}
/**
* GeoJSON `FeatureCollection`, one `LineString` per segment.
*
* Coordinates are `[lon, lat, ele]` — **longitude first**, the opposite order to GPX
* and the most common mistake in GeoJSON output.
*/
fun geoJson(trip: Trip, segments: List<Segment>, points: List<TrackPoint>, name: String): String {
val bySegment = points.groupBy { it.segmentId }
val ordered = segments.map { it.id }.filter { bySegment.containsKey(it) } +
bySegment.keys.filter { id -> segments.none { it.id == id } }
val features = ordered.joinToString(",\n") { segmentId ->
val coords = bySegment.getValue(segmentId).joinToString(", ") { p ->
"[${coord(p.longitude)}, ${coord(p.latitude)}, ${num(p.altitudeM)}]"
}
""" {
"type": "Feature",
"properties": { "segment_id": $segmentId },
"geometry": { "type": "LineString", "coordinates": [$coords] }
}"""
}
return """{
"type": "FeatureCollection",
"properties": {
"name": ${jsonString(name)},
"started_at": ${trip.startedAt},
"distance_m": ${num(trip.distanceM)},
"max_speed_kmh": ${num(trip.maxSpeedKmh.toDouble())}
},
"features": [
$features
]
}
"""
}
private fun jsonString(text: String): String = buildString {
append('"')
for (c in text) {
when (c) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> if (c < ' ') append("\\u%04x".format(c.code)) else append(c)
}
}
append('"')
}
}

View File

@@ -0,0 +1,165 @@
package com.rippr.geo
import com.rippr.data.TrackPoint
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
data class LatLon(val lat: Double, val lon: Double)
data class Bounds(
val minLat: Double,
val minLon: Double,
val maxLat: Double,
val maxLon: Double,
) {
val centerLat: Double get() = (minLat + maxLat) / 2
val centerLon: Double get() = (minLon + maxLon) / 2
/** True when every point sits at effectively one spot — a parked "ride". */
val isDegenerate: Boolean
get() = abs(maxLat - minLat) < 1e-9 && abs(maxLon - minLon) < 1e-9
}
fun TrackPoint.toLatLon() = LatLon(latitude, longitude)
/**
* Geographic maths with no Android dependencies, so it is testable on the JVM.
*
* Haversine rather than Vincenty throughout: assuming a sphere costs about 0.5% — a few
* metres per kilometre — which is well below GPS noise. Vincenty's iterative solution
* would be false precision at real cost.
*/
object Geo {
/** IUGG mean Earth radius. */
const val EARTH_RADIUS_M = 6_371_008.8
private const val DEG_TO_RAD = Math.PI / 180.0
fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val dLat = (lat2 - lat1) * DEG_TO_RAD
val dLon = (lon2 - lon1) * DEG_TO_RAD
val sinLat = sin(dLat / 2)
val sinLon = sin(dLon / 2)
val a = sinLat * sinLat +
cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sinLon * sinLon
// asin(sqrt(a)) rather than atan2 — better conditioned for the very short hops
// between consecutive GPS fixes.
return 2 * EARTH_RADIUS_M * asin(min(1.0, sqrt(a)))
}
fun haversineMeters(a: LatLon, b: LatLon): Double =
haversineMeters(a.lat, a.lon, b.lat, b.lon)
/**
* Douglas–Peucker simplification, tolerance in **metres**.
*
* A degree-based tolerance would behave differently depending where you ride — a
* degree of longitude is ~111 km at the equator and ~0 at the poles.
*
* Iterative with an explicit stack: a 20,000-point ride can blow the call stack in
* the pathological case where recursion never balances.
*/
fun simplify(points: List<LatLon>, epsilonMeters: Double): List<LatLon> {
if (points.size <= 2 || epsilonMeters <= 0.0) return points
val keep = BooleanArray(points.size)
keep[0] = true
keep[points.lastIndex] = true
val stack = ArrayDeque<Pair<Int, Int>>()
stack.addLast(0 to points.lastIndex)
while (stack.isNotEmpty()) {
val (first, last) = stack.removeLast()
if (last <= first + 1) continue
var maxDist = 0.0
var index = first
for (i in (first + 1) until last) {
val d = perpendicularDistanceMeters(points[i], points[first], points[last])
if (d > maxDist) {
maxDist = d
index = i
}
}
if (maxDist > epsilonMeters) {
keep[index] = true
stack.addLast(first to index)
stack.addLast(index to last)
}
}
return points.filterIndexed { i, _ -> keep[i] }
}
/**
* Distance from [p] to the segment [a]–[b], via a local equirectangular projection.
*
* Valid over the short spans between consecutive fixes and far cheaper than a
* geodesic solution.
*/
fun perpendicularDistanceMeters(p: LatLon, a: LatLon, b: LatLon): Double {
val latRef = (a.lat + b.lat) / 2 * DEG_TO_RAD
val mPerDegLat = EARTH_RADIUS_M * DEG_TO_RAD
val mPerDegLon = mPerDegLat * cos(latRef)
val ax = a.lon * mPerDegLon
val ay = a.lat * mPerDegLat
val bx = b.lon * mPerDegLon
val by = b.lat * mPerDegLat
val px = p.lon * mPerDegLon
val py = p.lat * mPerDegLat
val dx = bx - ax
val dy = by - ay
val lengthSq = dx * dx + dy * dy
if (lengthSq == 0.0) {
// Degenerate segment: fall back to point distance.
val ex = px - ax
val ey = py - ay
return sqrt(ex * ex + ey * ey)
}
// Clamped projection, so a point beyond either end measures to the endpoint
// rather than to the infinite line.
val t = (((px - ax) * dx + (py - ay) * dy) / lengthSq).coerceIn(0.0, 1.0)
val cx = ax + t * dx
val cy = ay + t * dy
val ex = px - cx
val ey = py - cy
return sqrt(ex * ex + ey * ey)
}
/** Null for an empty list — callers must handle "no path" rather than centring on Null Island. */
fun bounds(points: List<LatLon>): Bounds? {
if (points.isEmpty()) return null
var minLat = points[0].lat
var maxLat = points[0].lat
var minLon = points[0].lon
var maxLon = points[0].lon
for (p in points) {
minLat = min(minLat, p.lat)
maxLat = max(maxLat, p.lat)
minLon = min(minLon, p.lon)
maxLon = max(maxLon, p.lon)
}
return Bounds(minLat, minLon, maxLat, maxLon)
}
/** Total length of a polyline. Callers must not span a pause with this. */
fun pathLengthMeters(points: List<LatLon>): Double {
if (points.size < 2) return 0.0
var total = 0.0
for (i in 1 until points.size) {
total += haversineMeters(points[i - 1], points[i])
}
return total
}
}

View File

@@ -0,0 +1,302 @@
package com.rippr.stats
import com.rippr.Telemetry
import com.rippr.data.Segment
import com.rippr.data.TrackPoint
import com.rippr.geo.Geo
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
data class RideSummary(
val distanceM: Double = 0.0,
/** Wall clock, first fix to last. */
val elapsedMillis: Long = 0,
/** Time spent above the speed noise floor. */
val movingMillis: Long = 0,
val maxSpeedKmh: Float = 0f,
/** Distance ÷ moving time — not the mean of the speed samples. */
val avgMovingSpeedKmh: Float = 0f,
val elevationGainM: Double = 0.0,
val elevationLossM: Double = 0.0,
val pointCount: Int = 0,
) {
val stoppedMillis: Long get() = max(0L, elapsedMillis - movingMillis)
companion object {
val EMPTY = RideSummary()
}
}
data class SpeedBucket(val fromKmh: Int, val toKmh: Int, val millis: Long) {
val label: String get() = "$fromKmh–$toKmh"
}
data class ElevationSample(val distanceM: Double, val altitudeM: Double)
/**
* Batch statistics over a stored ride.
*
* This is the authoritative computation. The recorder accumulates the same values live
* as points arrive, but re-runs this on trip completion so a mid-ride process kill
* cannot leave permanently skewed totals. Both paths must agree, which is why they share
* the constants below rather than duplicating magic numbers.
*/
object RideStatistics {
/**
* A GPS dropout leaves a large gap between consecutive fixes. Without a cap, a
* two-minute tunnel counts as two minutes of moving time at the last known speed.
*/
const val MAX_SAMPLE_GAP_MILLIS = 10_000L
/**
* Raw GPS altitude wanders by ±5–10 m even sitting still. Summing every positive
* delta turns a flat ride into thousands of metres of climbing — the classic bug in
* this calculation. A climb only counts once it exceeds this much in one direction.
*/
const val ELEVATION_HYSTERESIS_M = 3.0
fun compute(points: List<TrackPoint>, segments: List<Segment> = emptyList()): RideSummary {
if (points.isEmpty()) return RideSummary.EMPTY
var distanceM = 0.0
var movingMillis = 0L
var maxSpeedKmh = 0f
val elevation = ElevationAccumulator()
// Grouping by segment is what keeps a pause from inventing distance: points
// either side of a gas-station stop can be kilometres apart.
for (segmentPoints in points.groupBy { it.segmentId }.values) {
var previous: TrackPoint? = null
for (point in segmentPoints) {
maxSpeedKmh = max(maxSpeedKmh, point.speedKmh)
elevation.add(point.altitudeM)
previous?.let { prev ->
distanceM += Geo.haversineMeters(
prev.latitude, prev.longitude, point.latitude, point.longitude,
)
val dt = point.timestamp - prev.timestamp
if (dt in 1..MAX_SAMPLE_GAP_MILLIS &&
point.speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH
) {
movingMillis += dt
}
}
previous = point
}
}
elevation.finish()
val elapsedMillis = elapsedFor(points, segments)
// Guard the divide: a ride that never moved would otherwise produce NaN, which
// Compose happily renders as the literal text "NaN".
val avgMovingSpeedKmh = if (movingMillis > 0) {
(distanceM / 1000.0 / (movingMillis / 3_600_000.0)).toFloat()
} else {
0f
}
return RideSummary(
distanceM = distanceM,
elapsedMillis = elapsedMillis,
movingMillis = movingMillis,
maxSpeedKmh = maxSpeedKmh,
avgMovingSpeedKmh = avgMovingSpeedKmh,
elevationGainM = elevation.gain,
elevationLossM = elevation.loss,
pointCount = points.size,
)
}
/**
* Prefers segment boundaries over point timestamps: they capture the time between a
* segment's last fix and the pause itself, which point timestamps cannot see.
*/
private fun elapsedFor(points: List<TrackPoint>, segments: List<Segment>): Long {
val closed = segments.mapNotNull { s -> s.endedAt?.let { it - s.startedAt } }
if (closed.isNotEmpty() && closed.size == segments.size) {
return closed.sum()
}
val timestamps = points.map { it.timestamp }
return max(0L, (timestamps.maxOrNull() ?: 0L) - (timestamps.minOrNull() ?: 0L))
}
/**
* Time spent in each speed band. Buckets are keyed on the *interval* between fixes,
* so the result is a time distribution rather than a sample count — a bike that sits
* idle at 2 Hz would otherwise dominate purely by producing more samples.
*/
fun speedHistogram(points: List<TrackPoint>, bucketKmh: Int = 10): List<SpeedBucket> {
if (points.size < 2 || bucketKmh <= 0) return emptyList()
val millisByBucket = sortedMapOf<Int, Long>()
for (segmentPoints in points.groupBy { it.segmentId }.values) {
for (i in 1 until segmentPoints.size) {
val dt = segmentPoints[i].timestamp - segmentPoints[i - 1].timestamp
if (dt !in 1..MAX_SAMPLE_GAP_MILLIS) continue
val bucket = (segmentPoints[i].speedKmh / bucketKmh).toInt()
millisByBucket[bucket] = (millisByBucket[bucket] ?: 0L) + dt
}
}
return millisByBucket.map { (bucket, millis) ->
SpeedBucket(bucket * bucketKmh, (bucket + 1) * bucketKmh, millis)
}
}
/**
* Altitude against distance travelled, downsampled for charting.
*
* Sampled by distance along the path rather than by index, so a long stop does not
* flatten the interesting part of the profile into a few pixels.
*/
fun elevationProfile(points: List<TrackPoint>, maxSamples: Int = 200): List<ElevationSample> {
if (points.isEmpty()) return emptyList()
if (points.size == 1) return listOf(ElevationSample(0.0, points[0].altitudeM))
val full = ArrayList<ElevationSample>(points.size)
var cumulative = 0.0
var previous: TrackPoint? = null
var previousSegment = points.first().segmentId
for (point in points) {
previous?.let { prev ->
// Distance only accrues within a segment, matching compute().
if (point.segmentId == previousSegment) {
cumulative += Geo.haversineMeters(
prev.latitude, prev.longitude, point.latitude, point.longitude,
)
}
}
full += ElevationSample(cumulative, point.altitudeM)
previous = point
previousSegment = point.segmentId
}
if (full.size <= maxSamples) return full
val step = full.size.toDouble() / maxSamples
return (0 until maxSamples).map { full[(it * step).roundToInt().coerceAtMost(full.lastIndex)] } +
full.last()
}
}
/**
* Elevation gain/loss accumulator that survives GPS altitude noise.
*
* Two mechanisms, because one is not enough:
*
* 1. **A moving-average window.** Raw GPS altitude wanders by ±5–10 m while completely
* stationary. Averaging over [windowSize] samples cuts the noise by roughly
* sqrt(windowSize), bringing it under the threshold below.
* 2. **Reversal hysteresis.** A climb is only banked once the altitude turns back down
* by more than [thresholdM] from its peak. Simply summing every delta that exceeds a
* threshold does *not* work — noise crosses any small threshold constantly, and a
* parked bike accumulates well over a kilometre of imaginary climbing. That was
* measured, not assumed.
*
* Streaming rather than batch so the recorder can accumulate live and the batch
* computation can reuse the identical code path.
*/
class ElevationAccumulator(
private val windowSize: Int = SMOOTHING_WINDOW,
private val thresholdM: Double = RideStatistics.ELEVATION_HYSTERESIS_M,
) {
private val window = ArrayDeque<Double>(windowSize)
private var windowSum = 0.0
private var lastRaw: Double = 0.0
private var lastCommitted: Double? = null
private var extreme: Double = 0.0
private var direction = 0 // 0 unknown, +1 climbing, -1 descending
var gain: Double = 0.0
private set
var loss: Double = 0.0
private set
fun add(altitudeM: Double) {
if (!altitudeM.isFinite()) return
lastRaw = altitudeM
window.addLast(altitudeM)
windowSum += altitudeM
if (window.size > windowSize) windowSum -= window.removeFirst()
val smoothed = windowSum / window.size
val committed = lastCommitted
if (committed == null) {
lastCommitted = smoothed
extreme = smoothed
return
}
when (direction) {
0 -> when {
smoothed > committed + thresholdM -> { direction = 1; extreme = smoothed }
smoothed < committed - thresholdM -> { direction = -1; extreme = smoothed }
}
1 -> if (smoothed > extreme) {
extreme = smoothed
} else if (smoothed < extreme - thresholdM) {
gain += extreme - committed
lastCommitted = extreme
direction = -1
extreme = smoothed
}
else -> if (smoothed < extreme) {
extreme = smoothed
} else if (smoothed > extreme + thresholdM) {
loss += committed - extreme
lastCommitted = extreme
direction = 1
extreme = smoothed
}
}
}
/**
* Gain including the run still in progress, without mutating state.
*
* Safe to poll while recording continues — [finish] would end the run, which is wrong
* mid-ride, but reading only [gain] would report zero for a climb that has not yet
* turned back down.
*/
fun gainIncludingPending(): Double {
val committed = lastCommitted ?: return gain
val tip = if (direction == 1) max(extreme, lastRaw) else extreme
return if (direction == 1 && tip > committed) gain + (tip - committed) else gain
}
/**
* Banks the run still in progress. Must be called once the last point is added, or a
* steady climb to the summit with no descent afterwards reports zero gain.
*/
fun finish() {
val committed = lastCommitted ?: return
// The moving average lags the true altitude by about half a window, so the final
// smoothed value clips the tail of a climb (a 100 m ascent measured 93 m before
// this). Reconcile against the last raw reading to recover it.
when (direction) {
1 -> extreme = max(extreme, lastRaw)
-1 -> extreme = min(extreme, lastRaw)
}
when {
direction == 1 && extreme > committed -> gain += extreme - committed
direction == -1 && extreme < committed -> loss += committed - extreme
}
lastCommitted = extreme
direction = 0
}
private companion object {
/** ~7 s at 2 Hz: long enough to suppress wander, short enough to keep real terrain. */
const val SMOOTHING_WINDOW = 15
}
}

View File

@@ -0,0 +1,40 @@
package com.rippr.ui
import com.rippr.data.Trip
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Display formatting shared across screens.
*
* Timestamps come from `Location.time`, which is UTC epoch millis, so everything here
* converts to the device zone. Formatting in UTC would show a 21:00 ride as tomorrow.
*/
object Format {
private val dayTime: DateTimeFormatter =
DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale.getDefault())
private val fileStamp: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmm", Locale.getDefault())
fun dateTime(epochMillis: Long): String =
dayTime.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()))
fun fileTimestamp(epochMillis: Long): String =
fileStamp.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()))
/** A trip's own name, or a date-derived label when it has none. */
fun tripLabel(trip: Trip): String = trip.name ?: dateTime(trip.startedAt)
fun distance(meters: Double): String = when {
meters < 1_000 -> "${meters.toInt()} m"
else -> String.format(Locale.getDefault(), "%.1f km", meters / 1_000)
}
fun speed(kmh: Float): String = String.format(Locale.getDefault(), "%.1f km/h", kmh)
fun elevation(meters: Double): String = "${meters.toInt()} m"
}

View File

@@ -0,0 +1,60 @@
package com.rippr.ui
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.rippr.ui.detail.TripDetailScreen
import com.rippr.ui.record.RecordScreen
import com.rippr.ui.trips.TripsScreen
object Routes {
const val RECORD = "record"
const val TRIPS = "trips"
const val TRIP_DETAIL = "trip/{tripId}"
fun tripDetail(tripId: Long) = "trip/$tripId"
}
@Composable
fun RipprNavHost(
onRequestPermissions: () -> Unit,
hasLocationPermission: () -> Boolean,
isBatteryExempt: () -> Boolean,
onRequestBatteryExemption: () -> Unit,
) {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = Routes.RECORD) {
composable(Routes.RECORD) {
RecordScreen(
onOpenTrips = { nav.navigate(Routes.TRIPS) },
onRequestPermissions = onRequestPermissions,
hasLocationPermission = hasLocationPermission,
isBatteryExempt = isBatteryExempt,
onRequestBatteryExemption = onRequestBatteryExemption,
)
}
composable(Routes.TRIPS) {
TripsScreen(
onOpenTrip = { nav.navigate(Routes.tripDetail(it)) },
onBack = { nav.popBackStack() },
)
}
composable(
Routes.TRIP_DETAIL,
// Declared explicitly: Room ids are Long, and without this the argument
// silently arrives as a String.
arguments = listOf(navArgument("tripId") { type = NavType.LongType }),
) { entry ->
TripDetailScreen(
tripId = entry.arguments?.getLong("tripId") ?: 0L,
onBack = { nav.popBackStack() },
)
}
}
}

View File

@@ -0,0 +1,40 @@
package com.rippr.ui
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import com.rippr.data.TripRepository
import com.rippr.ui.detail.TripDetailViewModel
import com.rippr.ui.record.RecordViewModel
import com.rippr.ui.trips.TripsViewModel
/**
* One factory for every screen.
*
* The app already uses `@Volatile` double-checked singletons for the database and
* repository; a DI framework would be more ceremony than three screens earn.
*/
class RipprViewModelFactory(private val tripId: Long = 0) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val app = checkNotNull(extras[APPLICATION_KEY]) { "Application missing from CreationExtras" }
val repo = TripRepository.get(app)
return when {
modelClass.isAssignableFrom(RecordViewModel::class.java) ->
RecordViewModel(app as Application, repo) as T
modelClass.isAssignableFrom(TripsViewModel::class.java) ->
TripsViewModel(repo) as T
modelClass.isAssignableFrom(TripDetailViewModel::class.java) ->
TripDetailViewModel(repo, tripId) as T
else -> error("Unknown ViewModel ${modelClass.name}")
}
}
}
@androidx.compose.runtime.Composable
inline fun <reified T : ViewModel> ripprViewModel(tripId: Long = 0): T =
viewModel(factory = RipprViewModelFactory(tripId), key = "${T::class.java.name}:$tripId")

View File

@@ -0,0 +1,220 @@
package com.rippr.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.rippr.data.TrackPoint
import com.rippr.geo.Geo
import com.rippr.geo.LatLon
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Polyline
import androidx.compose.ui.graphics.Color as ComposeColor
/**
* Rendering tuning.
*
* A three-hour ride is ~21,600 points and an undecimated polyline janks badly on pan and
* zoom. Simplification here is **render-only** — stored and exported data always use raw
* points.
*/
private const val SIMPLIFY_EPSILON_M = 5.0
/** Colour runs are split at this granularity so speed reads without per-vertex shading. */
private const val SPEED_BUCKET_KMH = 10
/**
* OSM Mapnik publishes tiles to zoom 19. Fitting a short ride — a 50 m loop around a
* block — asks for a tighter zoom than that, and osmdroid then renders its empty grid
* placeholder with no streets at all. Clamping keeps the base layer visible; the path is
* vector and stays sharp regardless.
*/
private const val MAX_TILE_ZOOM = 19.0
/** Far enough out that street names are legible for a very short ride. */
private const val SHORT_RIDE_ZOOM = 17.0
/**
* The recorded path on an OpenStreetMap base layer.
*
* Only ever composed inside the trip detail screen. The recording service holds no
* reference to any of this — the phone rides in a pocket, and a live map would cost
* battery for something nobody is looking at.
*/
@Composable
fun RideMap(
points: List<TrackPoint>,
modifier: Modifier = Modifier,
lowColor: ComposeColor,
highColor: ComposeColor,
startColor: ComposeColor,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current
// Held here so the lifecycle observer below can reach the same instance the
// AndroidView is showing.
val mapView = remember {
MapView(context).apply {
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
maxZoomLevel = MAX_TILE_ZOOM
// The built-in +/- buttons overlap the path and are redundant with pinch.
zoomController.setVisibility(
org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER
)
}
}
AndroidView(
modifier = modifier,
factory = { mapView },
update = { map ->
map.overlays.clear()
drawPath(map, points, lowColor, highColor, startColor)
fitTo(map, points)
map.invalidate()
},
// Not optional. Without onDetach the tile handles and the downloader thread
// outlive the composable, and the leak compounds every time detail is opened.
onRelease = { it.onDetach() },
)
// MapView has its own lifecycle that does not follow Compose on its own. Skipping
// this leaves the tile downloader running while the app is backgrounded.
DisposableEffect(lifecycleOwner, mapView) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
mapView.onPause()
}
}
}
/**
* One polyline per segment, further split into runs of similar speed.
*
* Segments are never joined: a pause means the rider stopped recording, and connecting
* across it would draw a line through terrain never travelled. Bucketed runs are used
* rather than osmdroid's per-vertex colouring, which is fiddly and gains little at the
* zoom levels a ride is actually viewed at.
*/
private fun drawPath(
map: MapView,
points: List<TrackPoint>,
lowColor: ComposeColor,
highColor: ComposeColor,
startColor: ComposeColor,
) {
if (points.isEmpty()) return
val maxSpeed = points.maxOf { it.speedKmh }.coerceAtLeast(1f)
points.groupBy { it.segmentId }.values.forEach { segment ->
if (segment.size < 2) return@forEach
val simplified = Geo.simplify(segment.map { LatLon(it.latitude, it.longitude) }, SIMPLIFY_EPSILON_M)
val kept = matchSpeeds(segment, simplified)
var runStart = 0
var runBucket = bucketOf(kept[0].second)
for (i in 1..kept.size) {
val bucket = if (i < kept.size) bucketOf(kept[i].second) else -1
if (bucket != runBucket || i == kept.size) {
// Overlap by one point so consecutive runs meet with no visible seam.
val slice = kept.subList(runStart, minOf(i + 1, kept.size))
if (slice.size >= 2) {
map.overlays.add(
Polyline().apply {
setPoints(slice.map { GeoPoint(it.first.lat, it.first.lon) })
outlinePaint.strokeWidth = 10f
outlinePaint.color = blend(
lowColor, highColor,
(runBucket * SPEED_BUCKET_KMH).toFloat() / maxSpeed,
).toArgb()
}
)
}
runStart = i
runBucket = bucket
}
}
}
// Start and end markers, drawn as tiny circles rather than pins to stay unobtrusive.
val first = points.first()
val last = points.last()
map.overlays.add(dot(first.latitude, first.longitude, startColor))
map.overlays.add(dot(last.latitude, last.longitude, highColor))
}
private fun dot(lat: Double, lon: Double, color: ComposeColor) = Polyline().apply {
setPoints(listOf(GeoPoint(lat, lon), GeoPoint(lat + 1e-6, lon + 1e-6)))
outlinePaint.strokeWidth = 24f
outlinePaint.strokeCap = android.graphics.Paint.Cap.ROUND
outlinePaint.color = color.toArgb()
}
/** Pairs each surviving point with the speed of the original fix nearest to it. */
private fun matchSpeeds(
original: List<TrackPoint>,
simplified: List<LatLon>,
): List<Pair<LatLon, Float>> {
val byCoord = HashMap<Pair<Double, Double>, Float>(original.size)
original.forEach { byCoord[it.latitude to it.longitude] = it.speedKmh }
return simplified.map { it to (byCoord[it.lat to it.lon] ?: 0f) }
}
private fun bucketOf(speedKmh: Float): Int = (speedKmh / SPEED_BUCKET_KMH).toInt()
private fun blend(from: ComposeColor, to: ComposeColor, t: Float): ComposeColor {
val clamped = t.coerceIn(0f, 1f)
return ComposeColor(
red = from.red + (to.red - from.red) * clamped,
green = from.green + (to.green - from.green) * clamped,
blue = from.blue + (to.blue - from.blue) * clamped,
)
}
/**
* Frames the whole route with padding.
*
* A stationary "ride" produces a zero-area box, which either throws or lands at maximum
* zoom, so that case falls back to centring at a fixed level.
*/
private fun fitTo(map: MapView, points: List<TrackPoint>) {
val bounds = Geo.bounds(points.map { LatLon(it.latitude, it.longitude) }) ?: return
if (bounds.isDegenerate) {
map.controller.setZoom(SHORT_RIDE_ZOOM)
map.controller.setCenter(GeoPoint(bounds.centerLat, bounds.centerLon))
return
}
map.post {
map.zoomToBoundingBox(
BoundingBox(bounds.maxLat, bounds.maxLon, bounds.minLat, bounds.minLon),
false,
48,
)
// zoomToBoundingBox ignores maxZoomLevel, so a short ride still lands past the
// last zoom OSM publishes and the base layer renders as an empty grid.
if (map.zoomLevelDouble > MAX_TILE_ZOOM) {
map.controller.setZoom(SHORT_RIDE_ZOOM)
map.controller.setCenter(GeoPoint(bounds.centerLat, bounds.centerLon))
}
}
}

View File

@@ -0,0 +1,67 @@
package com.rippr.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** The one number that matters, rendered large. */
@Composable
fun BigStat(label: String, value: String, unit: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
label,
fontSize = 13.sp,
letterSpacing = 2.sp,
color = MaterialTheme.colorScheme.outline,
)
Row(verticalAlignment = Alignment.Bottom) {
Text(value, fontSize = 64.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
Text(" $unit", fontSize = 18.sp, color = MaterialTheme.colorScheme.outline)
}
}
}
@Composable
fun StatRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = MaterialTheme.colorScheme.outline)
Text(value, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium)
}
}
@Composable
fun ConfirmDialog(
title: String,
message: String,
confirmLabel: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
TextButton(onClick = { onConfirm(); onDismiss() }) {
Text(confirmLabel, color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}

View File

@@ -0,0 +1,401 @@
package com.rippr.ui.detail
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rippr.Telemetry
import com.rippr.stats.ElevationSample
import com.rippr.stats.SpeedBucket
import com.rippr.ui.Format
import com.rippr.Config
import com.rippr.ui.components.ConfirmDialog
import com.rippr.export.ExportFormat
import com.rippr.export.ExportManager
import com.rippr.ui.components.RideMap
import com.rippr.ui.components.StatRow
import com.rippr.ui.ripprViewModel
@Composable
fun TripDetailScreen(
tripId: Long,
onBack: () -> Unit,
viewModel: TripDetailViewModel = ripprViewModel(tripId),
) {
val ui by viewModel.uiState.collectAsStateWithLifecycle()
var renaming by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf(false) }
(ui as? TripDetailUiState.Ready)?.let { ready ->
if (renaming) {
RenameDialog(
initial = ready.trip.name.orEmpty(),
onConfirm = { viewModel.rename(it) },
onDismiss = { renaming = false },
)
}
if (confirmDelete) {
ConfirmDialog(
title = "Delete this ride?",
message = "${ready.summary.pointCount} points over " +
"${Format.distance(ready.summary.distanceM)} will be permanently deleted.",
confirmLabel = "Delete",
onConfirm = { viewModel.delete(onBack) },
onDismiss = { confirmDelete = false },
)
}
}
Column(Modifier.fillMaxSize()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = onBack) { Text("‹ Rides") }
Spacer(Modifier.weight(1f))
if (ui is TripDetailUiState.Ready) {
TextButton(onClick = { renaming = true }) { Text("Rename") }
TextButton(onClick = { confirmDelete = true }) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
}
}
when (val state = ui) {
TripDetailUiState.Loading -> Box(Modifier.fillMaxSize(), Alignment.Center) {
CircularProgressIndicator()
}
TripDetailUiState.NotFound -> Box(Modifier.fillMaxSize(), Alignment.Center) {
Text("This ride no longer exists.", color = MaterialTheme.colorScheme.outline)
}
is TripDetailUiState.Ready -> Content(state)
}
}
}
@Composable
private fun Content(state: TripDetailUiState.Ready) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
Format.tripLabel(state.trip),
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
if (state.trip.name != null) {
Text(
Format.dateTime(state.trip.startedAt),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.outline,
)
}
MapSection(state)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
val s = state.summary
StatRow("Distance", Format.distance(s.distanceM))
StatRow("Moving time", Telemetry.formatDuration(s.movingMillis))
StatRow("Elapsed", Telemetry.formatDuration(s.elapsedMillis))
StatRow("Max speed", Format.speed(s.maxSpeedKmh))
StatRow("Avg moving speed", Format.speed(s.avgMovingSpeedKmh))
StatRow("Elevation gain", Format.elevation(s.elevationGainM))
StatRow("Points", s.pointCount.toString())
}
}
ExportCard(state)
ChartCard("ELEVATION") { ElevationChart(state.elevation) }
ChartCard("SPEED DISTRIBUTION") { SpeedHistogram(state.histogram) }
if (state.isMultiSegment) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(
"${state.segments.size} SEGMENTS",
fontSize = 12.sp,
letterSpacing = 2.sp,
color = MaterialTheme.colorScheme.outline,
)
Spacer(Modifier.height(8.dp))
Text(
"This ride was paused ${state.segments.size - 1} time(s). " +
"The path will show a gap at each pause.",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun ExportCard(state: TripDetailUiState.Ready) {
val context = LocalContext.current
val viewModel: TripDetailViewModel = ripprViewModel(state.trip.id)
fun share(format: ExportFormat) = viewModel.export(context, format) { uri ->
context.startActivity(
android.content.Intent.createChooser(
ExportManager.shareIntent(uri, format),
"Share ride",
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(
"EXPORT",
fontSize = 12.sp,
letterSpacing = 2.sp,
color = MaterialTheme.colorScheme.outline,
)
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { share(ExportFormat.GPX) },
enabled = state.points.isNotEmpty(),
modifier = Modifier.weight(1f),
) { Text("GPX") }
Spacer(Modifier.width(12.dp))
OutlinedButton(
onClick = { share(ExportFormat.GEOJSON) },
enabled = state.points.isNotEmpty(),
modifier = Modifier.weight(1f),
) { Text("GeoJSON") }
}
Spacer(Modifier.height(8.dp))
Text(
"GPX keeps each pause as a separate track segment, so Strava and Garmin " +
"will not draw a line across the gap.",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
@Composable
private fun MapSection(state: TripDetailUiState.Ready) {
val context = LocalContext.current
// Read once per composition of this screen; the switch below drives recomposition.
var mapEnabled by remember { mutableStateOf(Config.mapEnabled(context)) }
Card(Modifier.fillMaxWidth()) {
Column {
if (mapEnabled) {
if (state.points.size >= 2) {
RideMap(
points = state.points,
// osmdroid draws past its measured bounds, which otherwise puts
// tiles on top of the row below it.
modifier = Modifier.fillMaxWidth().height(260.dp).clipToBounds(),
lowColor = MaterialTheme.colorScheme.outline,
highColor = MaterialTheme.colorScheme.primary,
startColor = MaterialTheme.colorScheme.onBackground,
)
} else {
Box(Modifier.fillMaxWidth().height(120.dp), Alignment.Center) {
Text(
"Not enough points to draw a path",
color = MaterialTheme.colorScheme.outline,
)
}
}
}
Row(
Modifier
.fillMaxWidth()
// Opaque, so no tile can bleed through underneath the control.
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Show map",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.outline,
)
Spacer(Modifier.weight(1f))
Switch(
checked = mapEnabled,
onCheckedChange = {
// Off means the composable is never created, so no tile is
// requested at all.
mapEnabled = it
Config.setMapEnabled(context, it)
},
)
}
}
}
}
@Composable
private fun ChartCard(title: String, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(
title,
fontSize = 12.sp,
letterSpacing = 2.sp,
color = MaterialTheme.colorScheme.outline,
)
Spacer(Modifier.height(12.dp))
content()
}
}
}
@Composable
private fun ElevationChart(samples: List<ElevationSample>) {
if (samples.size < 2) {
NotEnoughData(); return
}
val minAlt = samples.minOf { it.altitudeM }
val maxAlt = samples.maxOf { it.altitudeM }
val range = (maxAlt - minAlt).takeIf { it > 0.5 } ?: 1.0 // flat ride: avoid /0
val maxDist = samples.last().distanceM.takeIf { it > 0 } ?: 1.0
val accent = MaterialTheme.colorScheme.primary
Column {
Canvas(Modifier.fillMaxWidth().height(120.dp)) {
val path = Path()
samples.forEachIndexed { i, sample ->
val x = (sample.distanceM / maxDist * size.width).toFloat()
val y = (size.height - (sample.altitudeM - minAlt) / range * size.height).toFloat()
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, accent, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 3f))
}
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween) {
AxisLabel("${minAlt.toInt()} m")
AxisLabel(Format.distance(maxDist))
AxisLabel("${maxAlt.toInt()} m")
}
}
}
@Composable
private fun SpeedHistogram(buckets: List<SpeedBucket>) {
if (buckets.isEmpty()) {
NotEnoughData(); return
}
val maxMillis = buckets.maxOf { it.millis }.coerceAtLeast(1L)
val accent = MaterialTheme.colorScheme.primary
Column {
Canvas(Modifier.fillMaxWidth().height(120.dp)) {
val slot = size.width / buckets.size
val barWidth = slot * 0.7f
buckets.forEachIndexed { i, bucket ->
val h = (bucket.millis.toFloat() / maxMillis) * size.height
drawRect(
color = accent,
topLeft = Offset(i * slot + (slot - barWidth) / 2, size.height - h),
size = androidx.compose.ui.geometry.Size(barWidth, h),
)
}
}
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween) {
AxisLabel("${buckets.first().fromKmh} km/h")
AxisLabel("${buckets.last().toKmh} km/h")
}
}
}
@Composable
private fun AxisLabel(text: String) {
Text(
text,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.outline,
)
}
@Composable
private fun NotEnoughData() {
Box(Modifier.fillMaxWidth().height(80.dp), Alignment.Center) {
Text(
"Not enough data",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.outline,
)
}
}
@Composable
private fun RenameDialog(
initial: String,
onConfirm: (String?) -> Unit,
onDismiss: () -> Unit,
) {
var text by remember { mutableStateOf(initial) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Name this ride") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
placeholder = { Text("Leave blank to use the date") },
)
},
confirmButton = {
TextButton(onClick = {
// Blank collapses to null so the stored value and the date-derived label
// cannot diverge; the repository trims and enforces this too.
onConfirm(text.trim().takeIf { it.isNotEmpty() })
onDismiss()
}) { Text("Save") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}

View File

@@ -0,0 +1,103 @@
package com.rippr.ui.detail
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rippr.data.Segment
import com.rippr.data.TrackPoint
import com.rippr.data.Trip
import com.rippr.data.TripRepository
import com.rippr.export.ExportFormat
import com.rippr.export.ExportManager
import com.rippr.ui.Format
import com.rippr.stats.ElevationSample
import com.rippr.stats.RideStatistics
import com.rippr.stats.RideSummary
import com.rippr.stats.SpeedBucket
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
sealed interface TripDetailUiState {
data object Loading : TripDetailUiState
data object NotFound : TripDetailUiState
data class Ready(
val trip: Trip,
val summary: RideSummary,
val segments: List<Segment>,
val points: List<TrackPoint>,
val elevation: List<ElevationSample>,
val histogram: List<SpeedBucket>,
) : TripDetailUiState {
val isMultiSegment: Boolean get() = segments.size > 1
}
}
class TripDetailViewModel(
private val repo: TripRepository,
private val tripId: Long,
) : ViewModel() {
private val _uiState = MutableStateFlow<TripDetailUiState>(TripDetailUiState.Loading)
val uiState: StateFlow<TripDetailUiState> = _uiState.asStateFlow()
init {
load()
}
/**
* Loads on IO. A three-hour ride is ~21,600 rows, and reading that on the main thread
* would jank the transition into this screen.
*/
private fun load() = viewModelScope.launch {
val state = withContext(Dispatchers.IO) {
val trip = repo.tripById(tripId) ?: return@withContext TripDetailUiState.NotFound
val points = repo.pointsForTrip(tripId)
val segments = repo.segmentsForTrip(tripId)
TripDetailUiState.Ready(
trip = trip,
summary = RideStatistics.compute(points, segments),
segments = segments,
points = points,
elevation = RideStatistics.elevationProfile(points),
histogram = RideStatistics.speedHistogram(points),
)
}
_uiState.value = state
}
fun rename(name: String?) = viewModelScope.launch {
repo.renameTrip(tripId, name)
load()
}
/**
* Writes the export off the main thread — a long ride is several MB of string
* building — then hands the resulting URI back for sharing.
*/
fun export(context: Context, format: ExportFormat, onReady: (Uri) -> Unit) =
viewModelScope.launch {
val ready = _uiState.value as? TripDetailUiState.Ready ?: return@launch
val uri = withContext(Dispatchers.IO) {
ExportManager.write(
context = context.applicationContext,
trip = ready.trip,
segments = ready.segments,
points = ready.points,
name = Format.tripLabel(ready.trip),
fileStamp = Format.fileTimestamp(ready.trip.startedAt),
format = format,
)
}
onReady(uri)
}
fun delete(onDeleted: () -> Unit) = viewModelScope.launch {
repo.deleteTrip(tripId)
onDeleted()
}
}

View File

@@ -0,0 +1,192 @@
package com.rippr.ui.record
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rippr.Telemetry
import com.rippr.ui.Format
import com.rippr.ui.components.BigStat
import com.rippr.ui.components.ConfirmDialog
import com.rippr.ui.components.StatRow
import com.rippr.ui.ripprViewModel
@Composable
fun RecordScreen(
onOpenTrips: () -> Unit,
onRequestPermissions: () -> Unit,
hasLocationPermission: () -> Boolean,
isBatteryExempt: () -> Boolean,
onRequestBatteryExemption: () -> Unit,
viewModel: RecordViewModel = ripprViewModel(),
) {
val ui by viewModel.uiState.collectAsStateWithLifecycle()
var confirmDiscard by remember { mutableStateOf(false) }
if (confirmDiscard) {
val points = ui.trip?.pointCount ?: 0
val distance = Format.distance(ui.trip?.distanceM ?: 0.0)
ConfirmDialog(
title = "Discard this ride?",
message = "$points points recorded over $distance will be permanently deleted.",
confirmLabel = "Discard",
onConfirm = viewModel::discard,
onDismiss = { confirmDiscard = false },
)
}
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"RIPPR",
fontSize = 34.sp,
fontWeight = FontWeight.Black,
letterSpacing = 6.sp,
color = MaterialTheme.colorScheme.primary,
)
TextButton(onClick = onOpenTrips) { Text("Rides") }
}
Spacer(Modifier.height(20.dp))
// Current speed, not max: a max figure only moves when you beat it, which reads
// as a frozen screen while riding steadily.
BigStat("SPEED", "%.0f".format(ui.currentSpeedKmh), "km/h")
Spacer(Modifier.height(16.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
if (ui.isIdle) {
// A resting state, not a zeroed ride — otherwise the screen looks like
// a recording that is going nowhere.
StatRow("Status", "Ready")
StatRow("Last ride", "See Rides")
} else {
val trip = ui.trip!!
StatRow("Distance", Format.distance(trip.distanceM))
StatRow("Elapsed", Telemetry.formatDuration(ui.elapsedMillis))
StatRow("Moving time", Telemetry.formatDuration(trip.movingMillis))
StatRow("Max speed", Format.speed(trip.maxSpeedKmh))
StatRow("Points captured", trip.pointCount.toString())
StatRow("Elevation gain", Format.elevation(trip.elevationGainM))
if (ui.isPaused) StatRow("Status", "Paused")
}
ui.uploadError?.let { StatRow("Last upload error", it) }
}
}
Spacer(Modifier.height(28.dp))
Controls(
ui = ui,
onStart = {
if (hasLocationPermission()) viewModel.start() else onRequestPermissions()
},
onPause = viewModel::pause,
onResume = viewModel::resume,
onStop = viewModel::stop,
onDiscard = { confirmDiscard = true },
)
if (!isBatteryExempt()) {
Spacer(Modifier.height(8.dp))
TextButton(onClick = onRequestBatteryExemption) {
Text("Disable battery optimization (recommended)")
}
}
}
}
@Composable
private fun Controls(
ui: RecordUiState,
onStart: () -> Unit,
onPause: () -> Unit,
onResume: () -> Unit,
onStop: () -> Unit,
onDiscard: () -> Unit,
) {
when {
ui.isIdle -> PrimaryButton("START RECORDING", onStart)
ui.isRecording -> Column(Modifier.fillMaxWidth()) {
PrimaryButton("PAUSE", onPause)
Spacer(Modifier.height(12.dp))
SecondaryButton("STOP", onStop, Modifier.fillMaxWidth())
}
// Discard is offered only while paused. Putting a destructive action next to
// Pause during a live ride invites a gloved mis-tap at speed.
else -> Column(Modifier.fillMaxWidth()) {
PrimaryButton("RESUME", onResume)
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth()) {
SecondaryButton("STOP", onStop, Modifier.weight(1f))
Spacer(Modifier.width(12.dp))
SecondaryButton("DISCARD", onDiscard, Modifier.weight(1f), destructive = true)
}
}
}
}
/** 72dp tall throughout — these get pressed with gloves on. */
@Composable
private fun PrimaryButton(label: String, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth().height(72.dp),
) {
Text(label, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun SecondaryButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
destructive: Boolean = false,
) {
OutlinedButton(
onClick = onClick,
modifier = modifier.height(56.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = if (destructive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onBackground
},
),
) {
Text(label, fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}

View File

@@ -0,0 +1,91 @@
package com.rippr.ui.record
import android.app.Application
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rippr.TrackingService
import com.rippr.LiveTelemetry
import com.rippr.UploadStatus
import com.rippr.data.Trip
import com.rippr.data.TripRepository
import com.rippr.data.TripState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.delay
/** Everything the recording screen draws, in one snapshot. */
data class RecordUiState(
val trip: Trip? = null,
val uploadError: String? = null,
val currentSpeedKmh: Float = 0f,
/** Wall clock since the ride began, ticking every second. */
val elapsedMillis: Long = 0,
) {
val state: TripState? get() = trip?.state
val isIdle: Boolean get() = trip == null
val isRecording: Boolean get() = trip?.state == TripState.RECORDING
val isPaused: Boolean get() = trip?.state == TripState.PAUSED
}
@OptIn(ExperimentalCoroutinesApi::class)
class RecordViewModel(
application: Application,
private val trips: TripRepository,
) : AndroidViewModel(application) {
/**
* Drives the elapsed clock. The Trip row only advances `movingMillis`, and only on a
* ~2 s flush while actually moving, so without this the screen looks frozen at a
* traffic light — which is exactly how it read on the first real ride.
*/
private val ticker = flow {
while (true) {
emit(System.currentTimeMillis())
delay(1_000)
}
}
/**
* State is held here rather than queried on demand, which is what lets the buttons
* decide synchronously what they do. T03 briefly had `onToggleClicked` suspend to ask
* the database "are we recording" — correct, but a button should never have to wait.
*/
val uiState: StateFlow<RecordUiState> =
combine(
trips.observeActiveTrip(),
UploadStatus.lastError,
LiveTelemetry.speedKmh,
ticker,
) { trip, error, speed, now ->
RecordUiState(
trip = trip,
uploadError = error,
currentSpeedKmh = speed,
elapsedMillis = trip?.let { (now - it.startedAt).coerceAtLeast(0L) } ?: 0L,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RecordUiState())
fun start() = send(TrackingService.ACTION_START, foreground = true)
fun pause() = send(TrackingService.ACTION_PAUSE)
fun resume() = send(TrackingService.ACTION_RESUME)
fun stop() = send(TrackingService.ACTION_STOP)
fun discard() = send(TrackingService.ACTION_DISCARD)
private fun send(action: String, foreground: Boolean = false) {
val context = getApplication<Application>()
val intent = Intent(context, TrackingService::class.java).setAction(action)
// Only START may need to bring the service up; the rest target a running service,
// and startForegroundService on an already-foreground service is unnecessary.
if (foreground) {
ContextCompat.startForegroundService(context, intent)
} else {
context.startService(intent)
}
}
}

View File

@@ -0,0 +1,54 @@
package com.rippr.ui.theme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Matches the launcher icon: near-black tarmac with a safety-orange accent.
private val Accent = Color(0xFFFF5722)
private val Ground = Color(0xFF101418)
private val Surface = Color(0xFF181D23)
private val Ink = Color(0xFFF2F5F8)
private val Outline = Color(0xFF66727E)
private val Danger = Color(0xFFE53935)
private val RipprColors = darkColorScheme(
primary = Accent,
onPrimary = Color(0xFF100A06),
secondary = Outline,
background = Ground,
onBackground = Ink,
surface = Surface,
onSurface = Ink,
surfaceVariant = Color(0xFF212832),
onSurfaceVariant = Ink,
outline = Outline,
error = Danger,
onError = Ink,
)
/**
* Dark-only by design. This is read at a glance in daylight with a visor down, and a
* light theme would be actively worse for that.
*/
@Composable
fun RipprTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = RipprColors,
typography = Typography(),
) {
// The Surface is load-bearing, not decoration: it sets LocalContentColor. Without
// it, any Text that does not name a colour falls back to black — which on this
// near-black ground renders as nothing at all.
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
content = content,
)
}
}

View File

@@ -0,0 +1,192 @@
package com.rippr.ui.trips
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rippr.Telemetry
import com.rippr.data.Trip
import com.rippr.ui.Format
import com.rippr.ui.components.ConfirmDialog
import com.rippr.ui.ripprViewModel
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TripsScreen(
onOpenTrip: (Long) -> Unit,
onBack: () -> Unit,
viewModel: TripsViewModel = ripprViewModel(),
) {
val ui by viewModel.uiState.collectAsStateWithLifecycle()
var confirmDelete by remember { mutableStateOf(false) }
var confirmMerge by remember { mutableStateOf(false) }
if (confirmDelete) {
ConfirmDialog(
title = "Delete ${ui.selection.size} ride(s)?",
message = "Every recorded point for these rides will be permanently deleted.",
confirmLabel = "Delete",
onConfirm = viewModel::deleteSelected,
onDismiss = { confirmDelete = false },
)
}
if (confirmMerge) {
val total = ui.trips.filter { it.id in ui.selection }.sumOf { it.distanceM }
ConfirmDialog(
title = "Merge two rides?",
message = "They become one ride of about ${Format.distance(total)}. " +
"The join is kept as a segment break, so no straight line is drawn across it.",
confirmLabel = "Merge",
onConfirm = viewModel::mergeSelected,
onDismiss = { confirmMerge = false },
)
}
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (ui.selecting) {
TextButton(onClick = viewModel::clearSelection) { Text("Cancel") }
Text(
"${ui.selection.size} selected",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(start = 8.dp),
)
} else {
TextButton(onClick = onBack) { Text("‹ Record") }
Text(
"RIDES",
fontSize = 18.sp,
fontWeight = FontWeight.Black,
letterSpacing = 4.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp),
)
}
}
if (ui.selecting) {
Row(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
OutlinedButton(
onClick = { confirmMerge = true },
enabled = ui.canMerge,
modifier = Modifier.weight(1f),
) { Text(if (ui.canMerge) "MERGE" else "MERGE (pick 2)") }
Spacer(Modifier.width(12.dp))
OutlinedButton(
onClick = { confirmDelete = true },
modifier = Modifier.weight(1f),
) { Text("DELETE", color = MaterialTheme.colorScheme.error) }
}
}
when {
!ui.loaded -> Unit
ui.isEmpty -> EmptyState()
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(ui.trips, key = { it.id }) { trip ->
TripRow(
trip = trip,
selected = trip.id in ui.selection,
selecting = ui.selecting,
onClick = {
if (ui.selecting) viewModel.toggleSelection(trip.id)
else onOpenTrip(trip.id)
},
onLongClick = { viewModel.toggleSelection(trip.id) },
)
}
}
}
}
}
@Composable
private fun EmptyState() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("No rides yet", fontWeight = FontWeight.Bold)
Text(
"Head back and hit START RECORDING.",
color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TripRow(
trip: Trip,
selected: Boolean,
selecting: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
border = if (selected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
) {
Column(Modifier.padding(16.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
(if (selecting && selected) "✓ " else "") + Format.tripLabel(trip),
fontWeight = FontWeight.Medium,
)
Text(
Format.distance(trip.distanceM),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text(
"${Telemetry.formatDuration(trip.movingMillis)} moving · max ${Format.speed(trip.maxSpeedKmh)}",
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.outline,
)
}
}
}

View File

@@ -0,0 +1,61 @@
package com.rippr.ui.trips
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rippr.data.Trip
import com.rippr.data.TripRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class TripsUiState(
val trips: List<Trip> = emptyList(),
val loaded: Boolean = false,
val selection: Set<Long> = emptySet(),
) {
val isEmpty: Boolean get() = loaded && trips.isEmpty()
val selecting: Boolean get() = selection.isNotEmpty()
/** Merge combines exactly two rides; anything else is ambiguous. */
val canMerge: Boolean get() = selection.size == 2
}
class TripsViewModel(private val repo: TripRepository) : ViewModel() {
private val selection = MutableStateFlow<Set<Long>>(emptySet())
/**
* Every value shown in the list is already persisted on the Trip row, so this screen
* never touches the point table. That keeps it fast with hundreds of rides.
*/
val uiState: StateFlow<TripsUiState> =
combine(repo.observeCompletedTrips(), selection) { trips, selected ->
// Drop ids that no longer exist, so a deleted trip cannot linger in selection.
val live = selected.intersect(trips.map { it.id }.toSet())
TripsUiState(trips = trips, loaded = true, selection = live)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TripsUiState())
fun toggleSelection(tripId: Long) {
selection.value = selection.value.let {
if (tripId in it) it - tripId else it + tripId
}
}
fun clearSelection() {
selection.value = emptySet()
}
fun deleteSelected() = viewModelScope.launch {
selection.value.forEach { repo.deleteTrip(it) }
selection.value = emptySet()
}
fun mergeSelected() = viewModelScope.launch {
val ids = selection.value.toList()
if (ids.size == 2) repo.mergeTrips(ids[0], ids[1])
selection.value = emptySet()
}
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Track ring. -->
<path
android:pathData="M86.0,54.0 A32.0,32.0 0 1 1 22.0,54.0 A32.0,32.0 0 1 1 86.0,54.0"
android:strokeColor="#66727E"
android:strokeWidth="3.5"
android:strokeAlpha="0.6" />
<!-- Material Symbols `route`, Apache-2.0. Scaled from its 960 box onto the icon canvas. -->
<group
android:translateX="34"
android:translateY="74"
android:scaleX="0.041667"
android:scaleY="0.041667">
<path
android:pathData="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"
android:fillColor="#F2F5F8" />
</group>
<!-- Recording-progress segment: the SVG's dash pattern, cut with trimPath. -->
<path
android:pathData="M86.0,54.0 A32.0,32.0 0 1 1 22.0,54.0 A32.0,32.0 0 1 1 86.0,54.0"
android:strokeColor="#FF5722"
android:strokeWidth="3.5"
android:strokeLineCap="round"
android:trimPathStart="0.3701"
android:trimPathEnd="0.4960" />
</vector>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M86.0,54.0 A32.0,32.0 0 1 1 22.0,54.0 A32.0,32.0 0 1 1 86.0,54.0"
android:strokeColor="#FF000000"
android:strokeWidth="3.5" />
<group
android:translateX="34"
android:translateY="74"
android:scaleX="0.041667"
android:scaleY="0.041667">
<path
android:pathData="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"
android:fillColor="#FF000000" />
</group>
</vector>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<group
android:translateX="2"
android:translateY="22"
android:scaleX="0.020833"
android:scaleY="0.020833">
<path
android:pathData="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"
android:fillColor="#FFFFFFFF" />
</group>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#101418</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Rippr</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Rippr" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content />

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Exports are transient handoffs, so they live in cache where the system can
reclaim them. RipprApp clears the directory on start. -->
<cache-path name="exports" path="exports/" />
</paths>

View File

@@ -0,0 +1,181 @@
package com.rippr
import com.rippr.data.TrackPoint
import com.rippr.stats.RideStatistics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AccumulatorTest {
private var nextId = 1L
private fun point(
segmentId: Long = 1,
ts: Long,
lat: Double = 51.0,
speed: Float = 40f,
alt: Double = 1000.0,
) = TrackPoint(
id = nextId++,
tripId = 1,
segmentId = segmentId,
timestamp = ts,
latitude = lat,
longitude = -114.0,
speedKmh = speed,
altitudeM = alt,
)
private fun run(n: Int, segmentId: Long = 1, startTs: Long = 0, startLat: Double = 51.0) =
(0 until n).map {
point(segmentId, startTs + it * 1_000L, lat = startLat + it * 0.0001)
}
// --- The cross-batch anchor: the subtle bug this class exists to avoid ----
@Test
fun `distance across many small batches matches one big batch`() {
val points = run(100)
val batched = Accumulator().apply {
points.chunked(7).forEach { fold(it) }
}
val single = Accumulator().apply { fold(points) }
assertEquals(
"losing the anchor between flushes silently under-reports distance",
single.distanceM,
batched.distanceM,
1e-6,
)
}
@Test
fun `accumulated distance agrees with the batch statistics`() {
val points = run(100)
val acc = Accumulator().apply { points.chunked(25).forEach { fold(it) } }
val summary = RideStatistics.compute(points)
assertEquals(summary.distanceM, acc.distanceM, 0.5)
assertEquals(summary.movingMillis, acc.movingMillis)
assertEquals(summary.maxSpeedKmh, acc.maxSpeedKmh, 1e-6f)
assertEquals(summary.pointCount, acc.pointCount)
}
// --- Segment boundaries ---------------------------------------------------
@Test
fun `distance does not span a segment boundary`() {
// Second segment starts a full degree of latitude away — a trailered gap.
val acc = Accumulator()
acc.fold(run(5, segmentId = 1, startLat = 51.0))
acc.fold(run(5, segmentId = 2, startTs = 600_000, startLat = 52.0))
assertTrue(
"the ~111 km gap leaked into distance: ${acc.distanceM} m",
acc.distanceM < 200.0,
)
}
@Test
fun `onSegmentChanged clears the anchor even within one batch stream`() {
val acc = Accumulator()
acc.fold(run(5, segmentId = 1, startLat = 51.0))
acc.onSegmentChanged()
acc.fold(run(5, segmentId = 1, startTs = 600_000, startLat = 52.0))
assertTrue("anchor was not cleared: ${acc.distanceM} m", acc.distanceM < 200.0)
}
// --- Moving time -----------------------------------------------------------
@Test
fun `moving time ignores samples below the speed noise floor`() {
val acc = Accumulator()
acc.fold((0 until 30).map { point(ts = it * 1_000L, speed = 40f) })
acc.fold((30 until 60).map { point(ts = it * 1_000L, speed = 0f) })
assertEquals(29_000L, acc.movingMillis)
}
@Test
fun `a long gap between fixes does not inject phantom moving time`() {
val acc = Accumulator()
acc.fold(listOf(point(ts = 0, speed = 90f), point(ts = 120_000, lat = 51.001, speed = 90f)))
assertEquals(0L, acc.movingMillis)
}
// --- Elevation -------------------------------------------------------------
@Test
fun `elevation gain is visible before the climb ends`() {
// A steady ascent that never turns back down. Reading the committed total alone
// would report zero, so the live figure has to include the pending run.
val acc = Accumulator()
acc.fold((0 until 101).map { point(ts = it * 1_000L, alt = 1000.0 + it) })
assertEquals(100.0, acc.elevationGain(), 6.0)
}
@Test
fun `a parked bike does not accumulate phantom climbing`() {
val rng = kotlin.random.Random(seed = 7)
val acc = Accumulator()
acc.fold((0 until 600).map {
point(ts = it * 1_000L, speed = 0f, alt = 1000.0 + rng.nextDouble(-8.0, 8.0))
})
assertTrue("phantom climbing: ${acc.elevationGain()} m", acc.elevationGain() < 40.0)
}
// --- Restore ---------------------------------------------------------------
@Test
fun `restore continues from persisted totals rather than starting over`() {
val acc = Accumulator()
acc.restore(
distanceM = 5_000.0,
movingMillis = 300_000,
maxSpeedKmh = 95f,
elevationGainM = 120.0,
pointCount = 600,
lastPoint = null,
)
acc.fold(run(11))
assertTrue("distance went backwards", acc.distanceM > 5_000.0)
assertEquals(5_111.2, acc.distanceM, 5.0)
assertEquals(611, acc.pointCount)
assertEquals(95f, acc.maxSpeedKmh, 1e-6f)
assertTrue("persisted elevation was lost", acc.elevationGain() >= 120.0)
}
@Test
fun `restore keeps a higher persisted max speed`() {
val acc = Accumulator()
acc.restore(0.0, 0, maxSpeedKmh = 150f, elevationGainM = 0.0, pointCount = 0, lastPoint = null)
acc.fold(run(5))
assertEquals(150f, acc.maxSpeedKmh, 1e-6f)
}
@Test
fun `reset clears everything`() {
val acc = Accumulator()
acc.fold(run(20))
acc.reset()
assertEquals(0.0, acc.distanceM, 1e-9)
assertEquals(0L, acc.movingMillis)
assertEquals(0f, acc.maxSpeedKmh, 1e-9f)
assertEquals(0, acc.pointCount)
assertEquals(0.0, acc.elevationGain(), 1e-9)
}
@Test
fun `folding an empty batch is a no-op`() {
val acc = Accumulator()
acc.fold(emptyList())
assertEquals(0, acc.pointCount)
assertEquals(0.0, acc.distanceM, 1e-9)
}
}

View File

@@ -0,0 +1,98 @@
package com.rippr
import com.rippr.data.TrackPoint
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class TelemetryTest {
@Test
fun `converts meters per second to kmh`() {
assertEquals(36f, Telemetry.msToKmh(10f), 0.001f)
assertEquals(0f, Telemetry.msToKmh(0f), 0.001f)
}
@Test
fun `gps jitter below the noise floor reports as zero`() {
assertEquals(0f, Telemetry.sanitizeSpeedKmh(0.9f), 0.001f)
assertEquals(0f, Telemetry.sanitizeSpeedKmh(Float.NaN), 0.001f)
assertEquals(0f, Telemetry.sanitizeSpeedKmh(-5f), 0.001f)
}
@Test
fun `real speeds pass through untouched`() {
assertEquals(97.3f, Telemetry.sanitizeSpeedKmh(97.3f), 0.001f)
}
@Test
fun `rejects fixes worse than the accuracy budget`() {
assertTrue(Telemetry.isUsableFix(12f))
assertTrue("unknown accuracy must not be discarded", Telemetry.isUsableFix(0f))
assertFalse(Telemetry.isUsableFix(120f))
}
@Test
fun `formats duration as hh mm ss`() {
assertEquals("00:00:00", Telemetry.formatDuration(0))
assertEquals("00:00:00", Telemetry.formatDuration(-1))
assertEquals("00:01:05", Telemetry.formatDuration(65_000))
assertEquals("02:03:04", Telemetry.formatDuration(7_384_000))
}
@Test
fun `encodes a batch as the documented payload shape`() {
val points = listOf(
TrackPoint(
id = 7,
tripId = 3,
segmentId = 5,
timestamp = 1_700_000_000_000,
latitude = 51.0447,
longitude = -114.0719,
speedKmh = 88.5f,
altitudeM = 1045.0,
accuracyM = 4.2f,
bearingDeg = 271.5f
)
)
val json = JSONObject(Telemetry.encodeBatch("device-abc", points))
assertEquals("device-abc", json.getString("device_id"))
val first = json.getJSONArray("points").getJSONObject(0)
assertEquals(7L, first.getLong("id"))
assertEquals(3L, first.getLong("trip_id"))
assertEquals(5L, first.getLong("segment_id"))
assertEquals(1_700_000_000_000L, first.getLong("ts"))
assertEquals(51.0447, first.getDouble("lat"), 1e-6)
assertEquals(-114.0719, first.getDouble("lon"), 1e-6)
assertEquals(88.5, first.getDouble("speed_kmh"), 1e-4)
}
@Test
fun `a batch spanning two segments labels each point individually`() {
// A batch is drawn by id and can cross a boundary, so identity must travel with
// the point rather than the batch.
val points = listOf(
TrackPoint(id = 1, tripId = 3, segmentId = 5, timestamp = 1, latitude = 51.0,
longitude = -114.0, speedKmh = 40f, altitudeM = 1000.0),
TrackPoint(id = 2, tripId = 3, segmentId = 6, timestamp = 2, latitude = 51.1,
longitude = -114.0, speedKmh = 40f, altitudeM = 1000.0),
)
val array = JSONObject(Telemetry.encodeBatch("d", points)).getJSONArray("points")
assertEquals(5L, array.getJSONObject(0).getLong("segment_id"))
assertEquals(6L, array.getJSONObject(1).getLong("segment_id"))
assertEquals(3L, array.getJSONObject(0).getLong("trip_id"))
}
@Test
fun `encodes an empty batch without failing`() {
val json = JSONObject(Telemetry.encodeBatch("d", emptyList()))
assertEquals(0, json.getJSONArray("points").length())
}
}

View File

@@ -0,0 +1,150 @@
package com.rippr
import com.rippr.data.RideStats
import com.rippr.data.TrackPoint
import com.rippr.data.TrackPointDao
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/** In-memory DAO so the uploader can be exercised without a device. */
private class FakeDao : TrackPointDao {
val points = mutableListOf<TrackPoint>()
private var nextId = 1L
override suspend fun insertPoint(point: TrackPoint): Long {
val id = nextId++
points += point.copy(id = id)
return id
}
override suspend fun insertPoints(points: List<TrackPoint>): List<Long> =
points.map { insertPoint(it) }
override suspend fun forTrip(tripId: Long): List<TrackPoint> = points.filter { it.tripId == tripId }
override suspend fun forSegment(segmentId: Long): List<TrackPoint> =
points.filter { it.segmentId == segmentId }
override suspend fun lastInSegment(segmentId: Long): TrackPoint? =
points.lastOrNull { it.segmentId == segmentId }
override suspend fun countForTrip(tripId: Long): Int = points.count { it.tripId == tripId }
override fun observeTripStats(tripId: Long): Flow<RideStats> = flowOf(RideStats.EMPTY)
override fun observePendingUpload(tripId: Long): Flow<Int> =
flowOf(points.count { it.tripId == tripId && !it.synced })
override suspend fun reparent(oldTripId: Long, newTripId: Long) {
points.replaceAll { if (it.tripId == oldTripId) it.copy(tripId = newTripId) else it }
}
override suspend fun getAllPoints(): List<TrackPoint> = points.toList()
override suspend fun getUnsyncedPoints(limit: Int): List<TrackPoint> =
points.filter { !it.synced }.take(limit)
override suspend fun markSynced(ids: List<Long>) {
ids.forEach { id ->
val i = points.indexOfFirst { it.id == id }
if (i >= 0) points[i] = points[i].copy(synced = true)
}
}
override suspend fun countUnsynced(): Int = points.count { !it.synced }
override suspend fun clearAll() = points.clear()
}
class TelemetryUploaderTest {
private lateinit var server: MockWebServer
private lateinit var dao: FakeDao
@Before
fun setUp() {
server = MockWebServer().also { it.start() }
dao = FakeDao()
}
@After
fun tearDown() {
server.shutdown()
}
private fun uploader(endpoint: String = server.url("/ingest").toString()) =
TelemetryUploader(dao, endpoint, "test-device")
private suspend fun seed(count: Int) {
repeat(count) { i ->
dao.insertPoint(
TrackPoint(
tripId = 1,
segmentId = 1,
timestamp = 1_700_000_000_000 + i,
latitude = 51.0 + i * 1e-5,
longitude = -114.0,
speedKmh = 60f,
altitudeM = 1000.0
)
)
}
}
@Test
fun `uploads pending points and marks them synced`() = runTest {
seed(3)
server.enqueue(MockResponse().setResponseCode(200))
val result = uploader().uploadPending()
assertTrue(result is TelemetryUploader.Result.Success)
assertEquals(3, (result as TelemetryUploader.Result.Success).uploaded)
assertEquals(0, dao.countUnsynced())
val body = JSONObject(server.takeRequest().body.readUtf8())
assertEquals("test-device", body.getString("device_id"))
assertEquals(3, body.getJSONArray("points").length())
}
@Test
fun `server error leaves points unsynced for a later retry`() = runTest {
seed(2)
server.enqueue(MockResponse().setResponseCode(500))
val result = uploader().uploadPending()
assertEquals(TelemetryUploader.Result.Failed, result)
assertEquals("points must survive a failed upload", 2, dao.countUnsynced())
}
@Test
fun `retry after a failure succeeds and drains the backlog`() = runTest {
seed(2)
server.enqueue(MockResponse().setResponseCode(503))
uploader().uploadPending()
assertEquals(2, dao.countUnsynced())
server.enqueue(MockResponse().setResponseCode(200))
uploader().uploadPending()
assertEquals(0, dao.countUnsynced())
}
@Test
fun `blank endpoint disables uploading without touching the data`() = runTest {
seed(2)
assertEquals(TelemetryUploader.Result.Disabled, uploader(endpoint = "").uploadPending())
assertEquals(2, dao.countUnsynced())
assertEquals(0, server.requestCount)
}
@Test
fun `nothing pending means no request is made`() = runTest {
val result = uploader().uploadPending()
assertEquals(TelemetryUploader.Result.Success(0), result)
assertEquals(0, server.requestCount)
}
}

View File

@@ -0,0 +1,211 @@
package com.rippr.export
import com.rippr.data.Segment
import com.rippr.data.TrackPoint
import com.rippr.data.Trip
import com.rippr.data.TripState
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.w3c.dom.Element
import javax.xml.parsers.DocumentBuilderFactory
class RideExportTest {
private var nextId = 1L
private val trip = Trip(
id = 1,
startedAt = 1_700_000_000_000,
endedAt = 1_700_000_600_000,
state = TripState.COMPLETED,
distanceM = 12_345.6,
maxSpeedKmh = 118.4f,
)
private fun point(segmentId: Long, i: Int) = TrackPoint(
id = nextId++,
tripId = 1,
segmentId = segmentId,
timestamp = 1_700_000_000_000 + i * 1_000L,
latitude = 51.0447 + i * 0.0001,
longitude = -114.0719,
speedKmh = 72f,
altitudeM = 1045.0,
)
private fun ride(segmentCount: Int, perSegment: Int): Triple<List<Segment>, List<TrackPoint>, Int> {
val segments = (1..segmentCount).map {
Segment(id = it.toLong(), tripId = 1, startedAt = 0, endedAt = 1)
}
val points = segments.flatMap { s -> (0 until perSegment).map { point(s.id, it) } }
return Triple(segments, points, segmentCount * perSegment)
}
private fun parse(xml: String) = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
// --- GPX -----------------------------------------------------------------
@Test
fun `gpx is well-formed and carries the expected structure`() {
val (segments, points, _) = ride(segmentCount = 2, perSegment = 5)
val doc = parse(RideExport.gpx(trip, segments, points, "Morning loop"))
assertEquals("gpx", doc.documentElement.tagName)
assertEquals("1.1", doc.documentElement.getAttribute("version"))
assertEquals(1, doc.getElementsByTagName("trk").length)
}
@Test
fun `one trkseg per segment, so pauses survive the round trip`() {
val (segments, points, _) = ride(segmentCount = 3, perSegment = 4)
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
assertEquals(3, doc.getElementsByTagName("trkseg").length)
}
@Test
fun `every raw point is exported with no decimation`() {
val (segments, points, total) = ride(segmentCount = 2, perSegment = 250)
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
assertEquals(
"decimation must never reach an export",
total,
doc.getElementsByTagName("trkpt").length,
)
}
@Test
fun `timestamps are ISO 8601 in UTC`() {
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
val time = doc.getElementsByTagName("time").item(1).textContent
assertEquals("2023-11-14T22:13:20Z", time)
assertTrue("must be UTC with a Z suffix", time.endsWith("Z"))
}
@Test
fun `coordinates and elevation land on the right attributes`() {
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
val trkpt = doc.getElementsByTagName("trkpt").item(0) as Element
assertEquals("51.0447000", trkpt.getAttribute("lat"))
assertEquals("-114.0719000", trkpt.getAttribute("lon"))
assertEquals("1045.0", doc.getElementsByTagName("ele").item(0).textContent)
}
@Test
fun `speed is exported in metres per second`() {
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
// 72 km/h is exactly 20 m/s.
assertEquals("20.0", doc.getElementsByTagName("speed").item(0).textContent)
}
@Test
fun `a hostile trip name still produces valid xml`() {
val (segments, points, _) = ride(segmentCount = 1, perSegment = 2)
val nasty = """Sam & Dave's <ride> "fast" """
// Would throw if the name were interpolated unescaped.
val doc = parse(RideExport.gpx(trip, segments, points, nasty))
assertEquals(nasty, doc.getElementsByTagName("name").item(0).textContent)
}
@Test
fun `escaping covers every xml metacharacter`() {
assertEquals(
"&amp;&lt;&gt;&quot;&apos;",
RideExport.escapeXml("""&<>"'"""),
)
}
@Test
fun `an empty ride still produces a valid document`() {
val doc = parse(RideExport.gpx(trip, emptyList(), emptyList(), "Empty"))
assertEquals("gpx", doc.documentElement.tagName)
assertEquals(0, doc.getElementsByTagName("trkpt").length)
assertEquals(0, doc.getElementsByTagName("trkseg").length)
}
@Test
fun `points whose segment is missing are still exported`() {
val orphan = listOf(point(segmentId = 99, i = 0))
val doc = parse(RideExport.gpx(trip, emptyList(), orphan, "Ride"))
assertEquals("no point may be silently dropped", 1, doc.getElementsByTagName("trkpt").length)
}
// --- GeoJSON ---------------------------------------------------------------
@Test
fun `geojson coordinates are longitude first`() {
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
val json = JSONObject(RideExport.geoJson(trip, segments, points, "Ride"))
val coords = json.getJSONArray("features")
.getJSONObject(0)
.getJSONObject("geometry")
.getJSONArray("coordinates")
.getJSONArray(0)
assertEquals("longitude must come first", -114.0719, coords.getDouble(0), 1e-6)
assertEquals(51.0447, coords.getDouble(1), 1e-6)
assertEquals(1045.0, coords.getDouble(2), 1e-6)
}
@Test
fun `geojson has one LineString feature per segment`() {
val (segments, points, _) = ride(segmentCount = 3, perSegment = 4)
val json = JSONObject(RideExport.geoJson(trip, segments, points, "Ride"))
val features = json.getJSONArray("features")
assertEquals(3, features.length())
assertEquals("LineString", features.getJSONObject(0).getJSONObject("geometry").getString("type"))
}
@Test
fun `geojson exports every raw point`() {
val (segments, points, total) = ride(segmentCount = 2, perSegment = 120)
val json = JSONObject(RideExport.geoJson(trip, segments, points, "Ride"))
val exported = (0 until json.getJSONArray("features").length()).sumOf { i ->
json.getJSONArray("features").getJSONObject(i)
.getJSONObject("geometry").getJSONArray("coordinates").length()
}
assertEquals(total, exported)
}
@Test
fun `a hostile name is escaped in json too`() {
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
val json = JSONObject(RideExport.geoJson(trip, segments, points, """He said "go" \ fast"""))
assertEquals("""He said "go" \ fast""", json.getJSONObject("properties").getString("name"))
}
@Test
fun `geojson of an empty ride parses`() {
val json = JSONObject(RideExport.geoJson(trip, emptyList(), emptyList(), "Empty"))
assertEquals(0, json.getJSONArray("features").length())
}
}

View File

@@ -0,0 +1,210 @@
package com.rippr.geo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.abs
class GeoTest {
private val calgary = LatLon(51.0447, -114.0719)
private val edmonton = LatLon(53.5461, -113.4938)
// --- Haversine ----------------------------------------------------------
@Test
fun `matches a known long-distance reference`() {
// Calgary to Edmonton, great-circle. Cross-checked by hand: 2.5014° of latitude
// is ~278.1 km, and 0.5781° of longitude at ~52.3° is ~39.3 km, giving
// sqrt(278.1² + 39.3²) ≈ 280.9 km. (Road distance is ~300 km — not the same thing.)
val d = Geo.haversineMeters(calgary, edmonton)
assertEquals(280_900.0, d, 280_900.0 * 0.005)
}
@Test
fun `one degree of latitude is about 111 km anywhere`() {
val atEquator = Geo.haversineMeters(0.0, 0.0, 1.0, 0.0)
val atLatitude60 = Geo.haversineMeters(60.0, 0.0, 61.0, 0.0)
assertEquals(111_195.0, atEquator, 200.0)
assertEquals("latitude spacing does not vary with longitude", atEquator, atLatitude60, 200.0)
}
@Test
fun `a degree of longitude shrinks with latitude`() {
val atEquator = Geo.haversineMeters(0.0, 0.0, 0.0, 1.0)
val atLatitude60 = Geo.haversineMeters(60.0, 0.0, 60.0, 1.0)
// cos(60°) = 0.5, so it should be about half.
assertEquals(atEquator / 2, atLatitude60, 500.0)
}
@Test
fun `identical points are zero distance`() {
assertEquals(0.0, Geo.haversineMeters(calgary, calgary), 1e-9)
}
@Test
fun `short hops between consecutive fixes stay accurate`() {
// ~11 m north, the scale of a 2 Hz fix at road speed.
val d = Geo.haversineMeters(51.0447, -114.0719, 51.04480, -114.0719)
assertEquals(11.1, d, 0.5)
}
@Test
fun `distance is symmetric`() {
assertEquals(
Geo.haversineMeters(calgary, edmonton),
Geo.haversineMeters(edmonton, calgary),
1e-6,
)
}
@Test
fun `handles an antimeridian crossing without exploding`() {
// 0.02° apart in longitude, straddling +/-180.
val d = Geo.haversineMeters(0.0, 179.99, 0.0, -179.99)
assertTrue("expected a short hop, got $d m", d < 3_000)
}
// --- Douglas-Peucker ----------------------------------------------------
private fun line(n: Int) = (0 until n).map { LatLon(51.0 + it * 0.001, -114.0) }
@Test
fun `a straight line collapses to its endpoints`() {
val simplified = Geo.simplify(line(50), epsilonMeters = 5.0)
assertEquals(2, simplified.size)
assertEquals(line(50).first(), simplified.first())
assertEquals(line(50).last(), simplified.last())
}
@Test
fun `endpoints always survive`() {
val zigzag = (0 until 30).map {
LatLon(51.0 + it * 0.001, -114.0 + if (it % 2 == 0) 0.0 else 0.002)
}
val simplified = Geo.simplify(zigzag, epsilonMeters = 5.0)
assertEquals(zigzag.first(), simplified.first())
assertEquals(zigzag.last(), simplified.last())
}
@Test
fun `deviation above epsilon is preserved`() {
// Middle point sits ~110 m off the line between its neighbours.
val points = listOf(
LatLon(51.000, -114.0),
LatLon(51.001, -113.999),
LatLon(51.002, -114.0),
)
assertEquals(3, Geo.simplify(points, epsilonMeters = 5.0).size)
}
@Test
fun `deviation below epsilon is dropped`() {
val points = listOf(
LatLon(51.000, -114.0),
LatLon(51.001, -114.00001), // under a metre off the line
LatLon(51.002, -114.0),
)
assertEquals(2, Geo.simplify(points, epsilonMeters = 5.0).size)
}
@Test
fun `epsilon of zero returns the input untouched`() {
val input = line(20)
assertEquals(input, Geo.simplify(input, epsilonMeters = 0.0))
}
@Test
fun `inputs of two or fewer are returned as-is`() {
assertEquals(0, Geo.simplify(emptyList(), 5.0).size)
assertEquals(1, Geo.simplify(line(1), 5.0).size)
assertEquals(2, Geo.simplify(line(2), 5.0).size)
}
@Test
fun `handles a full ride without stack overflow and stays fast`() {
// Three hours at 2 Hz, the real scale this has to survive.
val ride = (0 until 21_600).map {
LatLon(51.0 + it * 0.00001, -114.0 + kotlin.math.sin(it / 100.0) * 0.001)
}
val started = System.nanoTime()
val simplified = Geo.simplify(ride, epsilonMeters = 5.0)
val elapsedMs = (System.nanoTime() - started) / 1_000_000
assertTrue("simplification should reduce the point count", simplified.size < ride.size)
assertEquals(ride.first(), simplified.first())
assertEquals(ride.last(), simplified.last())
assertTrue("took ${elapsedMs}ms, expected well under a second", elapsedMs < 1_000)
}
// --- Perpendicular distance ---------------------------------------------
@Test
fun `a point on the segment has zero perpendicular distance`() {
val d = Geo.perpendicularDistanceMeters(
LatLon(51.001, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0),
)
assertTrue("expected ~0, got $d", abs(d) < 0.01)
}
@Test
fun `a point beyond the end measures to the endpoint, not the infinite line`() {
// Directly past the segment's end along the same bearing.
val d = Geo.perpendicularDistanceMeters(
LatLon(51.003, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0),
)
assertEquals("should be the ~111 m to the endpoint", 111.0, d, 5.0)
}
@Test
fun `a degenerate segment falls back to point distance`() {
val a = LatLon(51.0, -114.0)
val d = Geo.perpendicularDistanceMeters(LatLon(51.001, -114.0), a, a)
assertEquals(111.0, d, 5.0)
}
// --- Bounds -------------------------------------------------------------
@Test
fun `bounds is null for an empty path`() {
assertNull(Geo.bounds(emptyList()))
}
@Test
fun `bounds spans mixed-sign coordinates`() {
val b = Geo.bounds(
listOf(LatLon(-10.0, -20.0), LatLon(30.0, 40.0), LatLon(5.0, 0.0)),
)!!
assertEquals(-10.0, b.minLat, 1e-9)
assertEquals(30.0, b.maxLat, 1e-9)
assertEquals(-20.0, b.minLon, 1e-9)
assertEquals(40.0, b.maxLon, 1e-9)
assertEquals(10.0, b.centerLat, 1e-9)
}
@Test
fun `a stationary ride reports degenerate bounds`() {
val b = Geo.bounds(List(10) { calgary })!!
assertTrue("map auto-fit must special-case this", b.isDegenerate)
}
@Test
fun `a real path is not degenerate`() {
assertTrue(!Geo.bounds(line(10))!!.isDegenerate)
}
// --- Path length --------------------------------------------------------
@Test
fun `path length sums consecutive hops`() {
val points = line(11) // ten hops of 0.001 degrees latitude
assertEquals(10 * 111.19, Geo.pathLengthMeters(points), 20.0)
}
@Test
fun `path length of fewer than two points is zero`() {
assertEquals(0.0, Geo.pathLengthMeters(emptyList()), 1e-9)
assertEquals(0.0, Geo.pathLengthMeters(listOf(calgary)), 1e-9)
}
}

View File

@@ -0,0 +1,265 @@
package com.rippr.stats
import com.rippr.data.Segment
import com.rippr.data.TrackPoint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.random.Random
class RideStatisticsTest {
private var nextId = 1L
private fun point(
segmentId: Long = 1,
ts: Long,
lat: Double = 51.0,
lon: Double = -114.0,
speed: Float = 50f,
alt: Double = 1000.0,
) = TrackPoint(
id = nextId++,
tripId = 1,
segmentId = segmentId,
timestamp = ts,
latitude = lat,
longitude = lon,
speedKmh = speed,
altitudeM = alt,
)
/** A straight northward run: [n] fixes one second apart, 0.0001° (~11 m) each. */
private fun straightRun(n: Int, segmentId: Long = 1, startTs: Long = 0, speed: Float = 40f) =
(0 until n).map {
point(segmentId, startTs + it * 1_000L, lat = 51.0 + it * 0.0001, speed = speed)
}
// --- Distance -----------------------------------------------------------
@Test
fun `distance matches the summed haversine hops`() {
val summary = RideStatistics.compute(straightRun(11))
// Ten hops of 0.0001 degrees latitude, ~11.12 m each.
assertEquals(111.2, summary.distanceM, 3.0)
}
@Test
fun `distance never spans a pause`() {
// Two segments 100 km apart — a rider who trailered between them.
val first = straightRun(5, segmentId = 1, startTs = 0)
val second = (0 until 5).map {
point(segmentId = 2, ts = 600_000 + it * 1_000L, lat = 52.0 + it * 0.0001)
}
val summary = RideStatistics.compute(first + second)
// Two runs of ~44.5 m each; the ~111 km gap must not appear.
assertTrue(
"gap leaked into distance: ${summary.distanceM} m",
summary.distanceM < 200.0,
)
}
@Test
fun `single point has zero distance and no NaN`() {
val summary = RideStatistics.compute(listOf(point(ts = 0)))
assertEquals(0.0, summary.distanceM, 1e-9)
assertEquals(0f, summary.avgMovingSpeedKmh, 1e-9f)
assertEquals(1, summary.pointCount)
}
@Test
fun `empty input returns the zero summary`() {
assertEquals(RideSummary.EMPTY, RideStatistics.compute(emptyList()))
}
// --- Elevation: the regression that matters most -------------------------
@Test
fun `stationary noisy altitude yields near-zero elevation gain`() {
// A parked bike for ten minutes with realistic +/-8 m GPS altitude wander.
//
// Measured: a naive "sum every delta over the threshold" implementation reported
// 1498 m of climbing here. Smoothing plus reversal hysteresis brings it to ~30 m.
// The bound below is a regression guard, not a target — real GPS altitude error is
// correlated rather than uniform, so the true figure is best judged on a real ride.
val rng = Random(seed = 42)
val points = (0 until 600).map {
point(ts = it * 1_000L, speed = 0f, alt = 1000.0 + rng.nextDouble(-8.0, 8.0))
}
val summary = RideStatistics.compute(points)
assertTrue(
"phantom climbing: ${summary.elevationGainM} m over a parked bike",
summary.elevationGainM < 35.0,
)
}
@Test
fun `a genuine climb is recorded`() {
val points = (0 until 101).map {
point(ts = it * 1_000L, lat = 51.0 + it * 0.0001, alt = 1000.0 + it)
}
val summary = RideStatistics.compute(points)
assertEquals(100.0, summary.elevationGainM, 5.0)
assertEquals(0.0, summary.elevationLossM, 5.0)
}
@Test
fun `a descent counts as loss, not gain`() {
val points = (0 until 101).map {
point(ts = it * 1_000L, lat = 51.0 + it * 0.0001, alt = 1100.0 - it)
}
val summary = RideStatistics.compute(points)
assertEquals(100.0, summary.elevationLossM, 5.0)
assertEquals(0.0, summary.elevationGainM, 5.0)
}
@Test
fun `an out-and-back records both gain and loss`() {
val up = (0 until 51).map { point(ts = it * 1_000L, alt = 1000.0 + it) }
val down = (0 until 51).map { point(ts = 51_000L + it * 1_000L, alt = 1050.0 - it) }
val summary = RideStatistics.compute(up + down)
assertEquals(50.0, summary.elevationGainM, 5.0)
assertEquals(50.0, summary.elevationLossM, 5.0)
}
// --- Moving vs elapsed time ---------------------------------------------
@Test
fun `moving time excludes time below the speed noise floor`() {
val moving = (0 until 60).map { point(ts = it * 1_000L, speed = 40f) }
val stopped = (60 until 120).map { point(ts = it * 1_000L, speed = 0f) }
val summary = RideStatistics.compute(moving + stopped)
assertEquals("~59 s of movement", 59_000.0, summary.movingMillis.toDouble(), 2_000.0)
assertEquals(119_000L, summary.elapsedMillis)
assertTrue(summary.stoppedMillis > 55_000L)
}
@Test
fun `a long gap between fixes does not inject phantom moving time`() {
// Two fixes two minutes apart — a tunnel. Without the cap this would count as
// 120 s of movement at the last known speed.
val points = listOf(
point(ts = 0, speed = 90f),
point(ts = 120_000, lat = 51.001, speed = 90f),
)
assertEquals(0L, RideStatistics.compute(points).movingMillis)
}
@Test
fun `elapsed time prefers closed segment spans when available`() {
val points = straightRun(5, startTs = 1_000)
val segments = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = 10_000))
assertEquals(10_000L, RideStatistics.compute(points, segments).elapsedMillis)
}
@Test
fun `elapsed falls back to point timestamps when a segment is still open`() {
val points = straightRun(5, startTs = 1_000) // 1000..5000
val segments = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = null))
assertEquals(4_000L, RideStatistics.compute(points, segments).elapsedMillis)
}
// --- Average speed -------------------------------------------------------
@Test
fun `average speed uses distance over moving time, not the sample mean`() {
// 100 fixes 1 s apart, each 0.0001 deg (~11.12 m) => ~1101 m over ~99 s
// => ~40 km/h. Half the samples report a bogus 0 speed to prove the mean of
// samples is not what is being computed.
val points = (0 until 100).map {
point(ts = it * 1_000L, lat = 51.0 + it * 0.0001, speed = 40f)
}
val summary = RideStatistics.compute(points)
assertEquals(40.0f, summary.avgMovingSpeedKmh, 2.0f)
}
@Test
fun `average speed is zero rather than NaN when nothing moved`() {
val points = (0 until 10).map { point(ts = it * 1_000L, speed = 0f) }
val summary = RideStatistics.compute(points)
assertEquals(0f, summary.avgMovingSpeedKmh, 1e-9f)
assertTrue("NaN would render literally on screen", !summary.avgMovingSpeedKmh.isNaN())
}
@Test
fun `max speed is the highest sample`() {
val points = listOf(
point(ts = 0, speed = 40f),
point(ts = 1_000, speed = 118.4f),
point(ts = 2_000, speed = 60f),
)
assertEquals(118.4f, RideStatistics.compute(points).maxSpeedKmh, 0.001f)
}
// --- Histogram ------------------------------------------------------------
@Test
fun `histogram measures time in band, not sample count`() {
val points = listOf(
point(ts = 0, speed = 5f),
point(ts = 1_000, speed = 15f), // 1 s in 10-20
point(ts = 2_000, speed = 15f), // 1 s in 10-20
point(ts = 3_000, speed = 95f), // 1 s in 90-100
)
val buckets = RideStatistics.speedHistogram(points, bucketKmh = 10)
assertEquals(2_000L, buckets.first { it.fromKmh == 10 }.millis)
assertEquals(1_000L, buckets.first { it.fromKmh == 90 }.millis)
}
@Test
fun `histogram is empty for degenerate input`() {
assertTrue(RideStatistics.speedHistogram(emptyList()).isEmpty())
assertTrue(RideStatistics.speedHistogram(listOf(point(ts = 0))).isEmpty())
}
// --- Elevation profile ----------------------------------------------------
@Test
fun `profile plots altitude against cumulative distance`() {
val points = (0 until 5).map {
point(ts = it * 1_000L, lat = 51.0 + it * 0.0001, alt = 1000.0 + it * 10)
}
val profile = RideStatistics.elevationProfile(points)
assertEquals(5, profile.size)
assertEquals(0.0, profile.first().distanceM, 1e-9)
assertEquals(1040.0, profile.last().altitudeM, 1e-9)
assertTrue("distance must increase", profile.last().distanceM > profile.first().distanceM)
}
@Test
fun `profile does not accrue distance across a pause`() {
val first = (0 until 3).map { point(segmentId = 1, ts = it * 1_000L, lat = 51.0 + it * 0.0001) }
val second = (0 until 3).map {
point(segmentId = 2, ts = 600_000 + it * 1_000L, lat = 52.0 + it * 0.0001)
}
val profile = RideStatistics.elevationProfile(first + second)
assertTrue(
"the 111 km gap leaked into the profile: ${profile.last().distanceM}",
profile.last().distanceM < 200.0,
)
}
@Test
fun `profile downsamples a long ride`() {
val points = (0 until 21_600).map {
point(ts = it * 500L, lat = 51.0 + it * 0.00001, alt = 1000.0)
}
val profile = RideStatistics.elevationProfile(points, maxSamples = 200)
assertTrue("expected ~200 samples, got ${profile.size}", profile.size <= 201)
assertEquals(points.last().altitudeM, profile.last().altitudeM, 1e-9)
}
@Test
fun `profile handles empty and single-point input`() {
assertTrue(RideStatistics.elevationProfile(emptyList()).isEmpty())
assertEquals(1, RideStatistics.elevationProfile(listOf(point(ts = 0))).size)
}
}

View File

@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
}

View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""Builds the logo concept review page, inlining each generated SVG."""
import pathlib
import re
import gen_logos
HERE = pathlib.Path(__file__).parent
LOGOS = HERE / "logos"
NOTES = {
"apex": (
"Strongest speed signal in the set, and the gauge reads as an instrument cluster "
"rather than a generic dial. Verified weakness: the tick marks disappear by 24px "
"and the needle loses its point, so the status-bar version needs the ticks removed."
),
"route": (
"Describes what the app records rather than what it rides on — the most specific "
"idea of the three. The accent segment reads as recording progress. "
"Ambiguous at 24px — the trace could be almost anything."
),
"heading": (
"Cleanest silhouette of the outlined concepts and the second-best small-size "
"performer. The accent arc marks where the arrow points, so ring and arrow read as "
"one object, and it reduces to a single flat color more cleanly than the other two — which makes it the natural pick for the status-bar icon."
),
}
# The status-bar icon must be a flat single-color silhouette; Android discards
# color and alpha nuance. Worth previewing since it rules some concepts out.
MONO_CSS = """
.mono svg *, .mono svg { fill: #fff !important; stroke: #fff !important; }
"""
def inner_svg(key: str) -> str:
raw = (LOGOS / f"rippr-{key}.svg").read_text()
return re.sub(r"^<svg[^>]*>|</svg>$", "", raw).strip()
def card(letter: str, key: str, name: str, desc: str) -> str:
body = inner_svg(key)
def mark(size, extra_class="", guide=False):
g = '<span class="safe"></span>' if guide else ""
return (
f'<span class="mark {extra_class}" style="--s:{size}px">'
f'<svg viewBox="0 0 108 108" width="{size}" height="{size}" '
f'aria-hidden="true">{body}</svg>{g}</span>'
)
sizes = "".join(
f'<div class="spec"><div class="tile sm">{mark(n)}</div>'
f'<span class="cap">{n}px</span></div>'
for n in (48, 32, 24)
)
return f"""
<article class="card">
<header class="card-hd">
<span class="letter">{letter}</span>
<div>
<h2>{name}</h2>
<p class="desc">{desc}</p>
</div>
</header>
<div class="stage">{mark(168, guide=True)}</div>
<div class="specs">
<div class="spec">
<div class="tile mask-circle">{mark(72)}</div>
<span class="cap">circle mask</span>
</div>
<div class="spec">
<div class="tile mask-squircle">{mark(72)}</div>
<span class="cap">squircle</span>
</div>
<div class="spec">
<div class="tile sm mono">{mark(24)}</div>
<span class="cap">status bar</span>
</div>
{sizes}
</div>
<p class="note">{NOTES[key]}</p>
</article>"""
def main():
gen_logos.main()
cards = "".join(
card(chr(65 + i), key, name, desc)
for i, (key, (name, desc, _)) in enumerate(gen_logos.CONCEPTS.items())
)
html = TEMPLATE.replace("{{CARDS}}", cards).replace("{{MONO_CSS}}", MONO_CSS)
out = HERE / "logo-review.html"
out.write_text(html)
print(f"wrote {out}")
TEMPLATE = """<title>Rippr — logo concepts</title>
<style>
:root {
--ground: #F2F3F5;
--surface: #FFFFFF;
--line: #D8DCE1;
--ink: #161A1F;
--ink-dim: #5C6874;
--accent: #E8480F;
--tile: #101418;
--tile-line: #2A323B;
--radius: 14px;
}
:root:not([data-theme="light"]) { color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #0C0F13;
--surface: #151A20;
--line: #262E37;
--ink: #E8EDF2;
--ink-dim: #8B98A6;
--accent: #FF5722;
--tile: #06080A;
--tile-line: #222A33;
}
}
:root[data-theme="dark"] {
--ground: #0C0F13;
--surface: #151A20;
--line: #262E37;
--ink: #E8EDF2;
--ink-dim: #8B98A6;
--accent: #FF5722;
--tile: #06080A;
--tile-line: #222A33;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 56px 24px 80px; }
.eyebrow {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; letter-spacing: .18em; text-transform: uppercase;
color: var(--accent); margin: 0 0 12px;
}
h1 {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: clamp(30px, 5vw, 46px); font-weight: 700; letter-spacing: -.02em;
margin: 0 0 14px; text-wrap: balance;
}
.lede { max-width: 62ch; color: var(--ink-dim); font-size: 17px; margin: 0; }
.lede strong { color: var(--ink); font-weight: 600; }
.grid { display: grid; gap: 22px; margin-top: 44px; }
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 22px;
display: grid; gap: 18px;
}
.card-hd { display: flex; gap: 14px; align-items: flex-start; }
.letter {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-weight: 700; color: var(--accent);
border: 1px solid var(--accent); border-radius: 6px;
width: 26px; height: 26px; display: grid; place-items: center; flex: none;
}
.card h2 { font-size: 19px; margin: 0; letter-spacing: -.01em; }
.desc { margin: 2px 0 0; color: var(--ink-dim); font-size: 14px; }
.stage {
background: var(--tile);
border: 1px solid var(--tile-line);
border-radius: 10px;
display: grid; place-items: center;
padding: 30px 0;
}
.mark { position: relative; display: grid; place-items: center; }
.mark svg { display: block; }
/* Adaptive-icon safe zone: content outside the centre 66/108 circle can be cropped. */
.safe {
position: absolute; inset: 0; margin: auto;
width: calc(var(--s) * 0.611); height: calc(var(--s) * 0.611);
border: 1px dashed var(--tile-line); border-radius: 50%;
pointer-events: none;
}
.specs { display: flex; flex-wrap: wrap; gap: 14px; }
.spec { display: grid; gap: 6px; justify-items: center; }
.tile {
background: var(--tile); border: 1px solid var(--tile-line);
width: 88px; height: 88px; display: grid; place-items: center; border-radius: 10px;
}
.tile.sm { width: 64px; height: 64px; }
.mask-circle { border-radius: 50%; overflow: hidden; }
.mask-squircle { border-radius: 26%; overflow: hidden; }
.cap {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
color: var(--ink-dim); font-variant-numeric: tabular-nums;
}
{{MONO_CSS}}
.note {
margin: 0; font-size: 14.5px; color: var(--ink-dim);
border-left: 2px solid var(--line); padding-left: 14px; max-width: 68ch;
}
.foot {
margin-top: 44px; padding-top: 24px; border-top: 1px solid var(--line);
color: var(--ink-dim); font-size: 14.5px; max-width: 68ch;
}
.foot h3 { color: var(--ink); font-size: 15px; margin: 0 0 8px; }
.foot code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px; background: var(--ground); border: 1px solid var(--line);
padding: 1px 5px; border-radius: 4px; color: var(--ink);
}
@media (min-width: 760px) { .grid { grid-template-columns: repeat(2, 1fr); } }
</style>
<div class="wrap">
<p class="eyebrow">Rippr · icon direction</p>
<h1>Three marks for a ride recorder</h1>
<p class="lede">
Each concept is drawn on Android's <strong>108-unit adaptive-icon canvas</strong>, so
whichever you pick converts straight to a vector drawable with no redrawing. The dashed
circle marks the safe zone — anything outside it can be cropped by an OEM mask. The
small tiles are the real test: a launcher icon is seen at 48px and the status-bar icon
is a flat 24px silhouette.
</p>
<div class="grid">{{CARDS}}</div>
<div class="foot">
<h3>What happens after you pick one</h3>
<p>
The chosen mark becomes <code>ic_launcher_foreground.xml</code> as a vector drawable,
paired with <code>#101418</code> as the adaptive background, replacing the placeholder
compass. Its monochrome variant replaces
<code>android.R.drawable.ic_menu_compass</code> in the recording notification. Source
SVGs live in <code>design/logos/</code>; regenerate with
<code>python3 design/gen_logos.py</code>.
</p>
</div>
</div>
"""
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
Generates the Rippr logo concept set as standalone SVGs, then rasterises them.
Everything is authored on Android's adaptive-icon canvas (108x108) with the key
shapes kept inside the 66-unit safe circle centred at (54,54), so a chosen concept
converts to res/drawable/ic_launcher_foreground.xml without re-drawing anything.
The figurative geometry is Google's Material Symbols (Apache-2.0, vendored under
design/material/) rather than hand-approximated curves. Only the framing — gauge
arcs, badge grounds, rings — is authored here, because arcs and circles are the
shapes that can be computed exactly instead of eyeballed.
"""
import math
import pathlib
import re
import subprocess
HERE = pathlib.Path(__file__).parent
OUT = HERE / "logos"
GLYPHS = HERE / "material"
PNG = HERE / "preview"
CX = CY = 54.0
INK = "#F2F5F8"
ACCENT = "#FF5722"
DEEP = "#101418"
DIM = "#66727E"
# Material glyphs are drawn in a 960 box spanning y = -960..0.
GLYPH_BOX = 960.0
def glyph_path(name: str) -> str:
"""Extract the single path's `d` attribute from a vendored Material Symbol."""
svg = (GLYPHS / f"{name}.svg").read_text()
m = re.search(r'<path[^>]*\sd="([^"]+)"', svg)
if not m:
raise ValueError(f"no path found in {name}.svg")
return m.group(1)
def glyph(name: str, size: float, cx: float = CX, cy: float = CY, fill: str = INK,
rotate: float = 0.0) -> str:
"""Place a Material glyph centred on (cx, cy), scaled to `size` units square."""
s = size / GLYPH_BOX
# Map x: 0..960 -> centred; y: -960..0 -> centred.
tx = cx - size / 2
ty = cy + size / 2
rot = f' transform="rotate({rotate} {cx} {cy})"' if rotate else ""
return (
f'<g{rot}><path d="{glyph_path(name)}" fill="{fill}" '
f'transform="translate({tx:.3f},{ty:.3f}) scale({s:.6f})"/></g>'
)
def polar(cx, cy, r, deg):
a = math.radians(deg)
return cx + r * math.cos(a), cy - r * math.sin(a)
def arc(cx, cy, r, start_deg, end_deg):
x0, y0 = polar(cx, cy, r, start_deg)
x1, y1 = polar(cx, cy, r, end_deg)
large = 1 if abs(end_deg - start_deg) > 180 else 0
sweep = 0 if end_deg > start_deg else 1
return f"M{x0:.3f},{y0:.3f} A{r:.3f},{r:.3f} 0 {large} {sweep} {x1:.3f},{y1:.3f}"
# --------------------------------------------------------------------------
# A — Apex. Material's `speed` gauge inside a computed tachometer sweep, with the
# last quarter of the sweep in accent as a redline.
# --------------------------------------------------------------------------
def concept_apex():
parts = [
f'<path d="{arc(CX, CY, 44, 215, -35)}" fill="none" stroke="{DIM}" '
f'stroke-width="5" stroke-linecap="round" opacity="0.55"/>',
f'<path d="{arc(CX, CY, 44, 25, -35)}" fill="none" stroke="{ACCENT}" '
f'stroke-width="5" stroke-linecap="round"/>',
]
for i in range(11):
deg = 215 - i * 25
major = i % 5 == 0
r0 = 34 if major else 37
x0, y0 = polar(CX, CY, r0, deg)
x1, y1 = polar(CX, CY, 39.5, deg)
parts.append(
f'<line x1="{x0:.2f}" y1="{y0:.2f}" x2="{x1:.2f}" y2="{y1:.2f}" '
f'stroke="{INK}" stroke-width="{2.6 if major else 1.6}" '
f'stroke-linecap="round" opacity="{0.85 if major else 0.45}"/>'
)
parts.append(glyph("speed", 46))
return "".join(parts)
# --------------------------------------------------------------------------
# D — Route. Material's `route` glyph ringed by a thin border, reading as a map
# trace rather than an instrument.
# --------------------------------------------------------------------------
def concept_route():
return (
f'<circle cx="{CX}" cy="{CY}" r="43" fill="none" stroke="{DIM}" '
f'stroke-width="3.5" opacity="0.6"/>'
+ glyph("route", 54, fill=INK)
+ f'<circle cx="{CX}" cy="{CY}" r="43" fill="none" stroke="{ACCENT}" '
f'stroke-width="3.5" stroke-linecap="round" '
f'stroke-dasharray="34 236" stroke-dashoffset="-100"/>'
)
# --------------------------------------------------------------------------
# E — Heading. `navigation` is the cleanest silhouette Material offers and the
# only one that stays unambiguous as a flat 24px status-bar icon.
# --------------------------------------------------------------------------
def concept_heading():
heading = 20 # degrees clockwise from north
# The accent segment marks where the arrow points, so the two read as one object
# rather than a ring with an arrow dropped inside it.
centre = 90 - heading
parts = [
f'<circle cx="{CX}" cy="{CY}" r="42" fill="none" stroke="{DIM}" '
f'stroke-width="2.5" opacity="0.7"/>',
f'<path d="{arc(CX, CY, 42, centre + 26, centre - 26)}" fill="none" '
f'stroke="{ACCENT}" stroke-width="5.5" stroke-linecap="round"/>',
# The glyph's ink sits below its box centre, so nudge up to centre it optically.
glyph("navigation", 48, cy=CY - 2, rotate=heading),
]
return "".join(parts)
CONCEPTS = {
"apex": ("Apex", "Tachometer sweep with redline", concept_apex),
"route": ("Route", "Recorded trace inside a ring", concept_route),
"heading": ("Heading", "Navigation arrow with heading arc", concept_heading),
}
def svg_doc(body, bg=None):
bg_rect = f'<rect width="108" height="108" fill="{bg}"/>' if bg else ""
return (
'<svg xmlns="http://www.w3.org/2000/svg" width="108" height="108" '
f'viewBox="0 0 108 108">{bg_rect}{body}</svg>'
)
def rasterise():
"""Render each concept large so the output can actually be reviewed."""
PNG.mkdir(parents=True, exist_ok=True)
for key in CONCEPTS:
src = OUT / f"rippr-{key}-onbg.svg"
dst = PNG / f"{key}.png"
subprocess.run(
["rsvg-convert", "-w", "512", "-h", "512", str(src), "-o", str(dst)],
check=True,
)
# Strips for review. `montage` needs a font for its auto-labels and fails without
# one, so compose with +append instead — no text involved.
def strip(px: int, name: str):
subprocess.run(
["magick"]
+ [str(PNG / f"{k}.png") for k in CONCEPTS]
+ ["-resize", f"{px}x{px}", "-background", "#05070A", "-splice", "8x0",
"+append", str(PNG / name)],
check=True,
)
strip(512, "contact-sheet.png")
# True launcher and status-bar sizes: where detail either survives or turns to mush.
strip(48, "small-48.png")
strip(24, "small-24.png")
def main():
OUT.mkdir(parents=True, exist_ok=True)
for key, (_name, _desc, fn) in CONCEPTS.items():
body = fn()
(OUT / f"rippr-{key}.svg").write_text(svg_doc(body))
(OUT / f"rippr-{key}-onbg.svg").write_text(svg_doc(body, bg=DEEP))
print(f"wrote rippr-{key}.svg")
rasterise()
print(f"rendered PNGs -> {PNG}")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="108" viewBox="0 0 108 108"><rect width="108" height="108" fill="#101418"/><path d="M17.957,79.237 A44.000,44.000 0 1 1 90.043,79.237" fill="none" stroke="#66727E" stroke-width="5" stroke-linecap="round" opacity="0.55"/><path d="M93.878,35.405 A44.000,44.000 0 0 1 90.043,79.237" fill="none" stroke="#FF5722" stroke-width="5" stroke-linecap="round"/><line x1="26.15" y1="73.50" x2="21.64" y2="76.66" stroke="#F2F5F8" stroke-width="2.6" stroke-linecap="round" opacity="0.85"/><line x1="17.56" y1="60.42" x2="15.10" y2="60.86" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="18.26" y1="44.42" x2="15.85" y2="43.78" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="25.66" y1="30.22" x2="23.74" y2="28.61" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="38.36" y1="20.47" x2="37.31" y2="18.20" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="54.00" y1="20.00" x2="54.00" y2="14.50" stroke="#F2F5F8" stroke-width="2.6" stroke-linecap="round" opacity="0.85"/><line x1="69.64" y1="20.47" x2="70.69" y2="18.20" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="82.34" y1="30.22" x2="84.26" y2="28.61" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="89.74" y1="44.42" x2="92.15" y2="43.78" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="90.44" y1="60.42" x2="92.90" y2="60.86" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="81.85" y1="73.50" x2="86.36" y2="76.66" stroke="#F2F5F8" stroke-width="2.6" stroke-linecap="round" opacity="0.85"/><g><path d="M480-316.5q38-.5 56-27.5l224-336-336 224q-27 18-28.5 55t22.5 61q24 24 62 23.5Zm0-483.5q59 0 113.5 16.5T696-734l-76 48q-33-17-68.5-25.5T480-720q-133 0-226.5 93.5T160-400q0 42 11.5 83t32.5 77h552q23-38 33.5-79t10.5-85q0-36-8.5-70T766-540l48-76q30 47 47.5 100T880-406q1 57-13 109t-41 99q-11 18-30 28t-40 10H204q-21 0-40-10t-30-28q-26-45-40-95.5T80-400q0-83 31.5-155.5t86-127Q252-737 325-768.5T480-800Zm7 313Z" fill="#F2F5F8" transform="translate(31.000,77.000) scale(0.047917)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="108" viewBox="0 0 108 108"><path d="M17.957,79.237 A44.000,44.000 0 1 1 90.043,79.237" fill="none" stroke="#66727E" stroke-width="5" stroke-linecap="round" opacity="0.55"/><path d="M93.878,35.405 A44.000,44.000 0 0 1 90.043,79.237" fill="none" stroke="#FF5722" stroke-width="5" stroke-linecap="round"/><line x1="26.15" y1="73.50" x2="21.64" y2="76.66" stroke="#F2F5F8" stroke-width="2.6" stroke-linecap="round" opacity="0.85"/><line x1="17.56" y1="60.42" x2="15.10" y2="60.86" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="18.26" y1="44.42" x2="15.85" y2="43.78" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="25.66" y1="30.22" x2="23.74" y2="28.61" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="38.36" y1="20.47" x2="37.31" y2="18.20" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="54.00" y1="20.00" x2="54.00" y2="14.50" stroke="#F2F5F8" stroke-width="2.6" stroke-linecap="round" opacity="0.85"/><line x1="69.64" y1="20.47" x2="70.69" y2="18.20" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="82.34" y1="30.22" x2="84.26" y2="28.61" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="89.74" y1="44.42" x2="92.15" y2="43.78" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="90.44" y1="60.42" x2="92.90" y2="60.86" stroke="#F2F5F8" stroke-width="1.6" stroke-linecap="round" opacity="0.45"/><line x1="81.85" y1="73.50" x2="86.36" y2="76.66" stroke="#F2F5F8" stroke-width="2.6" stroke-linecap="round" opacity="0.85"/><g><path d="M480-316.5q38-.5 56-27.5l224-336-336 224q-27 18-28.5 55t22.5 61q24 24 62 23.5Zm0-483.5q59 0 113.5 16.5T696-734l-76 48q-33-17-68.5-25.5T480-720q-133 0-226.5 93.5T160-400q0 42 11.5 83t32.5 77h552q23-38 33.5-79t10.5-85q0-36-8.5-70T766-540l48-76q30 47 47.5 100T880-406q1 57-13 109t-41 99q-11 18-30 28t-40 10H204q-21 0-40-10t-30-28q-26-45-40-95.5T80-400q0-83 31.5-155.5t86-127Q252-737 325-768.5T480-800Zm7 313Z" fill="#F2F5F8" transform="translate(31.000,77.000) scale(0.047917)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="108" viewBox="0 0 108 108"><rect width="108" height="108" fill="#101418"/><circle cx="54.0" cy="54.0" r="42" fill="none" stroke="#66727E" stroke-width="2.5" opacity="0.7"/><path d="M49.610,12.230 A42.000,42.000 0 0 1 84.212,24.824" fill="none" stroke="#FF5722" stroke-width="5.5" stroke-linecap="round"/><g transform="rotate(20 54.0 52.0)"><path d="m200-120-40-40 320-720 320 720-40 40-280-120-280 120Zm84-124 196-84 196 84-196-440-196 440Zm196-84Z" fill="#F2F5F8" transform="translate(30.000,76.000) scale(0.050000)"/></g></svg>

After

Width:  |  Height:  |  Size: 589 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="108" viewBox="0 0 108 108"><circle cx="54.0" cy="54.0" r="42" fill="none" stroke="#66727E" stroke-width="2.5" opacity="0.7"/><path d="M49.610,12.230 A42.000,42.000 0 0 1 84.212,24.824" fill="none" stroke="#FF5722" stroke-width="5.5" stroke-linecap="round"/><g transform="rotate(20 54.0 52.0)"><path d="m200-120-40-40 320-720 320 720-40 40-280-120-280 120Zm84-124 196-84 196 84-196-440-196 440Zm196-84Z" fill="#F2F5F8" transform="translate(30.000,76.000) scale(0.050000)"/></g></svg>

After

Width:  |  Height:  |  Size: 542 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="108" viewBox="0 0 108 108"><rect width="108" height="108" fill="#101418"/><circle cx="54.0" cy="54.0" r="43" fill="none" stroke="#66727E" stroke-width="3.5" opacity="0.6"/><g><path d="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z" fill="#F2F5F8" transform="translate(27.000,81.000) scale(0.056250)"/></g><circle cx="54.0" cy="54.0" r="43" fill="none" stroke="#FF5722" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="34 236" stroke-dashoffset="-100"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="108" viewBox="0 0 108 108"><circle cx="54.0" cy="54.0" r="43" fill="none" stroke="#66727E" stroke-width="3.5" opacity="0.6"/><g><path d="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z" fill="#F2F5F8" transform="translate(27.000,81.000) scale(0.056250)"/></g><circle cx="54.0" cy="54.0" r="43" fill="none" stroke="#FF5722" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="34 236" stroke-dashoffset="-100"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,7 @@
# Material Symbols
Source glyphs fetched from Google Fonts (Material Symbols, Outlined, 24px).
Licensed under the Apache License 2.0 — https://www.apache.org/licenses/LICENSE-2.0
These are vendored as reference geometry for the Rippr icon set. Glyph viewBox is
"0 -960 960 960" (x: 0..960, y: -960..0).

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m300-300 280-80 80-280-280 80-80 280Zm180-120q-25 0-42.5-17.5T420-480q0-25 17.5-42.5T480-540q25 0 42.5 17.5T540-480q0 25-17.5 42.5T480-420Zm0 340q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q133 0 226.5-93.5T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 133 93.5 226.5T480-160Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 547 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m136-240-56-56 212-212q35-35 85-35t85 35l46 46q12 12 28.5 12t28.5-12l178-178H640v-80h240v240h-80v-103L621-405q-35 35-85 35t-85-35l-47-47q-11-11-28-11t-28 11L136-240Z"/></svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m200-120-40-40 320-720 320 720-40 40-280-120-280 120Zm84-124 196-84 196 84-196-440-196 440Zm196-84Z"/></svg>

After

Width:  |  Height:  |  Size: 205 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"/></svg>

After

Width:  |  Height:  |  Size: 705 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-316.5q38-.5 56-27.5l224-336-336 224q-27 18-28.5 55t22.5 61q24 24 62 23.5Zm0-483.5q59 0 113.5 16.5T696-734l-76 48q-33-17-68.5-25.5T480-720q-133 0-226.5 93.5T160-400q0 42 11.5 83t32.5 77h552q23-38 33.5-79t10.5-85q0-36-8.5-70T766-540l48-76q30 47 47.5 100T880-406q1 57-13 109t-41 99q-11 18-30 28t-40 10H204q-21 0-40-10t-30-28q-26-45-40-95.5T80-400q0-83 31.5-155.5t86-127Q252-737 325-768.5T480-800Zm7 313Z"/></svg>

After

Width:  |  Height:  |  Size: 511 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-480Zm80 240q100 0 170-70t70-170q0-101-73.5-170.5T550-720q-48 0-93 11t-87 33l100 40q41 17 65.5 52.5T560-504q0 60-41.5 102T418-360H162q-2 24-2 54.5v65.5h400ZM176-440h240q27 0 45.5-18.5T480-504q0-19-10.5-34.5T440-562l-148-60q-42 37-71.5 84T176-440Zm384 280H160q-33 0-56.5-23.5T80-240v-90q0-98 37-183.5t100.5-149Q281-726 367-763t183-37q68 0 128 25t105 68.5Q828-663 854-605t26 125q0 66-25 124.5t-68.5 102Q743-210 684.5-185T560-160Z"/></svg>

After

Width:  |  Height:  |  Size: 537 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M160-200q-66 0-113-47T0-360q0-57 36.5-101t93.5-55l-28-24H0v-60h180l100 60 160-60h126l-62-80H400v-80h142l84 108 134-68v120h-92l70 92q15-6 30.5-9t31.5-3q66 0 113 47t47 113q0 66-47 113t-113 47q-66 0-113-47t-47-113q0-27 9.5-52.5T676-460l-20-24-136 204H400l-80-70q-5 63-51 106.5T160-200Zm0-80q33 0 56.5-23.5T240-360q0-33-23.5-56.5T160-440q-33 0-56.5 23.5T80-360q0 33 23.5 56.5T160-280Zm294-240-144 54 144-54h130-130Zm346 240q33 0 56.5-23.5T880-360q0-33-23.5-56.5T800-440q-33 0-56.5 23.5T720-360q0 33 23.5 56.5T800-280Zm-322-80 106-160H454l-144 54 120 106h48Z"/></svg>

After

Width:  |  Height:  |  Size: 659 B

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Emits the Android vector drawables for the chosen Route mark.
This is a real conversion, not a copy: VectorDrawable understands a subset of SVG.
Three things in the source SVG have no direct equivalent and are translated here.
<circle> -> two arc commands, since only <path> exists
stroke-dasharray -> trimPathStart / trimPathEnd, which cut one segment out of
a path; the dash offset becomes the trim start, expressed
as a fraction of the circle's circumference
opacity -> strokeAlpha / fillAlpha
Run after gen_logos.py; writes straight into app/src/main/res/drawable/.
"""
import math
import pathlib
import gen_logos
HERE = pathlib.Path(__file__).parent
RES = HERE.parent / "app" / "src" / "main" / "res" / "drawable"
CX = CY = 54.0
# The standalone logo draws its ring at r=43, which is outside the adaptive-icon safe
# circle (r=33) — a launcher mask crops it clean off, taking the accent segment with
# it. The launcher build therefore shrinks the whole mark to fit inside the safe zone.
DESIGN_RING_R = 43.0
RING_R = 32.0
GLYPH_SIZE = 40.0
# Must match concept_route() in gen_logos.py.
DASH_LEN = 34.0
DASH_OFFSET = 100.0
# Trim fractions describe an angular span, so they are derived from the design radius
# and stay correct at any ring size.
DESIGN_CIRCUMFERENCE = 2 * math.pi * DESIGN_RING_R
# Starts at due east and sweeps clockwise, matching how a browser renders <circle>,
# so the trim fractions below land the accent in the same place as the preview.
RING_PATH = (
f"M{CX + RING_R},{CY} "
f"A{RING_R},{RING_R} 0 1 1 {CX - RING_R},{CY} "
f"A{RING_R},{RING_R} 0 1 1 {CX + RING_R},{CY}"
)
LAUNCHER = """<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Track ring. -->
<path
android:pathData="{ring}"
android:strokeColor="{dim}"
android:strokeWidth="3.5"
android:strokeAlpha="0.6" />
<!-- Material Symbols `route`, Apache-2.0. Scaled from its 960 box onto the icon canvas. -->
<group
android:translateX="{tx}"
android:translateY="{ty}"
android:scaleX="{scale}"
android:scaleY="{scale}">
<path
android:pathData="{glyph}"
android:fillColor="{ink}" />
</group>
<!-- Recording-progress segment: the SVG's dash pattern, cut with trimPath. -->
<path
android:pathData="{ring}"
android:strokeColor="{accent}"
android:strokeWidth="3.5"
android:strokeLineCap="round"
android:trimPathStart="{trim_start:.4f}"
android:trimPathEnd="{trim_end:.4f}" />
</vector>
"""
# Status-bar icons are flattened to a single color with only alpha preserved, so the
# ring and accent are dropped — at 24dp they would collapse into a grey smudge.
MONO = """<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<group
android:translateX="{tx}"
android:translateY="{ty}"
android:scaleX="{scale}"
android:scaleY="{scale}">
<path
android:pathData="{glyph}"
android:fillColor="#FFFFFFFF" />
</group>
</vector>
"""
# Themed ("monochrome") launcher icons keep the 108 canvas and are tinted by the
# system, so this one keeps the ring for a fuller shape at launcher size.
THEMED = """<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="{ring}"
android:strokeColor="#FF000000"
android:strokeWidth="3.5" />
<group
android:translateX="{tx}"
android:translateY="{ty}"
android:scaleX="{scale}"
android:scaleY="{scale}">
<path
android:pathData="{glyph}"
android:fillColor="#FF000000" />
</group>
</vector>
"""
def main():
glyph = gen_logos.glyph_path("route")
launcher_scale = GLYPH_SIZE / gen_logos.GLYPH_BOX
common = dict(
ring=RING_PATH,
glyph=glyph,
dim=gen_logos.DIM,
ink=gen_logos.INK,
accent=gen_logos.ACCENT,
tx=f"{CX - GLYPH_SIZE / 2:g}",
ty=f"{CY + GLYPH_SIZE / 2:g}",
scale=f"{launcher_scale:.6f}",
trim_start=DASH_OFFSET / DESIGN_CIRCUMFERENCE,
trim_end=(DASH_OFFSET + DASH_LEN) / DESIGN_CIRCUMFERENCE,
)
RES.mkdir(parents=True, exist_ok=True)
(RES / "ic_launcher_foreground.xml").write_text(LAUNCHER.format(**common))
(RES / "ic_launcher_monochrome.xml").write_text(THEMED.format(**common))
# The status-bar glyph fills a 24dp box edge to edge, minus a little padding.
mono_size = 20.0
mono_scale = mono_size / gen_logos.GLYPH_BOX
(RES / "ic_stat_rippr.xml").write_text(
MONO.format(
glyph=glyph,
tx=f"{(24 - mono_size) / 2:g}",
ty=f"{(24 + mono_size) / 2:g}",
scale=f"{mono_scale:.6f}",
)
)
for name in ("ic_launcher_foreground", "ic_launcher_monochrome", "ic_stat_rippr"):
print(f"wrote {RES / (name + '.xml')}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,288 @@
# Architecture
The decisions that shaped Rippr, and — more usefully — *why*. Several exist because
something specific went wrong; those are marked.
---
## The recording pipeline
```
onLocationResult (main looper)
│ stamp tripId + segmentId, sanitize speed, reject bad-accuracy fixes
▼
Channel(UNLIMITED) ──► single writer coroutine (Dispatchers.IO)
│ batches of 25, flushed every ~2s, under writeMutex
├─► insertPoints()
└─► Accumulator.fold() → updateAggregates() on the Trip row
```
### Why an unbounded Channel
The location callback must never block. Writing to SQLite from inside `onLocationResult`
would couple GPS delivery to disk latency, and a slow flash write would drop fixes. The
channel decouples them: the callback's `trySend` never suspends, and back pressure stalls
the *writer*, not the sensor.
**Do not replace this with a direct write.** It is the single most load-bearing decision in
the recording path.
### Why points are stamped at creation
Each `TrackPoint` carries its `tripId` and `segmentId` from the moment it is built in the
callback, not looked up at write time.
This makes pausing safe: a fix already queued when the rider pauses lands in the segment it
was actually recorded during, whatever order the writes happen in.
> **Correction worth remembering.** The v2 plan originally claimed that closing a segment
> before draining the channel would misfile points. That was wrong — stamping at creation
> already prevents it. Draining before closing is still done, but as robustness (not
> leaving points unwritten while a ride idles), not correctness. If anyone ever refactors
> stamping into a write-time lookup, this guarantee disappears.
### Why batched writes
A 2 Hz stream would otherwise mean two transactions per second for hours. Batching to 25
points or 2 seconds — whichever comes first — cuts that to one write per two seconds.
### Why WAL journal mode
A ride is unrecoverable if writes are lost to a crash, but `fsync` on every insert at 2 Hz
burns battery. WAL with `NORMAL` sync survives app crashes; only an OS-level crash can lose
the last few points. That is the accepted trade.
### Why the wake lock
A foreground service alone does not guarantee CPU time with the screen off on all OEMs. A
`PARTIAL_WAKE_LOCK` keeps the writer coroutine running. It is **released on pause** — a
lunch stop has no business holding the CPU awake — and re-acquired on resume.
---
## Data model
```
Trip 1───* Segment 1───* TrackPoint
```
```kotlin
Trip(id, startedAt, endedAt?, name?, state,
distanceM, movingMillis, maxSpeedKmh, elevationGainM, pointCount)
Segment(id, tripId, startedAt, endedAt?)
TrackPoint(id, tripId, segmentId, timestamp, lat, lon, speedKmh, altitudeM,
accuracyM, bearingDeg, synced)
```
### Why Segment exists
Three of the four things v1 lacked — reset, pause, and trips — were the same missing
concept. Segments are what make pause *correct*: they represent a pause-free stretch of
recording, so every consumer naturally leaves a gap where the rider stopped.
They propagate into three places, and all three would be wrong without them:
- **Distance** accumulates only within a segment
- **Map polylines** are drawn one per segment, never joined
- **GPX** emits one `<trkseg>` per segment, which is exactly how GPX represents a gap
### Why both `endedAt` and `state`
`endedAt == null` distinguishes active from finished, but cannot distinguish RECORDING from
PAUSED. The service needs that difference to decide what to do when the OS restarts it
mid-ride.
### Why aggregates are denormalised onto Trip
**minSdk 26 means SQLite 3.18, which has no window functions** — no `LAG`, no `OVER`. There
is simply no SQL expression for "distance from the previous point". So consecutive-point
maths happens in Kotlin, in the writer loop that already touches every point, and the
result is persisted on the Trip row.
This also keeps the trips list fast: it reads only `Trip` rows, never the point table, so
it stays responsive with hundreds of rides.
### Ordering is by `(segmentId, id)`, never `timestamp`
`timestamp` comes from `Location.time`, which is GPS-derived and can jump. The autoincrement
`id` is genuinely monotonic in write order.
### Migrations are mandatory
`fallbackToDestructiveMigration()` was used during v2 development, when the only data was
throwaway test rides, and **removed in T18**. Any schema change from here must ship a
`Migration` against `app/schemas/com.rippr.data.AppDatabase/2.json`. Reinstating the
fallback would silently delete every stored ride on the next version bump.
---
## State ownership
Recording state is derived from the database, never held in memory.
v1 kept an in-memory flag. It lied after process death: `START_STICKY` restarts the service
with a null intent, and the flag came back `false` while a ride was genuinely underway.
This was verified fixed by force-stopping the app mid-ride — the button correctly still
read STOP.
`TripRepository` owns every transition, each in a `withTransaction`, and **each is
idempotent**:
| Transition | Behaviour on an unexpected state |
|---|---|
| `startTrip` | **Adopts** an already-active trip rather than creating a second |
| `pauseTrip` | No-op if already paused |
| `resumeTrip` | No-op if already recording — never opens a duplicate segment |
| `completeTrip` / `discardTrip` | No-op with no active trip |
Idempotency is not defensive padding: the OS can restart the service at any moment, and
these are the states it can restart into.
### Ephemeral state is separate and deliberate
Two objects hold genuinely transient state that *should* be lost on restart:
`UploadStatus` (last upload error) and `LiveTelemetry` (current speed, published from the
GPS callback at sensor rate so the speedo does not wait on the 2-second flush).
---
## Statistics
Computed twice, deliberately, by the same code:
- **Live**, folded per batch into `Accumulator`, so the screen shows moving numbers
- **Authoritatively**, via `RideStatistics.compute()` over stored points when a trip
completes, overwriting the live estimate
A mid-ride process kill loses the in-memory accumulator, so the live figure can drift.
Recomputing at completion with the *same functions* keeps the two consistent by
construction rather than by discipline.
### The cross-batch anchor
`Accumulator` keeps the last point of the *previous* batch. Without it, distance restarts
at every flush boundary and under-reports by roughly one hop per batch — a few percent,
invisible until compared against an odometer. A unit test asserts that chunked folding
equals single-batch folding.
### Elevation gain needs two mechanisms
This is the subtlest maths in the codebase, and it was measured, not assumed.
A naive "sum every delta above a 3 m threshold" reported **1498 m of climbing over a parked
bike** with ±8 m altitude noise, because noise crosses any small threshold constantly.
The shipped version combines:
1. A **15-sample moving average**, cutting noise by roughly √window
2. **Reversal hysteresis** — a climb banks only once altitude turns back *down* past the
threshold from its peak
That brings the same fixture to ~30 m. Smoothing then clipped real terrain (a 100 m climb
measured 93 m, since a moving average lags by half a window), so `finish()` reconciles the
final run against the last raw reading.
**~30 m of drift per ten stationary minutes remains.** The regression test guards against
returning to 1498 m; it is not a claim of accuracy.
### Other definitions worth knowing
- **Average speed is distance ÷ moving time**, not the mean of speed samples. The mean
over-weights time spent stopped.
- **Sample gaps are capped at 10 s.** Without it, a two-minute tunnel counts as two minutes
of moving time at the last known speed.
- **The speed histogram is weighted by time**, not sample count.
- **Division guards everywhere** — a NaN reaching Compose renders as the literal text "NaN".
---
## UI
`MainActivity` (74 lines) owns only runtime permissions and the battery-optimisation
prompt. Everything else is `ui/{theme,components,record,trips,detail}` with one ViewModel
per screen behind a single factory. No DI framework — three screens do not earn Hilt.
### Two hard-won UI lessons
**`Surface` is load-bearing, not decoration.** Moving the UI out of the old `Surface`
wrapper made `LocalContentColor` default to black, rendering the 64sp speed figure
invisible against the near-black ground. Text with an explicit colour still showed, so the
screen looked merely odd rather than broken. `RipprTheme` now wraps content in a `Surface`.
**No test would have caught this** — only looking at a screenshot did.
**Show current speed, not max.** The first real ride reported the screen as frozen. A max
figure only moves when you beat it, so riding steadily leaves it motionless. The headline
is now current speed, with max demoted to the stats card, and a wall-clock elapsed timer
ticks every second independent of any database write.
### Control layout
| State | Primary | Secondary |
|---|---|---|
| Idle | START RECORDING | — |
| Recording | PAUSE | STOP |
| Paused | RESUME | STOP · DISCARD |
**Discard appears only while paused.** A destructive control next to Pause during a live
ride invites a gloved mis-tap at speed. Buttons are 72dp/56dp because they get pressed with
gloves on.
---
## Map
osmdroid, chosen over MapLibre and Google Maps because it needs no API key, no billing
account, and no GCP project, and it caches tiles for offline use — which matters on
mountain rides.
Three things that will break it if disturbed:
1. **The user agent must be set before any `MapView` is constructed.** osmdroid's default is
rejected by OSM's tile servers with a 403, and the failure presents as an empty map
rather than an error. Set in `RipprApp.onCreate`.
2. **`onDetach()` is not optional.** Without it, tile handles and the downloader thread
outlive the composable and the leak compounds. The `MapView` is hoisted into a
`remember` so the lifecycle observer can reach the same instance the `AndroidView`
shows — an earlier version observed the lifecycle and did nothing, because it could not
see the view.
3. **Zoom must be clamped.** `zoomToBoundingBox` ignores `maxZoomLevel`. A 50 m ride zooms
past OSM Mapnik's maximum published zoom of 19, where no tile exists, and the map renders
as an empty grid. This shipped in v2.0 and was found on the first real ride.
Rendering uses **bucketed polylines** — runs of similar speed drawn as separate monochrome
lines, overlapping by one point — rather than osmdroid's per-vertex `PolyChromaticPaintList`,
which is fiddly and gains little at real viewing zoom.
**Decimation is render-only.** `Geo.simplify` is applied when building overlays and nowhere
else. Storage and export always use raw points; the export tests assert exact counts
specifically to catch a leak.
---
## Export
Pure string generation with no Android dependency, so the format logic is unit-tested on
the JVM.
- **GPX 1.1**, one `<trkseg>` per segment, speed in `<extensions>` in **m/s** per the spec
- **GeoJSON**, coordinates `[lon, lat, ele]` — **longitude first**, the opposite of GPX and
the classic silent error; a test asserts it explicitly
- **Timestamps inside files are UTC**; **filenames use local time**, because a human reads
the filename and a machine reads the contents
- Names are XML- and JSON-escaped — a ride called `Sam & Dave's <ride>` would otherwise
produce a malformed file, and the failure is invisible until an import rejects it
Delivery is via `FileProvider` and the share sheet. A raw `file://` URI throws
`FileUriExposedException` on Android 7+, and the receiving app needs
`FLAG_GRANT_READ_URI_PERMISSION` or it reports a SecurityException that looks like a bug in
*that* app.
---
## Upload
`TelemetryUploader` is deliberately subordinate to recording: every failure is swallowed and
retried, and nothing in it can stop the location pipeline. Points carry `synced = 0` until
the server acknowledges them, so a dead endpoint costs only a growing backlog.
`trip_id` and `segment_id` are stamped **per point, not per batch**, because
`getUnsyncedPoints()` draws by id and a batch can straddle a segment or — after a
discard-and-restart — a trip boundary.

View File

@@ -0,0 +1,188 @@
# Development
Everything needed to build, test, and drive Rippr without Android Studio. This was all
done from the command line; the GUI is not required at any point.
---
## Toolchain
### JDK — must be 17–21
**Not 25.** AGP 8.7 does not support it, and the failure is confusing. This machine runs
JDK 25 by default via `mise`, so builds need an explicit `JAVA_HOME`:
```bash
export JAVA_HOME=~/.local/share/mise/installs/java/temurin-21.0.12+8.0.LTS
export PATH="$JAVA_HOME/bin:$PATH"
```
Install with `mise install java@temurin-21` if it is missing.
### Android SDK
```bash
export ANDROID_HOME=$HOME/Library/Android/sdk
export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
```
Needs platform 35 and build-tools 35. `sdkmanager` and `avdmanager` come from
`brew install android-commandlinetools`, but note the brew binaries resolve their own SDK
root and **will not see** `~/Library/Android/sdk` — `avdmanager create` fails with
"Package path is not valid" even when the image is installed. Hand-editing
`~/.android/avd/*.ini` is more reliable than fighting it.
### Disk space — the surprise blocker
The emulator enforces a **fixed ~7.4 GB free-space minimum** before it will boot. This is
not tunable: shrinking the AVD's `disk.dataPartition.size` and passing `-partition-size`
both leave the requirement unchanged.
A full build needs roughly 3–5 GB on top of that. Reclaimable without touching source:
```bash
brew cleanup -s
rm -rf ~/Library/Caches/Homebrew/* ~/Library/Caches/ms-playwright
pip cache purge; go clean -cache
```
---
## Build and test
```bash
./gradlew assembleDebug # debug APK
./gradlew assembleRelease # release APK (unsigned — will not install)
./gradlew testDebugUnitTest # 84 tests, no device needed
./gradlew lintDebug
./gradlew connectedDebugAndroidTest # 46 tests, needs a device
```
Only the **debug** APK installs directly; release is unsigned.
### Reading results without the HTML report
```bash
python3 -c "
import re,glob
t=f=0
for p in glob.glob('app/build/test-results/testDebugUnitTest/*.xml'):
m=re.search(r'tests=\"(\d+)\".*?failures=\"(\d+)\".*?errors=\"(\d+)\"',open(p).read())
t+=int(m.group(1)); f+=int(m.group(2))+int(m.group(3))
print(f'{t} run, {f} failed')
"
```
### Room schema export
Schemas land in `app/schemas/com.rippr.data.AppDatabase/`. **The directory name follows the
`@Database` class package** — when the class moved from `com.rippr` to `com.rippr.data`,
the export path moved with it, which initially looked like the schema had not generated at
all.
---
## Emulator
```bash
emulator -avd Medium_Phone_API_35 -no-window -no-audio -no-boot-anim \
-gpu swiftshader_indirect
adb emu kill # shut down
```
Grant permissions up front so the runtime dialog does not eat your taps:
```bash
for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION POST_NOTIFICATIONS; do
adb shell pm grant com.rippr android.permission.$p
done
```
### Feeding a synthetic ride
```bash
adb emu geo fix <lon> <lat> <altitude>
```
**Coordinate order is longitude first.** See [TESTING.md](TESTING.md) for what this
cannot simulate — it matters more than it sounds.
### Driving the UI
The service is `exported=false`, so `adb shell am start-service` is **correctly refused**.
Drive through the UI, or from an instrumented test running in the app's own process.
Locating a control by its label:
```bash
tapText() {
adb shell uiautomator dump /sdcard/u.xml >/dev/null 2>&1
adb shell cat /sdcard/u.xml | python3 -c "
import sys,re
d=sys.stdin.read()
m=re.search(r'text=\"$1\"[^>]*bounds=\"\[(\d+),(\d+)\]\[(\d+),(\d+)\]\"', d)
print(f'{(int(m.group(1))+int(m.group(3)))//2} {(int(m.group(2))+int(m.group(4)))//2}' if m else 'NONE')
"
}
read x y <<< "$(tapText 'START RECORDING')"; adb shell input tap $x $y
```
### Inspecting the database
`sqlite3` is **not present** on the emulator image. Pull the files instead — and take the
`-wal` too, or recent writes are missing:
```bash
for f in rippr_db rippr_db-wal rippr_db-shm; do
adb exec-out run-as com.rippr cat databases/$f > /tmp/db/$f
done
python3 -c "
import sqlite3; c=sqlite3.connect('/tmp/db/rippr_db')
print(c.execute('SELECT id,state,ROUND(distanceM),pointCount FROM trips').fetchall())
"
```
---
## Hard-won harness lessons
These cost real time. All were false alarms that looked like app bugs.
**Never `sleep` and assume a tap landed.** A cold start took 8.7 s once; a 4-second sleep
produced a silently-missed tap and a false "the service didn't start" conclusion. Poll
`uiautomator dump` for the expected text, then tap.
**Even a confirmed-present control can swallow a tap** right after a fresh install.
`uiautomator` reported the button present, the tap returned success, and nothing happened —
no `databases/`, no service. Repeating it moments later worked.
**Verify database state between UI steps.** Every emulator false alarm in this project came
from trusting a tap instead of checking what actually happened. Checking for the *absence
of the data directory* is what finally made one of them obvious.
**Suspiciously identical results mean a broken harness.** A constant-sweep loop returned
four results identical to six decimal places. The cause was a shell quoting bug that
corrupted the source file while the compile error hid behind `/dev/null`. Never redirect a
build to `/dev/null` inside a measurement loop.
**The notification shade stays open between runs** and will cover the app, making
`uiautomator` report quick-settings tiles instead of your UI. `adb shell cmd statusbar
collapse` first.
---
## Design assets
Logos and icons are generated, not hand-drawn:
```bash
python3 design/gen_logos.py # SVG concepts + PNG previews (needs rsvg-convert)
python3 design/to_vector_drawable.py # → app/src/main/res/drawable/
python3 design/build_preview.py # review page
```
`design/material/` vendors Google Material Symbols (Apache-2.0) as reference geometry.
The launcher icon lives on Android's 108-unit adaptive canvas. **Content must stay inside
the centre 66-unit safe circle** — the first version drew its ring at r=43 and the launcher
mask cropped it clean off, taking the accent segment with it.

139
rippr-src/docs/TESTING.md Normal file
View File

@@ -0,0 +1,139 @@
# Testing
What is covered, what is not, and — most importantly — **what the emulator structurally
cannot verify**. Read the last section before trusting any green build.
```
84 unit tests (JVM, no device) 46 instrumented tests (device required)
```
---
## Unit tests — `app/src/test/`
Pure logic is kept free of Android imports specifically so it can be tested here. This is
the pattern `Telemetry.kt` established in v1 and every later module follows.
| Suite | Covers |
|---|---|
| `TelemetryTest` | Speed conversion, noise floor, accuracy gate, duration formatting, upload payload shape |
| `TelemetryUploaderTest` | Upload success, failure, retry, disabled endpoint — against `MockWebServer` and an in-memory fake DAO |
| `GeoTest` | Haversine against known references, Douglas–Peucker, bounds, degenerate cases |
| `RideStatisticsTest` | Distance, moving time, elevation hysteresis, histogram, profile |
| `AccumulatorTest` | Live accumulation, the cross-batch anchor, restore, segment boundaries |
| `RideExportTest` | GPX/GeoJSON structure parsed with real parsers, escaping, exact point counts |
### The tests that matter most
A few exist because something specific went wrong and must not return:
- **`stationary noisy altitude yields near-zero elevation gain`** — a naive implementation
reported **1498 m of climbing over a parked bike**. The bound is a regression guard, not
an accuracy claim.
- **`distance across many small batches matches one big batch`** — guards the cross-batch
anchor, whose absence under-reports distance by a few percent, invisibly.
- **`every raw point is exported with no decimation`** — catches render-side simplification
leaking into exports.
- **`geojson coordinates are longitude first`** — the classic silent error; wrong order
plots in the wrong hemisphere.
- **`a hostile trip name still produces valid xml`** — uses `Sam & Dave's <ride> "fast"`.
---
## Instrumented tests — `app/src/androidTest/`
| Suite | Covers |
|---|---|
| `SchemaTest` | CASCADE deletes, active-trip flow, ordering, COALESCE guards, upload backlog |
| `TripRepositoryTest` | All lifecycle transitions, idempotency, process-death simulation |
| `TrackingServiceLifecycleTest` | The real service driven through all five actions |
| `MergeTest` | Re-parenting, recomputed aggregates, rejections, atomicity |
### These tests must use in-memory databases
`TrackingServiceLifecycleTest` originally ran against the **production** `AppDatabase`
singleton and called `deleteAll()` in setUp/tearDown. Harmless on a throwaway emulator, but
it would have destroyed every recorded ride had the suite ever been run against a personal
phone. It now substitutes an in-memory database through `AppDatabase.overrideForTest()` and
`TripRepository.overrideForTest()`, restoring the singletons afterwards.
**Any new test that touches the service must do the same.**
### Timeouts
Service tests poll with a 25 s timeout. This was raised from 10 s when the suite grew —
but note the flakiness that prompted it turned out to have a **real bug** behind it
(`stopSelf()` racing a queued START). Raise timeouts only after ruling out a defect.
---
## What the emulator cannot verify
This is the most important section in this document.
### It reports zero velocity, always
`adb emu geo fix` teleports the device. `Location.speed` is therefore **always 0**, and
every recorded `speedKmh` is `0.0`.
Unverifiable on the emulator:
- Max speed, average moving speed
- Moving time (everything falls below the 1.5 km/h noise floor, so it stays 00:00:00)
- **Speed colouring on the map** — the path renders uniformly in the low-speed colour
A green suite says nothing about any of these.
### It hides UI consequences of that limitation
This is subtler and it bit us. v2.0 shipped with **max speed** as the headline figure on the
recording screen. On the emulator every value is zero, so a number that never moves looks
perfectly normal. On a real ride it read as a frozen, broken screen.
**When a value cannot change under test, question whether the UI around it can be judged
at all.**
### Synthetic fixtures hide scale-dependent bugs
The emulator ride fixtures were ~900 m. A real 50 m ride zoomed the map past OSM's maximum
tile zoom and rendered an empty grid. The bug was entirely deterministic and entirely
invisible to a fixture of the wrong size.
**Test short and long rides.**
### Correlated noise is not uniform noise
The elevation fixture uses uniform ±8 m random noise. Real GPS altitude error is
*correlated* — it wanders rather than jitters. The ~30 m residual drift measured against
synthetic noise may behave quite differently in practice.
---
## Not covered at all
1. **No Compose UI tests** for any of the six screens. Verification was manual screenshots.
This is the largest single gap.
2. **No battery measurement** over a multi-hour ride.
3. **No map memory-leak measurement** across repeated navigation, despite the osmdroid
lifecycle being a known hazard.
4. **No rotation/state-retention testing.**
5. **No test that a GPX imports cleanly into Strava or Garmin** — structure is validated
against an XML parser, but schema validity does not guarantee a consumer accepts it.
---
## The real-ride checklist
The only way to validate the emulator's blind spots. Run it after any change to recording,
statistics, or the map.
1. Start, pocket the phone, ride, stop — matching actual usage
2. Max speed plausible against the speedometer
3. Distance plausible against the odometer
4. Moving time excludes stops
5. **Elevation gain near zero on flat ground** — the most likely silent bug
6. Path renders with no straight line across a pause
7. Speed colouring visibly varies along the path
8. **A short ride (under 100 m) still shows streets** — the v2.0 zoom bug
9. GPX opens correctly in Google Earth or Strava
10. Battery drain over a multi-hour ride is acceptable
11. Pause/resume survives a screen-off stretch

View File

@@ -0,0 +1,68 @@
# v1 — the original recorder
v1 was specified as **MotoTrack**, a "bare-bones, highly resilient" recorder to capture GPS
telemetry during motorcycle group rides. Renamed to Rippr partway through.
The original brief is preserved verbatim in [original-spec.md](original-spec.md).
**Design priority, quoted from the brief:** *"Unbreakable execution over UI beauty."*
That framing drove every architectural choice and still holds.
## What shipped
A single-screen app with an un-killable foreground service writing GPS fixes to Room, plus
a REST uploader. Validated on a real ride — including max speed, which the emulator cannot
produce.
## Deviations from the spec, and why
The spec was written as complete, paste-ready code. Most of it was sound; these parts were
not, and were changed deliberately.
| Spec said | What shipped | Why |
|---|---|---|
| `super.onCreate()` in `MainActivity` | `super.onCreate(savedInstanceState)` | Would not compile |
| `kapt` for Room | **KSP** | kapt is 2–3× slower and unreliable on modern Kotlin/JDK |
| Hardcoded `material3:1.2.1` beside a Compose BOM | Version catalog + BOM | Version conflict |
| `insertPoint()` per fix from the callback | Unbounded `Channel` + batched writer | A coroutine per fix gives no back-pressure guarantee; disk latency could block GPS |
| Activity-local `isRecording` | Process-wide state (later, in v2, the database) | Lied after process death |
| 1 Hz polling of two suspend DAO queries | Room `Flow` | Push, not poll |
| No stop action on the notification | Stop action + tap-to-open | |
| Nothing countering Doze / OEM killers | Battery-optimisation exemption prompt | A multi-hour ride must survive |
| No `Theme.MotoTrack`, no launcher icon | Generated both | Referenced by the manifest but never defined |
| "Streams over HTTP/REST" in the goal, no task for it | Full uploader: `synced` column, batched POST, retry, offline-safe | Stated as a goal, so it was built |
## Verification
The emulator harness built here was reused throughout v2:
- Merged manifest checked inside the APK — `foregroundServiceType=0x8` (location) confirmed
- 25 synthetic GPS fixes fed; final stored coordinate matched the fed value **exactly**
- Foreground service confirmed via `dumpsys` (`isForeground=true`, ongoing notification)
- Stop path verified: service gone, notification removed, final flush landed, 81 contiguous
point ids
**Speed was never verified in v1's automated testing** — the emulator reports zero
velocity. It was confirmed only on Dylan's first real ride.
## What v1 lacked
Feedback after that ride, which became the v2 brief:
> "app is simple and clean. I like that but it's missing stuff. There is no reset or pause
> button, there is no concept of a trip, it captures speed data but not path data."
Three of those four were the same missing concept — no `Trip` boundary. The fourth was a
misconception worth recording: **path data was already being captured.** Every point stored
latitude, longitude, altitude and bearing at 1–2 Hz from day one. What was missing was a
map to draw it on.
## Icon work
The launcher icon was designed in this phase. Five motorcycle-themed concepts were
generated locally as SVG, using Google Material Symbols (Apache-2.0) as reference geometry;
Dylan picked **Route** — a switchback trace inside a ring.
Lesson from that round: the first attempt hand-authored bezier paths without ever rendering
them, and they were poor. Building a rasterise-and-look loop (`rsvg-convert`) changed the
output quality completely. Generators live in `design/`.

View File

@@ -0,0 +1,91 @@
# Original v1 brief (verbatim)
Preserved as written, before any of it was implemented or corrected. See
[README.md](README.md) for which parts were changed and why — several would not have
compiled or would have dropped GPS fixes under load.
The project was called **MotoTrack** at this point, with package `com.example.mototrack`.
Both were renamed to Rippr / `com.rippr` during development.
---
## Project Specification: MotoTrack Single-Session Android Recorder
**Goal:** Build a bare-bones, highly resilient Native Android app (Kotlin) to record GPS
telemetry during motorcycle group rides today.
**Design Priority:** Unbreakable execution over UI beauty. Must run as an un-killable
**Foreground Service** that records telemetry to a local SQLite database and streams
updates over HTTP/REST when online.
### 1. Setup & Installation
Prerequisites: Android Studio (Koala/Ladybug+), a physical Android phone on 8.0+ (API 26+)
with USB debugging on, and a cable.
Project setup: New Project → Empty Activity (Jetpack Compose); Name `MotoTrack`; package
`com.example.mototrack`; Minimum SDK API 26; Kotlin.
Dependencies specified:
```kotlin
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
id("kotlin-kapt")
}
dependencies {
implementation("com.google.android.gms:play-services-location:21.2.0")
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
kapt("androidx.room:room-compiler:$roomVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
implementation("androidx.compose.material3:material3:1.2.1")
}
```
### 2. Task breakdown
**Task 1 — Manifest & permissions.** `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`,
`FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_LOCATION`, `POST_NOTIFICATIONS`, `INTERNET`;
`MainActivity` exported with a LAUNCHER intent filter; `TrackingService` declared with
`android:foregroundServiceType="location"`.
**Task 2 — Local storage (Room).** `TrackPoint` entity with `id`, `timestamp`, `latitude`,
`longitude`, `speedKmh`, `altitudeM`. `TrackPointDao` with `insertPoint`, `getAllPoints`,
`getMaxSpeed`, `getPointCount`. `AppDatabase` at version 1 with a `@Volatile` singleton.
**Task 3 — Un-killable foreground service.** `TrackingService` holding a
`FusedLocationProviderClient`, a `CoroutineScope(Dispatchers.IO + SupervisorJob())`, and a
`LocationCallback` that converts `loc.speed * 3.6f` to km/h and launches a coroutine per
fix to `insertPoint`. `startForeground` with `FOREGROUND_SERVICE_TYPE_LOCATION` on Q+,
`START_STICKY`, a `LocationRequest` at `PRIORITY_HIGH_ACCURACY` / 1000 ms with a 500 ms
minimum interval, and an `IMPORTANCE_LOW` notification channel.
**Task 4 — Simple UI & controller.** `MainActivity` with `mutableStateOf` fields for
`isRecording`, `maxSpeed` and `pointCount`; a `RequestMultiplePermissions` launcher; a
`LaunchedEffect` polling `getMaxSpeed()` and `getPointCount()` every second; and a single
button toggling between START and STOP RECORDING.
### 3. Verification & deployment
1. Attach device, click Run 'app' in Android Studio
2. Grant location and notification permissions
3. Tap START RECORDING; confirm the persistent notification appears
4. Lock the screen and take a 1-minute test walk/drive
5. Unlock: verify Max Speed and Points Captured are updating
---
## Notable in hindsight
- **"Streams updates over HTTP/REST"** appears in the goal but no task defines it. The
uploader was built anyway, since it was clearly intended.
- **The 1 Hz polling loop** in Task 4 was replaced by a Room `Flow`.
- **A coroutine launched per GPS fix** (Task 3) offers no back-pressure guarantee; this
became the unbounded `Channel` and single batched writer that still underpins recording.
- **`super.onCreate()`** in Task 4 is missing its argument and would not compile.
- The verification steps assume Android Studio. Everything ended up driven from the CLI
instead — see [../DEVELOPMENT.md](../DEVELOPMENT.md).

View File

@@ -0,0 +1,76 @@
# T01 — Repo + dependency baseline
**Phase** 0 · **Depends on** — · **Status** Done
## Goal
Put the project under version control and add every dependency v2 needs, verifying the
build stays green before a single line of feature code changes. Done when `git log` shows
v1 committed, the new libraries resolve, and `assembleDebug testDebugUnitTest lintDebug`
all pass.
## Context
- `~/dojo/rippr` is **not currently a git repo**. A multi-phase rewrite with no version
control and no way to diff or revert is reckless — this is the first thing to fix.
- `.gitignore` already exists and covers `build/`, `.gradle/`, `local.properties`,
`.idea/`, `.DS_Store`.
- `gradle/libs.versions.toml` is the single source of dependency truth. Everything goes
through the catalog; no inline coordinates in `app/build.gradle.kts`.
- `app/build.gradle.kts` already carries a `constraints` block forcing
`androidx.fragment:1.8.5` — play-services drags in 1.1.0 transitively and lint rejects
it. Leave that alone.
## Design
Three new dependencies, all in the catalog:
| Library | Why |
|---|---|
| `org.osmdroid:osmdroid-android` | Map rendering (T13/T14). Apache-2.0, no API key |
| `androidx.navigation:navigation-compose` | Three destinations (T08) |
| `androidx.lifecycle:lifecycle-viewmodel-compose` | ViewModels (T08) |
osmdroid is added now rather than at T13 so any resolution or manifest-merge surprise
surfaces against a known-green build instead of tangled up with map code.
`local.properties` stays gitignored — it holds a machine-specific `sdk.dir`.
## Implementation
1. `git init` in `~/dojo/rippr`, confirm `.gitignore` covers build outputs, and verify
`git status` shows no `build/` or `local.properties` noise.
2. Commit v1 as the baseline — source, design generators, vendored Material glyphs, and
the v2 docs.
3. Add to `[versions]`: `osmdroid`, `navigation`, `lifecycleViewmodel`.
4. Add to `[libraries]`: `osmdroid-android`, `androidx-navigation-compose`,
`androidx-lifecycle-viewmodel-compose`.
5. Wire the three into `app/build.gradle.kts` `dependencies`.
6. Build and test.
7. Commit the dependency bump separately, so a resolution problem is bisectable.
## Acceptance criteria
- [ ] `git log` shows at least two commits: v1 baseline, then dependency bump
- [ ] `git status` clean — no build artefacts or `local.properties` tracked
- [ ] All three libraries resolve
- [ ] `./gradlew assembleDebug testDebugUnitTest lintDebug` passes
- [ ] 12/12 existing unit tests still green
## Tests
No new tests. This task's job is proving the existing suite still passes with the new
dependency graph — particularly that osmdroid does not introduce a manifest-merge
conflict or a duplicate-class error.
## Risks / gotchas
- **osmdroid manifest merge.** It historically declared storage permissions. Modern
versions use app-private cache, but check the merged manifest afterward:
`aapt2 dump xmltree --file AndroidManifest.xml app/build/outputs/apk/debug/app-debug.apk`
and confirm no unexpected permission appeared.
- **Do not commit the APK.** Release artefacts belong in `~/dojo/samplez`, not here.
## Out of scope
Any use of the new libraries. This task only proves they resolve.

View File

@@ -0,0 +1,128 @@
# T02 — Schema v2 entities + DAOs
**Phase** 1 · **Depends on** T01 · **Status** Done
## Goal
Replace the flat single-table schema with `Trip → Segment → TrackPoint`, split the
monolithic `TrackPoint.kt` into a `data/` package, and add the queries the v2 screens
need. Done when the new schema builds, exports to `app/schemas/com.rippr.data.AppDatabase/2.json`, and the
instrumented DAO tests pass.
## Context
`app/src/main/java/com/rippr/TrackPoint.kt` (113 lines) currently holds the entity, the
`RideStats` projection, the DAO, **and** the `AppDatabase` all in one file. That was fine
for one table and is not fine for three.
Existing details that matter:
- `AppDatabase` uses `JournalMode.WRITE_AHEAD_LOGGING` — deliberate, keep it. WAL with
NORMAL sync survives app crashes without fsyncing every 2 Hz write.
- `exportSchema = true`, and `app/schemas/com.rippr.AppDatabase/1.json` exists.
- `observeStats()` returns a single-row aggregate with `COALESCE` guards so an empty
table does not null-crash the non-null `RideStats` fields. **That COALESCE lesson
carries over** — the same trap applies to per-trip aggregates.
- The `synced` column drives the upload backlog; it stays.
## Design
```kotlin
enum class TripState { RECORDING, PAUSED, COMPLETED }
@Entity(tableName = "trips")
data class Trip(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startedAt: Long,
val endedAt: Long? = null, // null ⇒ active
val name: String? = null, // null ⇒ UI derives a name from startedAt
val state: TripState = TripState.RECORDING,
val distanceM: Double = 0.0,
val movingMillis: Long = 0,
val maxSpeedKmh: Float = 0f,
val elevationGainM: Double = 0.0,
val pointCount: Int = 0,
)
@Entity(
tableName = "segments",
foreignKeys = [ForeignKey(Trip::class, ["id"], ["tripId"], onDelete = CASCADE)],
indices = [Index("tripId")],
)
data class Segment(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val tripId: Long,
val startedAt: Long,
val endedAt: Long? = null, // null ⇒ open
)
```
`TrackPoint` gains `tripId` and `segmentId`, both with CASCADE foreign keys and indices.
Deleting a trip therefore removes its segments and points in one statement — which is
exactly what Discard (T06) and Delete (T12) need.
**Why `state` as well as `endedAt`:** `endedAt == null` distinguishes active from
finished, but cannot distinguish RECORDING from PAUSED. Both are needed — the service
resumes differently depending on which it finds after a process restart.
**Destructive migration.** v1 data is intentionally discarded, so bump to version 2 with
`fallbackToDestructiveMigration()`. No `Migration` object, no `MigrationTestHelper`.
> ⚠ This is correct **only** while there is no ride data worth keeping. Once v2 is in
> daily use, a future schema change would silently wipe real rides. **T18 removes it.**
### New queries
| Query | Used by |
|---|---|
| `observeActiveTrip(): Flow<Trip?>` — `WHERE endedAt IS NULL` | T03, T09 |
| `observeCompletedTrips(): Flow<List<Trip>>` — ordered `startedAt DESC` | T10 |
| `observeTrip(id): Flow<Trip?>` | T11 |
| `pointsForTrip(id): List<TrackPoint>` — ordered `segmentId, id` | T11, T14, T15 |
| `segmentsForTrip(id): List<Segment>` | T14, T15 |
| `openSegment(tripId): Segment?` | T06 |
| `deleteTrip(id)` — CASCADE handles the rest | T06, T12 |
Ordering by `segmentId, id` rather than `timestamp` matters: `timestamp` is GPS time and
can jump, whereas the autoincrement id is genuinely monotonic in write order.
## Implementation
1. Create `data/` package; move and split the existing file.
2. Add `Trip`, `Segment`, `TripState` (+ a Room `TypeConverter` for the enum, or store
its `name` as `String`).
3. Add `tripId`/`segmentId` to `TrackPoint` with FKs and indices.
4. Split DAOs: `TripDao`, `SegmentDao`, `TrackPointDao`.
5. Bump `AppDatabase` to `version = 2`, add all three entities, add
`fallbackToDestructiveMigration()` with a comment pointing at T18.
6. Verify CASCADE actually fires in a test rather than assuming — Room does enable
foreign keys for its generated code, but that is worth proving, not trusting.
7. Update `TrackPointDaoTest.kt` for the new shape; add trip/segment tests.
## Acceptance criteria
- [x] `app/schemas/com.rippr.data.AppDatabase/2.json` generated (path follows the
package, which moved to `com.rippr.data`; the orphaned v1 dir was removed)
- [x] Deleting a trip cascades to its segments and points
- [x] `observeActiveTrip()` emits null on an empty database, not a crash
- [x] Per-trip aggregate queries return zeros, not nulls, for a trip with no points
- [x] Existing `TelemetryTest` / `TelemetryUploaderTest` untouched and green
## Tests
Instrumented (`app/src/androidTest/…`):
- CASCADE delete removes segments and points
- `observeActiveTrip` emits on insert, and emits null once `endedAt` is set
- Points ordered by `segmentId, id` across multiple segments
- Empty-trip aggregates are zero, not null (the COALESCE trap from v1)
## Risks / gotchas
- **`fallbackToDestructiveMigration()` silently wipes.** Correct here, dangerous later.
- **Foreign keys.** Room enables them for its generated code; the cascade test
confirms this empirically rather than relying on it.
- **The `synced` column must survive** the refactor or the upload backlog logic breaks.
## Out of scope
The repository layer (T03), any service changes (T06), any aggregate computation (T07).

View File

@@ -0,0 +1,112 @@
# T03 — Trip repository + reactive state
**Phase** 1 · **Depends on** T02 · **Status** Done
## Goal
Own trip lifecycle transitions in one place and make recording state a function of the
database rather than an in-memory flag. Done when `RecordingState` is deleted, the UI
observes trip state through a Flow, and killing the app process no longer loses track of
whether a ride is in progress.
## State as of T02
`TripDao`/`SegmentDao` already expose every query this task needs (`observeActive`, `getActive`, `openSegment`, `close`, `setState`, `deleteById`).
T02 left **temporary scaffolding** that this task and T06 replace:
`TrackingService.openTripAndSegment()` opens a trip and segment inline via the DAOs, and
`@Volatile currentTripId/currentSegmentId` are read by the location callback. The callback
drops fixes while those are 0 so a point can never violate the FK — **preserve that guard**.
## Context
v1 keeps state in a process-wide singleton in
`app/src/main/java/com/rippr/Telemetry.kt`:
```kotlin
object RecordingState {
private val _isRecording = MutableStateFlow(false)
…
}
```
This was already an improvement over the Activity-local `isRecording` the original spec
used, but it still lies after process death: `START_STICKY` restarts the service with a
null intent and the flag resets to `false` while a ride is genuinely underway.
The fix is that the database already knows. A row in `trips` with `endedAt IS NULL` *is*
the fact of an in-progress ride, and it survives anything short of uninstall.
**Keep the rest of `Telemetry.kt` exactly as it is.** `msToKmh`, `sanitizeSpeedKmh`,
`isUsableFix`, `formatDuration`, and `encodeBatch` are pure, already unit-tested by
`TelemetryTest`, and have no reason to change. Only `RecordingState` goes.
`RecordingState.lastUploadError` is also used by `TelemetryUploader` and surfaced on the
Record screen — that one is genuinely ephemeral UI state and can stay as a small
`UploadStatus` object, or move onto the repository. Do not silently drop it.
## Design
```kotlin
class TripRepository(
private val tripDao: TripDao,
private val segmentDao: SegmentDao,
private val pointDao: TrackPointDao,
) {
fun observeActiveTrip(): Flow<Trip?>
fun observeTrip(id: Long): Flow<Trip?>
fun observeCompletedTrips(): Flow<List<Trip>>
suspend fun startTrip(now: Long): Trip // creates trip + first segment
suspend fun pauseTrip(now: Long) // closes open segment, state=PAUSED
suspend fun resumeTrip(now: Long) // opens new segment, state=RECORDING
suspend fun completeTrip(now: Long) // closes segment, endedAt, COMPLETED
suspend fun discardTrip() // deletes active trip (CASCADE)
}
```
Every transition is idempotent and safe to call from an unexpected state — the service
can be restarted by the OS at any moment, so `resumeTrip` on an already-recording trip
must be a no-op rather than opening a duplicate segment.
**Single instance.** v1 uses a `@Volatile` double-checked singleton for `AppDatabase`;
follow the same pattern for the repository rather than introducing a DI framework. The
app is small and Hilt would be more ceremony than it earns.
## Implementation
1. Create `data/TripRepository.kt` with the interface above.
2. Implement each transition as a single Room `@Transaction` so a crash mid-transition
cannot leave a trip with two open segments.
3. Guard each transition on current state; log and no-op on nonsensical transitions.
4. Delete `RecordingState` from `Telemetry.kt`.
5. Relocate upload-error state so the Record screen keeps its indicator.
6. Update `TelemetryUploader` and `TrackingService` references.
## Acceptance criteria
- [x] `RecordingState` no longer exists
- [x] `observeActiveTrip()` reflects reality after a simulated process death
- [x] Double `startTrip()` adopts rather than duplicating
- [x] `resumeTrip()` while already RECORDING is a no-op
- [x] Upload-error surfacing still works, now via `UploadStatus`
- [x] `TelemetryTest` still green (the pure functions are untouched)
## Tests
Instrumented, against a real in-memory database:
- start → pause → resume → complete produces exactly two segments
- discard removes the trip and all its points
- transitions from wrong states are no-ops
- `observeActiveTrip` emits null after complete
## Risks / gotchas
- **Transitions race the writer loop.** The service writes points continuously; pausing
mid-flush must not orphan points into a closed segment. T06 handles ordering — pause
drains the channel *before* closing the segment.
- **Do not delete the pure Telemetry functions.** They are load-bearing and tested.
## Out of scope
Wiring transitions to service actions (T06) and aggregate maths (T07).

View File

@@ -0,0 +1,92 @@
# T04 — Geo utilities
**Phase** 2 · **Depends on** T01 · **Status** Done
## Goal
Pure geographic maths with no Android dependencies: distance between fixes, polyline
simplification for rendering, and bounding boxes for map auto-fit. Done when
`geo/Geo.kt` exists, imports nothing from `android.*`, and is covered by JVM unit tests
that run without a device.
## Context
This is the pattern `Telemetry.kt` already establishes and the reason its logic is
cheaply testable — the conversion and sanitisation functions there are covered by
`TelemetryTest` with no emulator involved. `Geo` follows the same rule.
Three consumers depend on this task: T05 (statistics), T14 (path rendering), T15
(export). It is on nobody's blocking path from the UI side, so it can be built in
parallel with the whole data-layer track.
## Design
```kotlin
object Geo {
const val EARTH_RADIUS_M = 6_371_008.8 // IUGG mean radius
fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double
/** Douglas–Peucker. Always preserves first and last points. */
fun simplify(points: List<LatLon>, epsilonMeters: Double): List<LatLon>
fun bounds(points: List<LatLon>): Bounds? // null for empty input
}
data class LatLon(val lat: Double, val lon: Double)
data class Bounds(val minLat: Double, val minLon: Double,
val maxLat: Double, val maxLon: Double)
```
**Haversine, not Vincenty.** Haversine assumes a sphere and is accurate to roughly 0.5%
— a few metres per kilometre. For motorcycle ride distances that is far below GPS noise,
and Vincenty's iterative solution would be false precision at real cost.
**Douglas–Peucker with a metre epsilon**, not a degree epsilon. A degree of longitude is
~111 km at the equator and ~0 at the poles, so a degree-based tolerance behaves
differently depending where you ride. Perpendicular distance is computed with a local
equirectangular approximation — valid over the short spans between consecutive fixes and
far cheaper than a full geodesic.
**Recursion depth.** A naive recursive Douglas–Peucker on 20,000+ points can blow the
stack in a pathological case. Implement iteratively with an explicit work stack.
## Implementation
1. Create `geo/Geo.kt` with `LatLon`, `Bounds`, and the three functions.
2. Haversine in double precision throughout — float loses metres over a long ride.
3. Iterative Douglas–Peucker with an explicit stack.
4. `bounds()` returns null on empty rather than a degenerate zero box, so callers must
handle "no path" explicitly instead of silently centring on Null Island.
5. Add an extension to map `TrackPoint` → `LatLon` so callers stay tidy.
## Acceptance criteria
- [x] No `android.*` imports in `geo/`
- [x] Haversine matches known reference distances within 0.5%
- [x] `simplify()` always returns first and last points unchanged
- [x] `simplify()` with epsilon 0 returns the input unchanged
- [x] `simplify()` on 25,000 synthetic points completes quickly and does not stack-overflow
- [x] `bounds()` returns null for an empty list
## Tests
JVM unit tests (`app/src/test/…`), no device needed:
- Known pairs: Calgary→Edmonton ≈ 280.9 km (great-circle, not road); a 1° latitude step ≈ 111.2 km; identical points → 0
- Antimeridian and equator crossings behave sanely
- Douglas–Peucker: a straight line collapses to two points; a zigzag above epsilon is
preserved; endpoints always survive
- 25,000-point performance and stack-safety check
- Bounds across a mixed-sign coordinate set
## Risks / gotchas
- **Float vs double.** `TrackPoint.latitude/longitude` are already `Double`; keep them
that way through every calculation. `speedKmh` is `Float`, which is fine — it is
display data, not accumulated.
- **Epsilon tuning is a T14 concern**, not this one. Expose it as a parameter and let the
render layer choose.
## Out of scope
Anything that consumes these functions — statistics (T05), rendering (T14), export (T15).

View File

@@ -0,0 +1,126 @@
# T05 — Ride statistics
**Phase** 2 · **Depends on** T04 · **Status** Done
## Goal
Derive every number the trip detail screen shows from a list of points, as pure
functions. Done when distance, moving time, elevation gain, speed histogram, and
elevation profile are computed and unit-tested with no device.
## Context
v1 shows max speed, point count, and a naive `lastTimestamp - firstTimestamp` duration —
computed in SQL by `TrackPointDao.observeStats()`. That query cannot survive into v2 for
the derived values, because **minSdk 26 means SQLite 3.18, which has no window
functions**, and distance/elevation both need consecutive-row differences.
So these move to Kotlin. T07 accumulates them live during recording; this task provides
the authoritative batch computation used on trip completion and on the detail screen.
Reuse from `Telemetry.kt`:
- `SPEED_NOISE_FLOOR_KMH` (1.5) — the moving/stopped threshold, so "moving" means the
same thing here as it does to the recorder
- `formatDuration` — already tested, used for display
## Available from T04
`com.rippr.geo.Geo` provides `haversineMeters` (both `Double` and `LatLon` overloads),
`simplify`, `perpendicularDistanceMeters`, `bounds`, and `pathLengthMeters`. `LatLon`,
`Bounds` (with `isDegenerate` for the stationary-ride case) and a `TrackPoint.toLatLon()`
extension are there too. All Android-free and unit-tested.
Use `Geo.pathLengthMeters` per segment rather than summing across the whole ride — it has
no notion of pause boundaries and will happily span them.
## Design
```kotlin
object RideStatistics {
fun compute(points: List<TrackPoint>, segments: List<Segment>): RideSummary
}
data class RideSummary(
val distanceM: Double,
val elapsedMillis: Long, // wall clock, first fix to last
val movingMillis: Long, // time above the speed noise floor
val maxSpeedKmh: Float,
val avgMovingSpeedKmh: Float, // distance / movingMillis, not the mean of samples
val elevationGainM: Double,
val elevationLossM: Double,
val pointCount: Int,
)
```
**Distance never crosses a segment boundary.** Points either side of a pause may be
kilometres apart; summing across the gap would invent distance the rider never covered.
Accumulate per segment and total.
**Elevation gain needs two mechanisms, not one.** A threshold alone is not enough —
measured, not assumed: a parked bike with ±8 m noise reported **1498 m** of climbing with
simple thresholding, because noise crosses any small threshold constantly. The shipped
implementation combines a 15-sample moving average (cuts noise by ~sqrt(window)) with
**reversal** hysteresis (a climb banks only once altitude turns back down by more than
3 m from its peak). That brings the same fixture to ~30 m.
The moving average lags the true altitude by about half a window, which clipped a 100 m
climb to 93 m, so `finish()` reconciles the final run against the last raw reading.
**Average speed is distance ÷ moving time**, not the arithmetic mean of `speedKmh`
samples. The mean of samples over-weights the time spent stopped and under-reports the
real pace.
**Speed histogram and elevation profile** are series for T11's charts:
```kotlin
fun speedHistogram(points: List<TrackPoint>, bucketKmh: Int = 10): List<Bucket>
fun elevationProfile(points: List<TrackPoint>, maxSamples: Int = 200): List<ElevationSample>
```
The profile is downsampled by distance-along-path, not by index, so a stretch where the
bike sat idle at 2 Hz does not dominate the chart.
## Implementation
1. Create `stats/RideStatistics.kt`.
2. `compute()` walks points grouped by `segmentId`, using `Geo.haversineMeters`.
3. Moving time accumulates `dt` only where `speedKmh >= SPEED_NOISE_FLOOR_KMH`; cap any
single `dt` (say 10 s) so a GPS dropout does not inject phantom moving time.
4. Elevation gain/loss with the 3 m hysteresis state machine.
5. Histogram and profile helpers.
6. Unit tests throughout.
## Acceptance criteria
- [x] Distance excludes inter-segment gaps
- [x] A stationary point cloud with ±8 m altitude noise yields ~0 m elevation gain
- [x] Moving time excludes stopped periods
- [x] Average speed uses moving time, not elapsed
- [x] Empty input returns a zeroed summary, never a crash or NaN
- [x] Single-point input returns zero distance, not NaN
## Tests
JVM unit tests:
- Synthetic straight-line ride: distance matches Haversine within rounding
- Two segments separated by a large jump: distance excludes the gap
- Stationary noisy-altitude fixture: elevation gain ≈ 0 (the regression test that
matters most)
- A genuine 100 m climb registers ~100 m
- Half-stopped ride: moving time ≈ half of elapsed
- Empty and single-point inputs
- `avgMovingSpeedKmh` on a known distance and duration
## Risks / gotchas
- **NaN propagation.** Division by zero moving time must return 0, not NaN — a NaN
reaching Compose renders as literal "NaN" on screen.
- **`dt` capping matters.** Without it, a two-minute tunnel dropout counts as two minutes
of moving time at the last known speed.
- **Keep this in sync with T07.** The live accumulator and this batch computation must
agree, or the number changes when a ride finishes. T07 recomputes with *this* code on
completion, which keeps them consistent by construction.
## Out of scope
Charts and rendering (T11); live accumulation during recording (T07).

View File

@@ -0,0 +1,146 @@
# T06 — Trip lifecycle in TrackingService
**Phase** 3 · **Depends on** T03 · **Status** Done
## Goal
Teach the foreground service the full ride lifecycle — start, pause, resume, stop,
discard — with segments opening and closing correctly and the notification reflecting
actual state. Done when a paused ride keeps its notification, stops consuming GPS, and
resumes into a *new* segment.
## Context
`app/src/main/java/com/rippr/TrackingService.kt` (265 lines) already gets the hard parts
right, and **none of this architecture should be disturbed**:
- Location callbacks `trySend` into an **unbounded `Channel`**; a single writer coroutine
drains it in batches of 25 every 2 s. This is why slow disk I/O can never block the GPS
thread, and why no fix is dropped under back pressure.
- `onDestroy` drains whatever remains on a scope that *outlives* `serviceScope`, so the
final points still land. Verified on the emulator: 81 points, ids 1..81 contiguous.
- A `PARTIAL_WAKE_LOCK` keeps the writer running with the screen off — a foreground
service alone does not guarantee this on all OEMs.
- `startForegroundCompat()` runs before anything else, because Android allows roughly
five seconds from `startForegroundService()` before killing the process.
What changes is lifecycle, not plumbing.
### State after T03
The repository is already wired in: the service holds `TripRepository`, start calls
`trips.startTrip()`, and `stopRecording()` calls `trips.completeTrip()` on a survivor
scope. `@Volatile currentTripId`/`currentSegmentId` remain (the location callback runs on
another thread) and are now populated from the returned `TripHandle`.
Still to do here:
- The five actions; today there is only start and `ACTION_STOP`
- `drainChannel()` and drain-before-close ordering
- Pause/resume, wake-lock release on pause, per-state notification
- The null-intent restart path (`trips.activeTrip()` already provides what it needs)
- The `tripId == 0L` guard in the location callback — **keep it**
**Note on force-stop.** A user-initiated `am force-stop` is more aggressive than an OS
kill: Android will not honour START_STICKY afterwards. The database state stays correct
and the UI reads it correctly, but recording does not auto-resume. The restart path here
covers OS-initiated kills, which is the case that matters on a long ride.
## Design
### Actions
| Action | Behaviour |
|---|---|
| `ACTION_START` | `repo.startTrip()`, acquire wake lock, request location updates |
| `ACTION_PAUSE` | **Drain channel first**, close segment, `removeLocationUpdates`, release wake lock, keep service alive |
| `ACTION_RESUME` | `repo.resumeTrip()` (new segment), re-acquire wake lock, re-request updates |
| `ACTION_STOP` | Drain, close segment, `completeTrip()`, recompute aggregates (T07), stop service |
| `ACTION_DISCARD` | Drain and discard buffered points, `discardTrip()` (CASCADE), stop service |
**Correction to the original reasoning.** This doc previously claimed that closing a
segment before draining would write points into the *wrong* segment. That is wrong:
`segmentId` is stamped onto each `TrackPoint` at creation in the location callback, so a
fix already queued always lands in the segment it was recorded during, whatever order the
writes happen in.
Draining before closing still matters, just for different reasons: points should not sit
unwritten while a ride idles at a pause, and stop/discard must not lose the tail. The
order used is stop-the-source → drain → close.
### Pause keeps the service, drops the wake lock
The foreground service stays alive so the notification persists and resuming is instant.
But with no location updates arriving there is nothing for the writer to do, so the wake
lock is released — a paused ride at a long lunch stop should not hold the CPU awake.
### Notification states
| State | Title | Text | Actions |
|---|---|---|---|
| Recording | Rippr Active | Recording ride telemetry… | Pause, Stop |
| Paused | Rippr Paused | Ride paused — not recording | Resume, Stop |
`NotificationCompat.Builder` is rebuilt and re-notified on transition; the channel and id
(101) stay as they are. The small icon stays `R.drawable.ic_stat_rippr`.
### Restart after process death
`START_STICKY` redelivers a null intent. On a null intent the service asks the repository
for the active trip:
- **RECORDING** → resume recording into a new segment (the gap is real; the phone was off)
- **PAUSED** → restore the paused notification, do not request location
- **none** → `stopSelf()`
This is only possible because T03 moved state into the database.
## Implementation
1. Replace the single `ACTION_STOP` constant with the five actions.
2. Inject `TripRepository`; drop all `RecordingState` references.
3. Add `drainChannel()` — a suspend function that empties the channel into the DB and
returns once flushed; reuse it from pause, stop, discard, and `onDestroy`.
4. Split wake lock acquire/release out of `onStartCommand` so pause/resume can call them.
5. Rebuild the notification per state; add `buildNotification(state: TripState)`.
6. Handle the null-intent restart path.
7. `stopWithTask="false"` in the manifest already survives task swipe — keep it.
## Acceptance criteria
- [x] Start → pause → resume → stop produces exactly two segments
- [x] No point is written into a closed segment
- [x] Paused: notification persists, no location updates, wake lock released
- [x] Discard deletes the trip and every one of its points
- [x] Force-stop then restart with an active RECORDING trip resumes into a new segment
- [x] Contiguous point ids across a pause (no gaps, no duplicates)
## Tests
Instrumented plus emulator end-to-end:
- Feed fixes, pause, feed more, resume, feed more, stop → assert two segments with the
right point counts either side
- Assert no point has a `segmentId` whose segment closed before its timestamp
- Discard leaves zero rows
Emulator harness (proven in v1):
```
adb shell am start-foreground-service -n com.rippr/.TrackingService # blocked: not exported
```
Drive through the UI instead — `adb shell input tap`, after waiting for the UI to
actually exist. A fixed `sleep` is not enough; the activity took 8.7 s to display on a
cold start during v1 testing and a premature tap silently does nothing.
## Risks / gotchas
- **Do not replace the Channel with a direct write.** It is the reason the GPS callback
never blocks.
- **Drain ordering** as described above; get this wrong and points land in the wrong
segment, which only shows up later as a rendering artefact.
- **Wake lock double-release** throws. `setReferenceCounted(false)` is already set; still
guard with `isHeld`.
- **`onDestroy` already spawns a survivor scope** for the final flush — do not remove it
while refactoring.
## Out of scope
Aggregate maths (T07) and any UI (T09).

View File

@@ -0,0 +1,113 @@
# T07 — Live aggregate accumulation
**Phase** 3 · **Depends on** T04, T06 · **Status** Done
## Goal
Keep distance, moving time, elevation gain, and max speed current on the active `Trip`
row while recording, so the Record screen shows live numbers without rescanning the
point table. Done when those values update during a ride and are reconciled exactly on
completion.
## Context
v1's `observeStats()` recomputes aggregates in SQL on every emission. That worked for
`MAX`, `AVG`, and `COUNT` over one table, but v2 needs **distance** and **elevation
gain**, which require consecutive-row differences.
**SQLite 3.18 on minSdk 26 has no window functions** — no `LAG`, no `OVER`. There is no
SQL expression for "distance from the previous point". So the maths moves into Kotlin,
into the writer loop that is already touching every point as it lands.
`TrackingService` already batches writes every ~2 s (`FLUSH_INTERVAL_MS = 2000`,
`FLUSH_SIZE = 25`), so one extra `UPDATE trips …` per flush is negligible — roughly one
write every two seconds against a table with a single active row.
## Design
The writer loop keeps the **last point of the previous batch** in memory as the anchor
for cross-batch distance. Without it, distance resets at every flush boundary and
under-reports by roughly one inter-point hop per 25 points.
**Reuse `ElevationAccumulator` from T05 verbatim.** It is already streaming (fed one
altitude at a time) precisely so the recorder can share it. Do **not** reimplement the
hysteresis here — a naive version measured 1498 m of phantom climbing on a parked bike.
Remember to call `finish()` before persisting final values.
Implemented as `com.rippr.Accumulator` in `RideAccumulator.kt` rather than a private
class inside the service, so it is unit-testable without a device.
```kotlin
private data class Accumulator(
var lastPoint: TrackPoint? = null,
var distanceM: Double = 0.0,
var movingMillis: Long = 0,
var maxSpeedKmh: Float = 0f,
var elevationGainM: Double = 0.0,
var climbBuffer: Double = 0.0, // hysteresis state, see T05
var pointCount: Int = 0,
)
```
Rules, matching T05 exactly:
- Distance accumulates only **within** a segment. On resume, `lastPoint` resets to null
so the pause gap contributes nothing.
- Moving time accumulates `dt` only where `speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH`,
with `dt` capped (10 s) so a tunnel dropout cannot inject phantom movement.
- Elevation gain uses the same 3 m hysteresis.
### Reconciliation on completion
Incremental accumulation can drift — a process kill mid-ride loses the in-memory
accumulator, and the restarted service resumes from zero while the DB row holds a stale
partial. So on `ACTION_STOP`, after the final drain, **recompute authoritatively** with
`RideStatistics.compute()` over all stored points and overwrite the row.
That is why T05 and T07 must agree: the live number is an estimate, the finished number
is computed by T05's code. Using the same functions for both keeps them consistent by
construction rather than by discipline.
## Implementation
1. Add `Accumulator` to `TrackingService`, owned by the writer coroutine.
2. After each batch insert, fold the batch into the accumulator and `UPDATE` the trip row
in the same transaction as the point insert — so a crash cannot commit points without
their aggregate contribution.
3. Reset `lastPoint` on segment change (pause/resume).
4. On restart-with-active-trip, seed the accumulator from the persisted trip row and set
`lastPoint` to the last stored point of the open segment.
5. On stop, recompute with `RideStatistics.compute()` and overwrite.
## Acceptance criteria
- [x] Distance updates live during recording
- [x] Distance does not jump across a pause
- [x] Distance is correct across flush boundaries (no per-batch reset)
- [x] Stationary noisy-altitude recording yields ~0 m elevation gain
- [x] Post-stop values exactly match `RideStatistics.compute()` over the stored points
- [x] Restart mid-ride resumes accumulation instead of restarting from zero
## Tests
Instrumented:
- Insert 100 synthetic points across four flushes; assert accumulated distance equals the
batch computation over the same points (the cross-batch anchor regression test)
- Pause/resume with a 5 km jump between segments; assert the gap is excluded
- Simulate restart: seed accumulator from DB, continue, assert continuity
Emulator: feed a synthetic ride and compare the live displayed distance against the
post-stop recomputation — they should differ by nothing.
## Risks / gotchas
- **The cross-batch anchor is the subtle bug here.** Losing `lastPoint` between flushes
silently under-reports distance by a few percent, which is exactly the kind of error
nobody notices until they compare against a bike odometer.
- **Same transaction as the insert.** Points and their aggregate contribution must commit
atomically or a crash leaves them disagreeing.
- **Do not let the accumulator's definitions drift from T05.** Share constants; do not
copy magic numbers.
## Out of scope
Displaying any of this (T09, T11).

View File

@@ -0,0 +1,98 @@
# T08 — Navigation shell + ViewModels
**Phase** 4 · **Depends on** T01 · **Status** Done
## Goal
Three navigable destinations, theme extracted out of the Activity, and state moved into
ViewModels. Done when Record, Trips, and Trip Detail are reachable with a working back
stack and `MainActivity` no longer owns screen state.
## Context
`app/src/main/java/com/rippr/MainActivity.kt` (216 lines) currently does everything:
permission launcher, service intents, theme definition, screen composition, and state.
That was proportionate for one screen and will not survive three.
Existing pieces to preserve rather than rewrite:
- The permission flow (`RequestMultiplePermissions`, with `POST_NOTIFICATIONS` added only
on TIRAMISU+) works and should move largely as-is.
- The battery-optimisation exemption prompt is genuinely useful for an app that must
survive a multi-hour ride; keep it on the Record screen.
- `RipprTheme` is currently a private composable applying `darkColorScheme()`. Dylan
called the UI "simple and clean" and explicitly likes it — **do not redesign it**.
Extract, do not restyle.
- `StatRow` and `BigStat` are reusable; move them into a shared `ui/components/`.
## Design
```
ui/
theme/ Theme.kt, Color.kt
components/ StatRow.kt, BigStat.kt, ConfirmDialog.kt
record/ RecordScreen.kt, RecordViewModel.kt
trips/ TripsScreen.kt, TripsViewModel.kt
detail/ TripDetailScreen.kt, TripDetailViewModel.kt
RipprNavHost.kt
```
Routes:
| Route | Screen |
|---|---|
| `record` | Start destination |
| `trips` | Trip list |
| `trip/{tripId}` | Detail, `tripId: Long` argument |
`navigation-compose` rather than hand-rolled state switching: the back stack from detail
→ list is worth not writing by hand, and it gives correct behaviour on the system back
gesture for free.
**ViewModels** get the repository through a simple factory — the app already uses a
`@Volatile` singleton for `AppDatabase` and T03 adds one for `TripRepository`. Hilt would
be more ceremony than a three-screen app earns.
**Navigation from Record → Trips** goes in a top bar action, keeping the Record screen's
centre free for the numeric readout.
## Implementation
1. Extract `ui/theme/` from `MainActivity`.
2. Move `StatRow` / `BigStat` into `ui/components/`, unchanged.
3. Create `RipprNavHost` with the three routes.
4. Add a ViewModel per screen with a shared factory.
5. Reduce `MainActivity` to: permissions, battery exemption, `setContent { RipprTheme { RipprNavHost() } }`.
6. Move service-intent dispatch into `RecordViewModel`.
## Acceptance criteria
- [x] All three destinations reachable; system back works from detail
- [x] `MainActivity` holds no screen state
- [x] Visual appearance of the Record screen is unchanged
- [x] Permission flow and battery-optimisation prompt still function
- [ ] Config change (rotation) does not lose state
> **Not verified.** The unchecked items above, and the Compose UI tests below, were
> not done. Verification for T08–T11 was manual on the emulator (screenshots through
> the full flow) plus the existing unit and instrumented suites. Compose UI tests are
> outstanding — tracked in T18.
## Tests
Instrumented Compose UI tests:
- Navigate record → trips → detail → back → back
- Rotation preserves screen state
- Existing suites stay green
## Risks / gotchas
- **Do not restyle.** The brief was "simple and clean, I like that". This task is
structural only.
- **`tripId` argument type.** Room ids are `Long`; declare `NavType.LongType` explicitly
or it silently arrives as a String.
- The v1 UI reads state via `collectAsStateWithLifecycle` already — keep that, it is the
correct choice over `collectAsState` for lifecycle-aware collection.
## Out of scope
Screen content beyond wiring (T09, T10, T11); any map (T13).

View File

@@ -0,0 +1,114 @@
# T09 — Record screen v2
**Phase** 4 · **Depends on** T06, T08 · **Status** Done
## Goal
Add Pause/Resume, Stop, and Discard to the recording screen while keeping the clean
numeric readout intact, and show per-trip rather than lifetime stats. Done when a ride
can be fully controlled from the screen and the numbers reflect the current trip only.
## Context
Direct feedback: *"app is simple and clean. I like that but it's missing stuff."* The
missing stuff is controls, not decoration. **Resist adding visual weight.**
v1's screen shows `MAX SPEED` as a large monospace figure, then a card with points
captured, avg speed, duration, and pending upload, then one full-width button. That
layout stays; it gains controls and loses its lifetime scope.
The current single button toggles start/stop via `RecordingState.isRecording`. v2 has
three states and needs a different control arrangement.
## Design
### Control layout by state
| State | Primary | Secondary |
|---|---|---|
| Idle | **START RECORDING** (full width) | — |
Service actions to dispatch: `TrackingService.ACTION_START` / `ACTION_PAUSE` /
`ACTION_RESUME` / `ACTION_STOP` / `ACTION_DISCARD`. Only START uses
`startForegroundService`; the rest are plain `startService` on the running service.
| Recording | **PAUSE** | STOP |
| Paused | **RESUME** | STOP · DISCARD |
Discard appears **only when paused** — deliberately. Offering a destructive action next
to Pause during an active ride invites a gloved mis-tap at 100 km/h. Pausing first is a
natural speed bump.
Discard shows a confirmation dialog stating what is lost ("Delete this ride? N points
recorded over X km will be permanently deleted."). Stop and Pause need no confirmation.
### Replacing T02 scaffolding
`MainActivity` derives stats with `flatMapLatest` over `TripRepository.observeActiveTrip()`
into `observeTripStats(tripId)`, and `isRecording` from `observeActiveTrip().map { it != null }`.
Upload errors come from the `UploadStatus` object. All of that moves into `RecordViewModel`,
and once T07 lands the screen reads persisted aggregates off the `Trip` row rather than
recomputing in SQL.
`onToggleClicked` is now a `lifecycleScope.launch` that queries `trips.activeTrip()` — there
is no synchronous recording flag any more. The ViewModel should expose state instead so the
screen never has to suspend to decide what a button does.
### Stats become per-trip
Driven by `observeActiveTrip()` from T03, showing the live aggregates from T07:
- Max speed (the big figure — it is the number Dylan cares about)
- Distance — **new in v2**, read straight off `Trip.distanceM` (no SQL recomputation)
- Duration, moving time
- Points captured
- Pending upload (only when non-zero, and only if an endpoint is configured)
When idle, the screen shows a resting state rather than stale numbers from the last ride.
## Implementation
1. `RecordViewModel` exposes `RecordUiState` derived from `observeActiveTrip()`.
2. Map `TripState` to the control layout above.
3. Dispatch service intents for each action.
4. Add `ConfirmDialog` for Discard.
5. Reuse `Telemetry.formatDuration` for both duration fields.
6. Keep the battery-optimisation prompt.
7. Add a top-bar action to reach the Trips list.
## Acceptance criteria
- [x] All five transitions work from the UI
- [x] Discard is unavailable while actively recording
- [x] Discard confirms, and states what will be lost
- [ ] Stats reset between trips — no bleed from the previous ride
- [x] Distance appears and increases during a ride
- [x] Idle state is visibly distinct from a zeroed ride
- [ ] Screen still reads at a glance; no added clutter
> **Not verified.** The unchecked items above, and the Compose UI tests below, were
> not done. Verification for T08–T11 was manual on the emulator (screenshots through
> the full flow) plus the existing unit and instrumented suites. Compose UI tests are
> outstanding — tracked in T18.
## Tests
Compose UI tests:
- Each state renders the right controls
- Discard dialog appears, cancel is non-destructive
- State survives rotation mid-ride
Emulator end-to-end: drive the full lifecycle through taps, then verify the database
matches what the screen claimed.
## Risks / gotchas
- **Tap targets.** These get used in gloves. Keep the 64–72dp button height v1 uses.
- **Wait for the UI before tapping in tests.** v1's cold start took 8.7 s; a fixed sleep
produced a silently-missed tap and a false "service didn't start" conclusion. Poll
`uiautomator dump` for the button text instead.
- **Do not show "Pending upload" when no endpoint is configured** — it reads as an error
when it is just an unused feature.
## Out of scope
Trips list (T10), any map, live path preview.

View File

@@ -0,0 +1,89 @@
# T10 — Trips list screen
**Phase** 4 · **Depends on** T02, T08 · **Status** Done
## Goal
A scrollable list of completed rides, each showing enough to identify it, tapping through
to detail. Done when past rides are browsable and the list updates reactively as rides
complete.
## Context
There is currently no way to see a past ride at all — v1 has one screen showing lifetime
totals. This is the first place trips become visible as objects.
`TripDao.observeCompletedTrips()` from T02 provides the data, ordered `startedAt DESC`.
All displayed values are already persisted on the `Trip` row by T07, so **this screen
runs no computation** — no loading points, no statistics. That keeps it fast with
hundreds of rides.
## Design
One row per trip:
```
Sat 9 Aug · 14:32 47.2 km
1h 12m moving · max 118 km/h ▸
```
- Name if set, otherwise a date-derived label ("Sat 9 Aug · 14:32")
- Distance as the right-aligned prominent figure
- Moving time and max speed as the secondary line
- Monospace with `tabular-nums` for the figures so columns align down the list
**Empty state** matters here — a new install has no trips, and an empty screen with no
explanation reads as broken. Show a short line pointing at the Record tab.
**An in-progress trip is excluded** (`endedAt IS NULL`). A half-finished ride in the
history list with a growing distance would be confusing; the Record screen owns that.
Grouping by month is tempting but premature. Revisit when there are enough rides to
justify it.
## Implementation
1. `TripsViewModel` exposing `observeCompletedTrips()` as state.
2. `LazyColumn` of `TripRow`.
3. Date formatting via `java.time` with the device locale and zone — **not** hardcoded
patterns. Ride timestamps come from `Location.time`, which is UTC epoch millis.
4. Reuse `Telemetry.formatDuration`.
5. Distance formatting helper — km with one decimal; metres below 1 km.
6. Tap navigates to `trip/{tripId}`.
7. Empty state.
## Acceptance criteria
- [x] Completed trips listed newest first
- [x] Active trip excluded
- [x] Empty state shown on a fresh install
- [x] Tap opens the right trip
- [ ] List updates when a ride completes, without manual refresh
- [x] No point-table reads — the screen uses only `Trip` rows
> **Not verified.** The unchecked items above, and the Compose UI tests below, were
> not done. Verification for T08–T11 was manual on the emulator (screenshots through
> the full flow) plus the existing unit and instrumented suites. Compose UI tests are
> outstanding — tracked in T18.
## Tests
Compose UI tests:
- Empty state on no data
- Rows render in the right order
- Tap emits the right navigation event
- Active trip absent from the list
Instrumented DAO test for ordering and the `endedAt IS NULL` exclusion.
## Risks / gotchas
- **Timezone.** `Location.time` is UTC. Format in the device zone or a ride at 21:00
local shows as tomorrow.
- **Do not compute stats here.** If a value is missing from the `Trip` row, fix T07
rather than loading points in the list — that path leads to a screen that janks after
fifty rides.
## Out of scope
Rename/delete/merge (T12); detail content (T11).

View File

@@ -0,0 +1,86 @@
# T11 — Trip detail screen (stats only)
**Phase** 4 · **Depends on** T05, T08 · **Status** Done
## Goal
A full breakdown of a single ride — summary figures, elevation profile, speed
distribution — with a deliberate placeholder where the map will go. Done when every
statistic renders correctly and the screen is verifiable before osmdroid lands.
## Context
Splitting this from the map is intentional. osmdroid brings tile caching, `MapView`
lifecycle, and coordinate projection — three sources of subtle failure. Building the
screen first means that when the map misbehaves in T13, the surrounding screen is already
known-good and the bug has nowhere to hide.
Data comes from `RideStatistics.compute()` (T05) over `pointsForTrip(id)` and
`segmentsForTrip(id)`. Unlike the list screen, this one **does** load points — a few
thousand rows for one trip is fine, and the derived series need them.
## Design
Sections top to bottom:
1. **Header** — name (inline-editable in T12) and start date/time
2. **Map placeholder** — a bordered box captioned "Map coming in T13", replaced wholesale
by T13/T14. Reserving the space now means the layout does not shift later.
3. **Summary grid** — distance, moving time, elapsed time, max speed, average moving
speed, elevation gain
4. **Elevation profile** — line chart over distance-along-path
5. **Speed distribution** — histogram in 10 km/h buckets
6. **Segments** — listed only when there is more than one, showing where the ride paused
Charts are drawn with **Compose `Canvas`**, not a charting library. Two simple series do
not justify a dependency, and hand-drawn keeps them consistent with the existing visual
language.
Both charts need: an explicit empty/insufficient-data state, axis labels with units, and
`tabular-nums` on any numeric label.
**Point loading happens off the main thread** in the ViewModel, with a loading state. A
three-hour ride is ~21,600 rows; loading that synchronously would jank the transition
into the screen.
## Implementation
1. `TripDetailViewModel` loading trip, points, and segments on `Dispatchers.IO`, exposing
`Loading | Ready(summary, profile, histogram, segments) | NotFound`.
2. Summary grid from `RideSummary`.
3. `ElevationChart` composable — `Canvas`, path stroke, min/max labels.
4. `SpeedHistogram` composable — `Canvas`, bars, bucket labels.
5. Segment list, shown conditionally.
6. Map placeholder box.
7. `NotFound` state for a deleted trip.
## Acceptance criteria
- [x] All summary figures match `RideStatistics.compute()`
- [ ] Charts render for a real ride and degrade gracefully with <2 points
- [x] Segments section appears only when the ride was paused
- [x] Loading state shown while points load; no main-thread I/O
- [ ] Deleted trip shows `NotFound` rather than crashing
- [x] Map placeholder occupies the space the real map will take
> **Not verified.** The unchecked items above, and the Compose UI tests below, were
> not done. Verification for T08–T11 was manual on the emulator (screenshots through
> the full flow) plus the existing unit and instrumented suites. Compose UI tests are
> outstanding — tracked in T18.
## Tests
Compose UI tests: loading, ready, not-found, single-segment vs multi-segment.
Unit tests for the chart data-preparation helpers (scaling, bucketing) — the drawing
itself is verified by screenshot on the emulator.
## Risks / gotchas
- **Main-thread point loading is the obvious trap here.** Keep it in the ViewModel on IO.
- **Chart division by zero** when all altitudes or speeds are identical — a flat ride
gives a zero-height range. Guard the scaling.
- **Do not start integrating the map in this task.** The separation is the point.
## Out of scope
The map (T13, T14), rename/delete/merge (T12), export (T16).

View File

@@ -0,0 +1,109 @@
# T12 — Trip rename / delete / merge
**Phase** 4 · **Depends on** T10, T11 · **Status** Done
## Goal
Let rides be renamed, deleted, and merged. Done when a ride can be given a meaningful
name, junk test rides can be removed, and two trips can be combined into one with correct
recomputed aggregates.
## Context
Merge exists as a **safety valve**. It was originally promised to rescue a ride wrongly
split by the migration heuristic; that heuristic is gone now that v1 data is being
discarded, but merge keeps earning its place:
- A ride accidentally stopped and restarted mid-route
- Test rides that should be one logical trip
- Any future automatic splitting (v3+) needs an undo
Delete is the mundane but most-used of the three — a session of testing leaves a dozen
30-second trips cluttering the list.
`Trip.name` is already nullable in the T02 schema, with the UI deriving a date label when
it is null. CASCADE foreign keys mean delete is a single statement.
## State after T08–T11
`TripDetailViewModel` already has `rename(name)` and `delete(onDeleted)`;
`TripsViewModel` has `delete(tripId)`. The repository has `renameTrip` (which already
collapses blank to null) and `deleteTrip`. What is missing is the **merge** operation and
all of the UI: selection mode on the list, the rename affordance on detail, and the
confirmation dialogs. `ConfirmDialog` exists in `ui/components/Stats.kt`.
## Design
### Rename
Inline edit on the detail header. Empty input clears back to null (date-derived label)
rather than storing an empty string — those two states must not diverge.
### Delete
From both the list (swipe or overflow) and detail. Confirmation stating what is lost.
CASCADE removes segments and points. After deleting from detail, navigate back.
### Merge
Selected from the trips list: long-press to enter selection mode, pick exactly two, then
Merge.
Semantics:
- The **earlier** trip (by `startedAt`) is the survivor
- The later trip's segments are re-parented to the survivor
- **Segments are never joined** — the boundary between the two rides becomes a segment
boundary, exactly like a pause. This is correct: the rider genuinely was not recording
in between, and joining them would draw a false straight line across the gap
- `endedAt` becomes the later trip's `endedAt`
- The now-empty later trip row is deleted
- Aggregates are **recomputed from scratch** with `RideStatistics.compute()`, not summed
- The survivor keeps its name if set; otherwise inherits the later trip's
Merge runs in a single `@Transaction` — a partial merge would leave orphaned segments.
Merging is not offered while either trip is active.
## Implementation
1. `TripRepository.renameTrip(id, name: String?)`
2. `TripRepository.deleteTrip(id)` — CASCADE
3. `TripRepository.mergeTrips(survivorId, absorbedId)` in one transaction:
re-parent segments → update `endedAt` → delete absorbed row → recompute aggregates
4. Selection mode in `TripsScreen`, enabled at exactly two selections
5. Confirmation dialogs for delete and merge; merge states the resulting distance
6. Inline rename on the detail header
## Acceptance criteria
- [x] Rename persists; clearing reverts to the date label
- [x] Delete removes the trip and every point (verified by row count)
- [x] Merge re-parents all segments and deletes the absorbed row
- [x] Merged aggregates equal a fresh computation over the combined points
- [x] Merged trip shows a segment boundary at the join, not a joined line
- [x] Merge unavailable unless exactly two completed trips are selected
- [x] Merge is atomic — an interrupted merge leaves no orphans
## Tests
Instrumented:
- Delete cascades: zero segments, zero points remain
- Merge: segment count is the sum of both; point count is the sum; no orphans
- Merged aggregates match `RideStatistics.compute()` over combined points
- Merge of trips out of chronological order still picks the earlier as survivor
- Rename to empty stores null, not `""`
Compose UI: selection mode enables Merge only at two selections. **Not written** — the
UI-test debt from T08–T11 covers this too; tracked in T18.
## Risks / gotchas
- **Do not sum aggregates on merge.** Distance is not additive across the join — there is
a real gap. Recompute.
- **Chronological survivor.** Selection order is not ride order; sort by `startedAt`.
- **Deleting the active trip** must go through the service's discard path (T06), not a
raw delete, or the service keeps writing into a deleted trip.
## Out of scope
Splitting a trip; bulk delete; undo.

View File

@@ -0,0 +1,119 @@
# T13 — osmdroid integration + map toggle
**Phase** 5 · **Depends on** T11 · **Status** Done
## Goal
A working osmdroid map on the trip detail screen, correctly bound to the Compose
lifecycle and controlled by a user toggle. Done when tiles render, the map can be
navigated away from and returned to repeatedly without leaking, and turning the toggle off
means no tile is ever fetched.
## Context
Explicit constraint from Dylan: *"ensure the map is only live when the app is opened. In
reality, 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."*
This is a hard architectural boundary. **`TrackingService` must never reference anything
in this task.** No map object, no tile fetch, no location→map plumbing exists outside the
Compose lifecycle of the detail screen. A live map is a v3+ conversation.
osmdroid was 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 with no signal.
## Design
### Two mandatory pieces of setup
**User agent.** osmdroid defaults to a user agent OSM's tile servers reject with 403.
Set it before any map is constructed:
```kotlin
Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID
```
**Tile cache location.** Point `osmdroidBasePath` and `osmdroidTileCache` at
app-private storage (`context.filesDir` / `context.cacheDir`). Older osmdroid guidance
uses external storage and would drag in a storage permission for no reason.
### Lifecycle — the part that actually breaks
`MapView` is a View with its own lifecycle that does not automatically follow Compose.
Getting this wrong is the classic osmdroid leak. Required wiring:
```kotlin
AndroidView(
factory = { MapView(it).apply { /* config */ } },
update = { /* apply state */ },
onRelease = { it.onDetach() }, // mandatory — releases tile handles
)
```
plus a `DisposableEffect` observing `LifecycleOwner` to forward `onResume` / `onPause`.
Without `onDetach()`, tile handles and the tile-downloader thread survive the composable
and the leak compounds every time detail is opened.
### The toggle
`Config.mapEnabled`, default **on**, following the existing SharedPreferences pattern in
`Config.kt` (`uploadEndpoint` / `deviceId`). Exposed as a switch on the trip detail
screen — no settings screen exists yet and one switch does not justify creating one.
When off: the composable is never created, so no tile request is issued at all. This must
be a genuine short-circuit, not a hidden map.
### OSM tile usage policy
OSM's public tiles are a donated resource. Set a real user agent, do not bulk-prefetch,
and keep zoom levels reasonable. If usage ever grows, self-host or switch providers.
## Where it plugs in
`TripDetailScreen` already reserves a 200dp `Card` captioned "Map arrives in T13", so the
layout will not shift. `TripDetailUiState.Ready` already carries `points` and `segments`,
loaded off the main thread, so no new data plumbing is needed.
## Implementation
1. Application-level init of `Configuration` before first map use.
2. `Config.mapEnabled` getter/setter following the existing pattern.
3. `ui/components/OsmMap.kt` — the `AndroidView` wrapper with full lifecycle wiring.
4. Replace the T11 map placeholder with `OsmMap`, gated on the toggle.
5. Toggle switch in the detail screen.
6. Verify no permission was added to the merged manifest.
## Acceptance criteria
- [x] Tiles render on trip detail
- [x] Toggling off means zero tile requests (verify with logcat / no cache growth)
- [ ] Navigating in and out of detail 20 times shows no unbounded memory growth
- [ ] Rotation does not crash or duplicate the map
- [x] No new permission in the merged manifest
- [x] `TrackingService` contains no reference to any map type
- [x] Tile cache lives in app-private storage
> **Not verified.** Unchecked items above were not tested. Speed colouring in
> particular is unverifiable on the emulator, which reports zero velocity — the
> rendered path is uniformly the low-speed colour there. Tracked in T18.
## Tests
Instrumented: repeated navigation in/out asserting no leak; toggle-off asserts the
composable is absent.
Manual on emulator: screenshot the map, rotate, navigate away and back, confirm tiles
still render and memory is stable in `adb shell dumpsys meminfo com.rippr`.
## Risks / gotchas
- **`onDetach()` is not optional.** Skipping it is the single most common osmdroid bug.
- **403 from tile servers** means the user agent was not set early enough — it must be
configured before the first `MapView` is constructed, not inside the composable.
- **Do not let this creep into the service.** The whole point of the toggle and the
detail-only placement is that the map never runs while the phone is in a pocket.
## Out of scope
Drawing the path (T14) — this task only proves a map renders and behaves.

View File

@@ -0,0 +1,108 @@
# T14 — Path rendering
**Phase** 5 · **Depends on** T04, T13 · **Status** Done
## Goal
Draw the ride on the map: one polyline per segment, coloured by speed, decimated for
performance, auto-fitted to the route. Done when a real ride renders smoothly with
visible gaps at pauses and colour that tracks pace.
## Context
This is the feature Dylan actually asked for — *"I wanna see my movements plotted on a
map like Google Maps or Uber, see the exact path I traversed."*
The data has been there since v1. Every point carries `latitude`, `longitude`, and
`speedKmh` at 1–2 Hz. Nothing new is captured; this renders what already exists.
Scale is the constraint: a three-hour ride at 2 Hz is ~21,600 points. Handing that
straight to a polyline renderer janks badly on pan and zoom.
## Design
### Decimation — render only
`Geo.simplify()` (T04) with an epsilon chosen from current zoom, roughly "half a pixel at
this scale". A sensible default is ~5 m at typical zoom, cutting a 21,600-point ride to a
few thousand with no visible difference.
> **Raw points are never decimated in storage or export.** Decimation exists purely in
> the render path. T15's tests assert full point counts precisely to catch any leak of
> this optimisation into exported data.
### One polyline per segment
Segments must not be joined. A pause means the rider stopped recording; connecting across
it draws a straight line through terrain never travelled — the exact artefact the Segment
model exists to prevent. Each segment gets its own `Polyline`, so gaps appear naturally.
### Speed colouring
Gradient from a cool colour at low speed to the app's accent orange (`#FF5722`) at high
speed, normalised against the trip's own max speed so every ride uses the full range.
osmdroid 6.1 offers `PolyChromaticPaintList` for per-vertex colouring. It is fiddly. If
it fights back, **fall back to bucketed polylines** — split each segment into runs of
similar speed (say 10 km/h buckets) and draw each run as its own monochrome polyline.
Visually near-identical, considerably more predictable. Do not burn hours on the
chromatic API.
Include a small legend; an unexplained colour gradient is decoration rather than
information.
### Auto-fit and markers
`Geo.bounds()` (T04) → `mapView.zoomToBoundingBox(bounds, false, padding)`. Guard the
degenerate case: a stationary "ride" has zero-area bounds and zooming to it either throws
or lands at maximum zoom. Fall back to centring at a fixed zoom.
Start and end markers, styled minimally.
## Implementation
1. Load points grouped by segment (already ordered `segmentId, id` from T02).
2. Decimate each segment independently — never across a boundary.
3. Build one `Polyline` per segment with speed-derived colour.
4. Add start/end markers.
5. Auto-fit with padding; handle degenerate bounds.
6. Recompute decimation on significant zoom change; debounce it.
7. Legend.
## Acceptance criteria
- [ ] Real ride renders with no straight-line artefact across pauses
- [ ] Colour visibly tracks speed
- [ ] A 20,000-point ride pans and zooms smoothly
- [x] Auto-fit frames the whole route with padding
- [x] A stationary ride does not crash the fit
- [x] Exported GPX still contains every raw point (verified: 24 points recorded, 24 exported)
- [x] Single-point and empty trips render without crashing
> **Not verified.** Unchecked items above were not tested. Speed colouring in
> particular is unverifiable on the emulator, which reports zero velocity — the
> rendered path is uniformly the low-speed colour there. Tracked in T18.
## Tests
Unit: decimation is applied per segment and never merges across boundaries; the
speed→colour mapping is monotonic and handles a zero-range (constant-speed) ride.
Instrumented/manual: render a synthetic paused ride on the emulator and screenshot;
confirm the gap is visible. Then confirm on a real ride — the emulator cannot produce
velocity, so **speed colouring can only be truly validated on a real ride**
(`adb emu geo fix` teleports, leaving `Location.speed` at 0 and every point the same
colour).
## Risks / gotchas
- **Speed colouring is unverifiable on the emulator.** Expect a uniform-colour path
there; that is the harness, not a bug. Validate on a real ride.
- **Zero-range normalisation** — a constant-speed ride divides by zero. Guard it.
- **Decimation leaking into export** is the correctness risk in this task. Keep the
simplified list strictly local to rendering.
- **Debounce zoom-triggered re-decimation** or a pinch gesture recomputes dozens of times.
## Out of scope
Live path during recording (v3+); route matching; heatmaps.

View File

@@ -0,0 +1,118 @@
# T15 — GPX + GeoJSON writers
**Phase** 6 · **Depends on** T04 · **Status** Done
## Goal
Serialise a trip to GPX 1.1 and GeoJSON as pure strings, with pause structure preserved.
Done when a generated GPX opens correctly in Strava, Garmin Connect, or Google Earth, and
both formats are covered by unit tests running without a device.
## Context
Export is the hedge against Rippr's own map ever being limiting — it makes the data
portable into tooling that already exists. It is also cheap: pure string generation with
no Android dependency, fully unit-testable, and parallelisable with the entire UI track.
Everything needed is already recorded: `latitude`, `longitude`, `altitudeM`, `timestamp`,
`speedKmh` per point, and segments delimiting pauses.
## Design
### GPX 1.1
```xml
<gpx version="1.1" creator="Rippr" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>…</name><time>…</time></metadata>
<trk>
<name>…</name>
<trkseg> <!-- one per Segment -->
<trkpt lat="51.04470" lon="-114.07190">
<ele>1045.0</ele>
<time>2026-08-10T13:48:50Z</time>
</trkpt>
</trkseg>
</trk>
</gpx>
```
**One `<trkseg>` per Segment** is the whole reason the Segment model exists — it is
exactly how GPX represents a recording gap, so pauses survive the round-trip into Strava
or Garmin rather than becoming a straight line across town.
Speed goes in a `<extensions>` block. Speed is not part of core GPX 1.1; consumers that
do not understand the extension ignore it, which is the correct degradation.
Formatting rules:
- Timestamps ISO 8601 **UTC with a `Z` suffix** — `Location.time` is already UTC epoch
millis, so no zone conversion, and local time here would be silently wrong
- Coordinates at 7 decimal places (~1 cm — beyond GPS precision but standard practice)
- Elevation at 1 decimal
- **XML-escape** any user-supplied trip name; a name containing `&` or `<` otherwise
produces a malformed file
### GeoJSON
`FeatureCollection`, one `LineString` `Feature` per segment, with trip metadata in
`properties`. Coordinates are `[lon, lat, ele]` — **longitude first**, which is the
opposite order to GPX and the most common mistake in GeoJSON output.
### API
```kotlin
object GpxWriter {
fun write(trip: Trip, segments: List<Segment>, points: List<TrackPoint>): String
}
object GeoJsonWriter {
fun write(trip: Trip, segments: List<Segment>, points: List<TrackPoint>): String
}
```
Pure functions over already-loaded data — no repository, no context, no I/O.
## Implementation
1. Create `export/GpxWriter.kt` and `export/GeoJsonWriter.kt`.
2. Group points by `segmentId`, preserving `segmentId, id` order.
3. ISO 8601 UTC formatting via `java.time.Instant`.
4. XML escaping for all interpolated text.
5. Build with `StringBuilder` — a full XML DOM is unnecessary for this shape.
6. Unit tests.
## Acceptance criteria
- [x] GPX is well-formed XML and validates against the GPX 1.1 schema
- [x] `<trkseg>` count equals segment count
- [x] Every raw point is present — **no decimation** (the T14 guard)
- [x] Timestamps are UTC with `Z`
- [x] A trip name containing `&`, `<`, `"` produces valid XML
- [x] GeoJSON coordinates are `[lon, lat, ele]`
- [x] Empty trip produces a valid document with no track points, not a crash
## Tests
JVM unit tests:
- Parse generated GPX with a real XML parser and assert structure
- Segment count matches; point count matches input exactly
- Timestamp format assertion against a known epoch
- XML-escaping test with a hostile trip name
- GeoJSON coordinate order — explicitly assert lon-first
- Empty and single-point trips
Manual: export a real ride and open it in Google Earth or Strava. This is the acceptance
test that actually matters — schema validity does not guarantee a consumer accepts it.
## Risks / gotchas
- **Coordinate order differs between the two formats.** GPX is lat/lon attributes;
GeoJSON is lon-first arrays. Easy to get backwards, and the result silently plots in
the wrong hemisphere.
- **Decimation must not appear here.** Assert full point counts in tests.
- **Unescaped names produce malformed XML** — the failure is invisible until an import
fails.
- **Memory**: a 21,600-point ride as one `String` is a few MB. Acceptable, but if trips
grow much larger, stream to the output instead.
## Out of scope
File writing, sharing, SAF (all T16).

Some files were not shown because too many files have changed in this diff Show More