Compare commits
6 Commits
0692f5278b
...
43bb7f4fa1
| Author | SHA1 | Date | |
|---|---|---|---|
| 43bb7f4fa1 | |||
| 247b9cdb3f | |||
| 280fd7f988 | |||
| 4debdfa518 | |||
| 80c9373ded | |||
| eaa804fc35 |
54
RIPPR.md
Normal 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
13
rippr-src/.gitignore
vendored
Normal 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
@@ -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`.
|
||||
112
rippr-src/app/build.gradle.kts
Normal 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
285
rippr-src/app/schemas/com.rippr.data.AppDatabase/2.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
197
rippr-src/app/src/androidTest/java/com/rippr/data/MergeTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
225
rippr-src/app/src/androidTest/java/com/rippr/data/SchemaTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
56
rippr-src/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
48
rippr-src/app/src/main/java/com/rippr/Config.kt
Normal 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)
|
||||
}
|
||||
30
rippr-src/app/src/main/java/com/rippr/LiveTelemetry.kt
Normal 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
|
||||
}
|
||||
}
|
||||
75
rippr-src/app/src/main/java/com/rippr/MainActivity.kt
Normal 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)) }
|
||||
}
|
||||
}
|
||||
114
rippr-src/app/src/main/java/com/rippr/RideAccumulator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
25
rippr-src/app/src/main/java/com/rippr/RipprApp.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
66
rippr-src/app/src/main/java/com/rippr/Telemetry.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
99
rippr-src/app/src/main/java/com/rippr/TelemetryUploader.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
526
rippr-src/app/src/main/java/com/rippr/TrackingService.kt
Normal 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"))
|
||||
}
|
||||
}
|
||||
21
rippr-src/app/src/main/java/com/rippr/UploadStatus.kt
Normal 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
|
||||
}
|
||||
}
|
||||
60
rippr-src/app/src/main/java/com/rippr/data/AppDatabase.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
26
rippr-src/app/src/main/java/com/rippr/data/RideStats.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
37
rippr-src/app/src/main/java/com/rippr/data/Segment.kt
Normal 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
|
||||
}
|
||||
29
rippr-src/app/src/main/java/com/rippr/data/SegmentDao.kt
Normal 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
|
||||
}
|
||||
47
rippr-src/app/src/main/java/com/rippr/data/TrackPoint.kt
Normal 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,
|
||||
)
|
||||
81
rippr-src/app/src/main/java/com/rippr/data/TrackPointDao.kt
Normal 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()
|
||||
}
|
||||
48
rippr-src/app/src/main/java/com/rippr/data/Trip.kt
Normal 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
|
||||
}
|
||||
80
rippr-src/app/src/main/java/com/rippr/data/TripDao.kt
Normal 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()
|
||||
}
|
||||
192
rippr-src/app/src/main/java/com/rippr/data/TripRepository.kt
Normal 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)
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
151
rippr-src/app/src/main/java/com/rippr/export/RideExport.kt
Normal 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("&")
|
||||
'<' -> append("<")
|
||||
'>' -> append(">")
|
||||
'"' -> append(""")
|
||||
'\'' -> append("'")
|
||||
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('"')
|
||||
}
|
||||
}
|
||||
165
rippr-src/app/src/main/java/com/rippr/geo/Geo.kt
Normal 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
|
||||
}
|
||||
}
|
||||
302
rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt
Normal 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
|
||||
}
|
||||
}
|
||||
40
rippr-src/app/src/main/java/com/rippr/ui/Format.kt
Normal 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"
|
||||
}
|
||||
60
rippr-src/app/src/main/java/com/rippr/ui/RipprNavHost.kt
Normal 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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
rippr-src/app/src/main/java/com/rippr/ui/RipprViewModels.kt
Normal 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")
|
||||
220
rippr-src/app/src/main/java/com/rippr/ui/components/RideMap.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
67
rippr-src/app/src/main/java/com/rippr/ui/components/Stats.kt
Normal 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") } },
|
||||
)
|
||||
}
|
||||
@@ -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") } },
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
192
rippr-src/app/src/main/java/com/rippr/ui/record/RecordScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
54
rippr-src/app/src/main/java/com/rippr/ui/theme/Theme.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
192
rippr-src/app/src/main/java/com/rippr/ui/trips/TripsScreen.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
18
rippr-src/app/src/main/res/drawable/ic_stat_rippr.xml
Normal 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>
|
||||
@@ -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>
|
||||
4
rippr-src/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#101418</color>
|
||||
</resources>
|
||||
4
rippr-src/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Rippr</string>
|
||||
</resources>
|
||||
8
rippr-src/app/src/main/res/values/themes.xml
Normal 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>
|
||||
2
rippr-src/app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content />
|
||||
6
rippr-src/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
181
rippr-src/app/src/test/java/com/rippr/AccumulatorTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
98
rippr-src/app/src/test/java/com/rippr/TelemetryTest.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
150
rippr-src/app/src/test/java/com/rippr/TelemetryUploaderTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
211
rippr-src/app/src/test/java/com/rippr/export/RideExportTest.kt
Normal 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(
|
||||
"&<>"'",
|
||||
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())
|
||||
}
|
||||
}
|
||||
210
rippr-src/app/src/test/java/com/rippr/geo/GeoTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
6
rippr-src/build.gradle.kts
Normal 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
|
||||
}
|
||||
262
rippr-src/design/build_preview.py
Normal 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()
|
||||
187
rippr-src/design/gen_logos.py
Normal 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()
|
||||
245
rippr-src/design/logo-review.html
Normal file
1
rippr-src/design/logos/rippr-apex-onbg.svg
Normal 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 |
1
rippr-src/design/logos/rippr-apex.svg
Normal 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 |
1
rippr-src/design/logos/rippr-heading-onbg.svg
Normal 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 |
1
rippr-src/design/logos/rippr-heading.svg
Normal 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 |
1
rippr-src/design/logos/rippr-route-onbg.svg
Normal 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 |
1
rippr-src/design/logos/rippr-route.svg
Normal 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 |
7
rippr-src/design/material/LICENSE.md
Normal 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).
|
||||
1
rippr-src/design/material/explore.svg
Normal 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 |
1
rippr-src/design/material/moving.svg
Normal 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 |
1
rippr-src/design/material/navigation.svg
Normal 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 |
1
rippr-src/design/material/route.svg
Normal 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 |
1
rippr-src/design/material/speed.svg
Normal 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 |
1
rippr-src/design/material/sports_motorsports.svg
Normal 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 |
1
rippr-src/design/material/two_wheeler.svg
Normal 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 |
173
rippr-src/design/to_vector_drawable.py
Normal 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()
|
||||
288
rippr-src/docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Architecture
|
||||
|
||||
The decisions that shaped Rippr, and — more usefully — *why*. Several exist because
|
||||
something specific went wrong; those are marked.
|
||||
|
||||
---
|
||||
|
||||
## The recording pipeline
|
||||
|
||||
```
|
||||
onLocationResult (main looper)
|
||||
│ stamp tripId + segmentId, sanitize speed, reject bad-accuracy fixes
|
||||
▼
|
||||
Channel(UNLIMITED) ──► single writer coroutine (Dispatchers.IO)
|
||||
│ batches of 25, flushed every ~2s, under writeMutex
|
||||
├─► insertPoints()
|
||||
└─► Accumulator.fold() → updateAggregates() on the Trip row
|
||||
```
|
||||
|
||||
### Why an unbounded Channel
|
||||
|
||||
The location callback must never block. Writing to SQLite from inside `onLocationResult`
|
||||
would couple GPS delivery to disk latency, and a slow flash write would drop fixes. The
|
||||
channel decouples them: the callback's `trySend` never suspends, and back pressure stalls
|
||||
the *writer*, not the sensor.
|
||||
|
||||
**Do not replace this with a direct write.** It is the single most load-bearing decision in
|
||||
the recording path.
|
||||
|
||||
### Why points are stamped at creation
|
||||
|
||||
Each `TrackPoint` carries its `tripId` and `segmentId` from the moment it is built in the
|
||||
callback, not looked up at write time.
|
||||
|
||||
This makes pausing safe: a fix already queued when the rider pauses lands in the segment it
|
||||
was actually recorded during, whatever order the writes happen in.
|
||||
|
||||
> **Correction worth remembering.** The v2 plan originally claimed that closing a segment
|
||||
> before draining the channel would misfile points. That was wrong — stamping at creation
|
||||
> already prevents it. Draining before closing is still done, but as robustness (not
|
||||
> leaving points unwritten while a ride idles), not correctness. If anyone ever refactors
|
||||
> stamping into a write-time lookup, this guarantee disappears.
|
||||
|
||||
### Why batched writes
|
||||
|
||||
A 2 Hz stream would otherwise mean two transactions per second for hours. Batching to 25
|
||||
points or 2 seconds — whichever comes first — cuts that to one write per two seconds.
|
||||
|
||||
### Why WAL journal mode
|
||||
|
||||
A ride is unrecoverable if writes are lost to a crash, but `fsync` on every insert at 2 Hz
|
||||
burns battery. WAL with `NORMAL` sync survives app crashes; only an OS-level crash can lose
|
||||
the last few points. That is the accepted trade.
|
||||
|
||||
### Why the wake lock
|
||||
|
||||
A foreground service alone does not guarantee CPU time with the screen off on all OEMs. A
|
||||
`PARTIAL_WAKE_LOCK` keeps the writer coroutine running. It is **released on pause** — a
|
||||
lunch stop has no business holding the CPU awake — and re-acquired on resume.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
```
|
||||
Trip 1───* Segment 1───* TrackPoint
|
||||
```
|
||||
|
||||
```kotlin
|
||||
Trip(id, startedAt, endedAt?, name?, state,
|
||||
distanceM, movingMillis, maxSpeedKmh, elevationGainM, pointCount)
|
||||
Segment(id, tripId, startedAt, endedAt?)
|
||||
TrackPoint(id, tripId, segmentId, timestamp, lat, lon, speedKmh, altitudeM,
|
||||
accuracyM, bearingDeg, synced)
|
||||
```
|
||||
|
||||
### Why Segment exists
|
||||
|
||||
Three of the four things v1 lacked — reset, pause, and trips — were the same missing
|
||||
concept. Segments are what make pause *correct*: they represent a pause-free stretch of
|
||||
recording, so every consumer naturally leaves a gap where the rider stopped.
|
||||
|
||||
They propagate into three places, and all three would be wrong without them:
|
||||
- **Distance** accumulates only within a segment
|
||||
- **Map polylines** are drawn one per segment, never joined
|
||||
- **GPX** emits one `<trkseg>` per segment, which is exactly how GPX represents a gap
|
||||
|
||||
### Why both `endedAt` and `state`
|
||||
|
||||
`endedAt == null` distinguishes active from finished, but cannot distinguish RECORDING from
|
||||
PAUSED. The service needs that difference to decide what to do when the OS restarts it
|
||||
mid-ride.
|
||||
|
||||
### Why aggregates are denormalised onto Trip
|
||||
|
||||
**minSdk 26 means SQLite 3.18, which has no window functions** — no `LAG`, no `OVER`. There
|
||||
is simply no SQL expression for "distance from the previous point". So consecutive-point
|
||||
maths happens in Kotlin, in the writer loop that already touches every point, and the
|
||||
result is persisted on the Trip row.
|
||||
|
||||
This also keeps the trips list fast: it reads only `Trip` rows, never the point table, so
|
||||
it stays responsive with hundreds of rides.
|
||||
|
||||
### Ordering is by `(segmentId, id)`, never `timestamp`
|
||||
|
||||
`timestamp` comes from `Location.time`, which is GPS-derived and can jump. The autoincrement
|
||||
`id` is genuinely monotonic in write order.
|
||||
|
||||
### Migrations are mandatory
|
||||
|
||||
`fallbackToDestructiveMigration()` was used during v2 development, when the only data was
|
||||
throwaway test rides, and **removed in T18**. Any schema change from here must ship a
|
||||
`Migration` against `app/schemas/com.rippr.data.AppDatabase/2.json`. Reinstating the
|
||||
fallback would silently delete every stored ride on the next version bump.
|
||||
|
||||
---
|
||||
|
||||
## State ownership
|
||||
|
||||
Recording state is derived from the database, never held in memory.
|
||||
|
||||
v1 kept an in-memory flag. It lied after process death: `START_STICKY` restarts the service
|
||||
with a null intent, and the flag came back `false` while a ride was genuinely underway.
|
||||
This was verified fixed by force-stopping the app mid-ride — the button correctly still
|
||||
read STOP.
|
||||
|
||||
`TripRepository` owns every transition, each in a `withTransaction`, and **each is
|
||||
idempotent**:
|
||||
|
||||
| Transition | Behaviour on an unexpected state |
|
||||
|---|---|
|
||||
| `startTrip` | **Adopts** an already-active trip rather than creating a second |
|
||||
| `pauseTrip` | No-op if already paused |
|
||||
| `resumeTrip` | No-op if already recording — never opens a duplicate segment |
|
||||
| `completeTrip` / `discardTrip` | No-op with no active trip |
|
||||
|
||||
Idempotency is not defensive padding: the OS can restart the service at any moment, and
|
||||
these are the states it can restart into.
|
||||
|
||||
### Ephemeral state is separate and deliberate
|
||||
|
||||
Two objects hold genuinely transient state that *should* be lost on restart:
|
||||
`UploadStatus` (last upload error) and `LiveTelemetry` (current speed, published from the
|
||||
GPS callback at sensor rate so the speedo does not wait on the 2-second flush).
|
||||
|
||||
---
|
||||
|
||||
## Statistics
|
||||
|
||||
Computed twice, deliberately, by the same code:
|
||||
|
||||
- **Live**, folded per batch into `Accumulator`, so the screen shows moving numbers
|
||||
- **Authoritatively**, via `RideStatistics.compute()` over stored points when a trip
|
||||
completes, overwriting the live estimate
|
||||
|
||||
A mid-ride process kill loses the in-memory accumulator, so the live figure can drift.
|
||||
Recomputing at completion with the *same functions* keeps the two consistent by
|
||||
construction rather than by discipline.
|
||||
|
||||
### The cross-batch anchor
|
||||
|
||||
`Accumulator` keeps the last point of the *previous* batch. Without it, distance restarts
|
||||
at every flush boundary and under-reports by roughly one hop per batch — a few percent,
|
||||
invisible until compared against an odometer. A unit test asserts that chunked folding
|
||||
equals single-batch folding.
|
||||
|
||||
### Elevation gain needs two mechanisms
|
||||
|
||||
This is the subtlest maths in the codebase, and it was measured, not assumed.
|
||||
|
||||
A naive "sum every delta above a 3 m threshold" reported **1498 m of climbing over a parked
|
||||
bike** with ±8 m altitude noise, because noise crosses any small threshold constantly.
|
||||
|
||||
The shipped version combines:
|
||||
1. A **15-sample moving average**, cutting noise by roughly √window
|
||||
2. **Reversal hysteresis** — a climb banks only once altitude turns back *down* past the
|
||||
threshold from its peak
|
||||
|
||||
That brings the same fixture to ~30 m. Smoothing then clipped real terrain (a 100 m climb
|
||||
measured 93 m, since a moving average lags by half a window), so `finish()` reconciles the
|
||||
final run against the last raw reading.
|
||||
|
||||
**~30 m of drift per ten stationary minutes remains.** The regression test guards against
|
||||
returning to 1498 m; it is not a claim of accuracy.
|
||||
|
||||
### Other definitions worth knowing
|
||||
|
||||
- **Average speed is distance ÷ moving time**, not the mean of speed samples. The mean
|
||||
over-weights time spent stopped.
|
||||
- **Sample gaps are capped at 10 s.** Without it, a two-minute tunnel counts as two minutes
|
||||
of moving time at the last known speed.
|
||||
- **The speed histogram is weighted by time**, not sample count.
|
||||
- **Division guards everywhere** — a NaN reaching Compose renders as the literal text "NaN".
|
||||
|
||||
---
|
||||
|
||||
## UI
|
||||
|
||||
`MainActivity` (74 lines) owns only runtime permissions and the battery-optimisation
|
||||
prompt. Everything else is `ui/{theme,components,record,trips,detail}` with one ViewModel
|
||||
per screen behind a single factory. No DI framework — three screens do not earn Hilt.
|
||||
|
||||
### Two hard-won UI lessons
|
||||
|
||||
**`Surface` is load-bearing, not decoration.** Moving the UI out of the old `Surface`
|
||||
wrapper made `LocalContentColor` default to black, rendering the 64sp speed figure
|
||||
invisible against the near-black ground. Text with an explicit colour still showed, so the
|
||||
screen looked merely odd rather than broken. `RipprTheme` now wraps content in a `Surface`.
|
||||
**No test would have caught this** — only looking at a screenshot did.
|
||||
|
||||
**Show current speed, not max.** The first real ride reported the screen as frozen. A max
|
||||
figure only moves when you beat it, so riding steadily leaves it motionless. The headline
|
||||
is now current speed, with max demoted to the stats card, and a wall-clock elapsed timer
|
||||
ticks every second independent of any database write.
|
||||
|
||||
### Control layout
|
||||
|
||||
| State | Primary | Secondary |
|
||||
|---|---|---|
|
||||
| Idle | START RECORDING | — |
|
||||
| Recording | PAUSE | STOP |
|
||||
| Paused | RESUME | STOP · DISCARD |
|
||||
|
||||
**Discard appears only while paused.** A destructive control next to Pause during a live
|
||||
ride invites a gloved mis-tap at speed. Buttons are 72dp/56dp because they get pressed with
|
||||
gloves on.
|
||||
|
||||
---
|
||||
|
||||
## Map
|
||||
|
||||
osmdroid, chosen over MapLibre and Google Maps because it needs no API key, no billing
|
||||
account, and no GCP project, and it caches tiles for offline use — which matters on
|
||||
mountain rides.
|
||||
|
||||
Three things that will break it if disturbed:
|
||||
|
||||
1. **The user agent must be set before any `MapView` is constructed.** osmdroid's default is
|
||||
rejected by OSM's tile servers with a 403, and the failure presents as an empty map
|
||||
rather than an error. Set in `RipprApp.onCreate`.
|
||||
2. **`onDetach()` is not optional.** Without it, tile handles and the downloader thread
|
||||
outlive the composable and the leak compounds. The `MapView` is hoisted into a
|
||||
`remember` so the lifecycle observer can reach the same instance the `AndroidView`
|
||||
shows — an earlier version observed the lifecycle and did nothing, because it could not
|
||||
see the view.
|
||||
3. **Zoom must be clamped.** `zoomToBoundingBox` ignores `maxZoomLevel`. A 50 m ride zooms
|
||||
past OSM Mapnik's maximum published zoom of 19, where no tile exists, and the map renders
|
||||
as an empty grid. This shipped in v2.0 and was found on the first real ride.
|
||||
|
||||
Rendering uses **bucketed polylines** — runs of similar speed drawn as separate monochrome
|
||||
lines, overlapping by one point — rather than osmdroid's per-vertex `PolyChromaticPaintList`,
|
||||
which is fiddly and gains little at real viewing zoom.
|
||||
|
||||
**Decimation is render-only.** `Geo.simplify` is applied when building overlays and nowhere
|
||||
else. Storage and export always use raw points; the export tests assert exact counts
|
||||
specifically to catch a leak.
|
||||
|
||||
---
|
||||
|
||||
## Export
|
||||
|
||||
Pure string generation with no Android dependency, so the format logic is unit-tested on
|
||||
the JVM.
|
||||
|
||||
- **GPX 1.1**, one `<trkseg>` per segment, speed in `<extensions>` in **m/s** per the spec
|
||||
- **GeoJSON**, coordinates `[lon, lat, ele]` — **longitude first**, the opposite of GPX and
|
||||
the classic silent error; a test asserts it explicitly
|
||||
- **Timestamps inside files are UTC**; **filenames use local time**, because a human reads
|
||||
the filename and a machine reads the contents
|
||||
- Names are XML- and JSON-escaped — a ride called `Sam & Dave's <ride>` would otherwise
|
||||
produce a malformed file, and the failure is invisible until an import rejects it
|
||||
|
||||
Delivery is via `FileProvider` and the share sheet. A raw `file://` URI throws
|
||||
`FileUriExposedException` on Android 7+, and the receiving app needs
|
||||
`FLAG_GRANT_READ_URI_PERMISSION` or it reports a SecurityException that looks like a bug in
|
||||
*that* app.
|
||||
|
||||
---
|
||||
|
||||
## Upload
|
||||
|
||||
`TelemetryUploader` is deliberately subordinate to recording: every failure is swallowed and
|
||||
retried, and nothing in it can stop the location pipeline. Points carry `synced = 0` until
|
||||
the server acknowledges them, so a dead endpoint costs only a growing backlog.
|
||||
|
||||
`trip_id` and `segment_id` are stamped **per point, not per batch**, because
|
||||
`getUnsyncedPoints()` draws by id and a batch can straddle a segment or — after a
|
||||
discard-and-restart — a trip boundary.
|
||||
188
rippr-src/docs/DEVELOPMENT.md
Normal file
@@ -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
@@ -0,0 +1,139 @@
|
||||
# Testing
|
||||
|
||||
What is covered, what is not, and — most importantly — **what the emulator structurally
|
||||
cannot verify**. Read the last section before trusting any green build.
|
||||
|
||||
```
|
||||
84 unit tests (JVM, no device) 46 instrumented tests (device required)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unit tests — `app/src/test/`
|
||||
|
||||
Pure logic is kept free of Android imports specifically so it can be tested here. This is
|
||||
the pattern `Telemetry.kt` established in v1 and every later module follows.
|
||||
|
||||
| Suite | Covers |
|
||||
|---|---|
|
||||
| `TelemetryTest` | Speed conversion, noise floor, accuracy gate, duration formatting, upload payload shape |
|
||||
| `TelemetryUploaderTest` | Upload success, failure, retry, disabled endpoint — against `MockWebServer` and an in-memory fake DAO |
|
||||
| `GeoTest` | Haversine against known references, Douglas–Peucker, bounds, degenerate cases |
|
||||
| `RideStatisticsTest` | Distance, moving time, elevation hysteresis, histogram, profile |
|
||||
| `AccumulatorTest` | Live accumulation, the cross-batch anchor, restore, segment boundaries |
|
||||
| `RideExportTest` | GPX/GeoJSON structure parsed with real parsers, escaping, exact point counts |
|
||||
|
||||
### The tests that matter most
|
||||
|
||||
A few exist because something specific went wrong and must not return:
|
||||
|
||||
- **`stationary noisy altitude yields near-zero elevation gain`** — a naive implementation
|
||||
reported **1498 m of climbing over a parked bike**. The bound is a regression guard, not
|
||||
an accuracy claim.
|
||||
- **`distance across many small batches matches one big batch`** — guards the cross-batch
|
||||
anchor, whose absence under-reports distance by a few percent, invisibly.
|
||||
- **`every raw point is exported with no decimation`** — catches render-side simplification
|
||||
leaking into exports.
|
||||
- **`geojson coordinates are longitude first`** — the classic silent error; wrong order
|
||||
plots in the wrong hemisphere.
|
||||
- **`a hostile trip name still produces valid xml`** — uses `Sam & Dave's <ride> "fast"`.
|
||||
|
||||
---
|
||||
|
||||
## Instrumented tests — `app/src/androidTest/`
|
||||
|
||||
| Suite | Covers |
|
||||
|---|---|
|
||||
| `SchemaTest` | CASCADE deletes, active-trip flow, ordering, COALESCE guards, upload backlog |
|
||||
| `TripRepositoryTest` | All lifecycle transitions, idempotency, process-death simulation |
|
||||
| `TrackingServiceLifecycleTest` | The real service driven through all five actions |
|
||||
| `MergeTest` | Re-parenting, recomputed aggregates, rejections, atomicity |
|
||||
|
||||
### These tests must use in-memory databases
|
||||
|
||||
`TrackingServiceLifecycleTest` originally ran against the **production** `AppDatabase`
|
||||
singleton and called `deleteAll()` in setUp/tearDown. Harmless on a throwaway emulator, but
|
||||
it would have destroyed every recorded ride had the suite ever been run against a personal
|
||||
phone. It now substitutes an in-memory database through `AppDatabase.overrideForTest()` and
|
||||
`TripRepository.overrideForTest()`, restoring the singletons afterwards.
|
||||
|
||||
**Any new test that touches the service must do the same.**
|
||||
|
||||
### Timeouts
|
||||
|
||||
Service tests poll with a 25 s timeout. This was raised from 10 s when the suite grew —
|
||||
but note the flakiness that prompted it turned out to have a **real bug** behind it
|
||||
(`stopSelf()` racing a queued START). Raise timeouts only after ruling out a defect.
|
||||
|
||||
---
|
||||
|
||||
## What the emulator cannot verify
|
||||
|
||||
This is the most important section in this document.
|
||||
|
||||
### It reports zero velocity, always
|
||||
|
||||
`adb emu geo fix` teleports the device. `Location.speed` is therefore **always 0**, and
|
||||
every recorded `speedKmh` is `0.0`.
|
||||
|
||||
Unverifiable on the emulator:
|
||||
- Max speed, average moving speed
|
||||
- Moving time (everything falls below the 1.5 km/h noise floor, so it stays 00:00:00)
|
||||
- **Speed colouring on the map** — the path renders uniformly in the low-speed colour
|
||||
|
||||
A green suite says nothing about any of these.
|
||||
|
||||
### It hides UI consequences of that limitation
|
||||
|
||||
This is subtler and it bit us. v2.0 shipped with **max speed** as the headline figure on the
|
||||
recording screen. On the emulator every value is zero, so a number that never moves looks
|
||||
perfectly normal. On a real ride it read as a frozen, broken screen.
|
||||
|
||||
**When a value cannot change under test, question whether the UI around it can be judged
|
||||
at all.**
|
||||
|
||||
### Synthetic fixtures hide scale-dependent bugs
|
||||
|
||||
The emulator ride fixtures were ~900 m. A real 50 m ride zoomed the map past OSM's maximum
|
||||
tile zoom and rendered an empty grid. The bug was entirely deterministic and entirely
|
||||
invisible to a fixture of the wrong size.
|
||||
|
||||
**Test short and long rides.**
|
||||
|
||||
### Correlated noise is not uniform noise
|
||||
|
||||
The elevation fixture uses uniform ±8 m random noise. Real GPS altitude error is
|
||||
*correlated* — it wanders rather than jitters. The ~30 m residual drift measured against
|
||||
synthetic noise may behave quite differently in practice.
|
||||
|
||||
---
|
||||
|
||||
## Not covered at all
|
||||
|
||||
1. **No Compose UI tests** for any of the six screens. Verification was manual screenshots.
|
||||
This is the largest single gap.
|
||||
2. **No battery measurement** over a multi-hour ride.
|
||||
3. **No map memory-leak measurement** across repeated navigation, despite the osmdroid
|
||||
lifecycle being a known hazard.
|
||||
4. **No rotation/state-retention testing.**
|
||||
5. **No test that a GPX imports cleanly into Strava or Garmin** — structure is validated
|
||||
against an XML parser, but schema validity does not guarantee a consumer accepts it.
|
||||
|
||||
---
|
||||
|
||||
## The real-ride checklist
|
||||
|
||||
The only way to validate the emulator's blind spots. Run it after any change to recording,
|
||||
statistics, or the map.
|
||||
|
||||
1. Start, pocket the phone, ride, stop — matching actual usage
|
||||
2. Max speed plausible against the speedometer
|
||||
3. Distance plausible against the odometer
|
||||
4. Moving time excludes stops
|
||||
5. **Elevation gain near zero on flat ground** — the most likely silent bug
|
||||
6. Path renders with no straight line across a pause
|
||||
7. Speed colouring visibly varies along the path
|
||||
8. **A short ride (under 100 m) still shows streets** — the v2.0 zoom bug
|
||||
9. GPX opens correctly in Google Earth or Strava
|
||||
10. Battery drain over a multi-hour ride is acceptable
|
||||
11. Pause/resume survives a screen-off stretch
|
||||
68
rippr-src/docs/v1/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# v1 — the original recorder
|
||||
|
||||
v1 was specified as **MotoTrack**, a "bare-bones, highly resilient" recorder to capture GPS
|
||||
telemetry during motorcycle group rides. Renamed to Rippr partway through.
|
||||
|
||||
The original brief is preserved verbatim in [original-spec.md](original-spec.md).
|
||||
|
||||
**Design priority, quoted from the brief:** *"Unbreakable execution over UI beauty."*
|
||||
That framing drove every architectural choice and still holds.
|
||||
|
||||
## What shipped
|
||||
|
||||
A single-screen app with an un-killable foreground service writing GPS fixes to Room, plus
|
||||
a REST uploader. Validated on a real ride — including max speed, which the emulator cannot
|
||||
produce.
|
||||
|
||||
## Deviations from the spec, and why
|
||||
|
||||
The spec was written as complete, paste-ready code. Most of it was sound; these parts were
|
||||
not, and were changed deliberately.
|
||||
|
||||
| Spec said | What shipped | Why |
|
||||
|---|---|---|
|
||||
| `super.onCreate()` in `MainActivity` | `super.onCreate(savedInstanceState)` | Would not compile |
|
||||
| `kapt` for Room | **KSP** | kapt is 2–3× slower and unreliable on modern Kotlin/JDK |
|
||||
| Hardcoded `material3:1.2.1` beside a Compose BOM | Version catalog + BOM | Version conflict |
|
||||
| `insertPoint()` per fix from the callback | Unbounded `Channel` + batched writer | A coroutine per fix gives no back-pressure guarantee; disk latency could block GPS |
|
||||
| Activity-local `isRecording` | Process-wide state (later, in v2, the database) | Lied after process death |
|
||||
| 1 Hz polling of two suspend DAO queries | Room `Flow` | Push, not poll |
|
||||
| No stop action on the notification | Stop action + tap-to-open | |
|
||||
| Nothing countering Doze / OEM killers | Battery-optimisation exemption prompt | A multi-hour ride must survive |
|
||||
| No `Theme.MotoTrack`, no launcher icon | Generated both | Referenced by the manifest but never defined |
|
||||
| "Streams over HTTP/REST" in the goal, no task for it | Full uploader: `synced` column, batched POST, retry, offline-safe | Stated as a goal, so it was built |
|
||||
|
||||
## Verification
|
||||
|
||||
The emulator harness built here was reused throughout v2:
|
||||
|
||||
- Merged manifest checked inside the APK — `foregroundServiceType=0x8` (location) confirmed
|
||||
- 25 synthetic GPS fixes fed; final stored coordinate matched the fed value **exactly**
|
||||
- Foreground service confirmed via `dumpsys` (`isForeground=true`, ongoing notification)
|
||||
- Stop path verified: service gone, notification removed, final flush landed, 81 contiguous
|
||||
point ids
|
||||
|
||||
**Speed was never verified in v1's automated testing** — the emulator reports zero
|
||||
velocity. It was confirmed only on Dylan's first real ride.
|
||||
|
||||
## What v1 lacked
|
||||
|
||||
Feedback after that ride, which became the v2 brief:
|
||||
|
||||
> "app is simple and clean. I like that but it's missing stuff. There is no reset or pause
|
||||
> button, there is no concept of a trip, it captures speed data but not path data."
|
||||
|
||||
Three of those four were the same missing concept — no `Trip` boundary. The fourth was a
|
||||
misconception worth recording: **path data was already being captured.** Every point stored
|
||||
latitude, longitude, altitude and bearing at 1–2 Hz from day one. What was missing was a
|
||||
map to draw it on.
|
||||
|
||||
## Icon work
|
||||
|
||||
The launcher icon was designed in this phase. Five motorcycle-themed concepts were
|
||||
generated locally as SVG, using Google Material Symbols (Apache-2.0) as reference geometry;
|
||||
Dylan picked **Route** — a switchback trace inside a ring.
|
||||
|
||||
Lesson from that round: the first attempt hand-authored bezier paths without ever rendering
|
||||
them, and they were poor. Building a rasterise-and-look loop (`rsvg-convert`) changed the
|
||||
output quality completely. Generators live in `design/`.
|
||||
91
rippr-src/docs/v1/original-spec.md
Normal file
@@ -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).
|
||||
76
rippr-src/docs/v2/01-baseline.md
Normal 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.
|
||||
128
rippr-src/docs/v2/02-schema.md
Normal 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).
|
||||
112
rippr-src/docs/v2/03-repository.md
Normal 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).
|
||||
92
rippr-src/docs/v2/04-geo.md
Normal 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).
|
||||
126
rippr-src/docs/v2/05-stats.md
Normal 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).
|
||||
146
rippr-src/docs/v2/06-service-lifecycle.md
Normal 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).
|
||||
113
rippr-src/docs/v2/07-aggregates.md
Normal 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).
|
||||
98
rippr-src/docs/v2/08-nav-shell.md
Normal 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).
|
||||
114
rippr-src/docs/v2/09-record-screen.md
Normal 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.
|
||||
89
rippr-src/docs/v2/10-trips-list.md
Normal 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).
|
||||
86
rippr-src/docs/v2/11-trip-detail.md
Normal 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).
|
||||
109
rippr-src/docs/v2/12-trip-management.md
Normal 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.
|
||||
119
rippr-src/docs/v2/13-map-integration.md
Normal 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.
|
||||
108
rippr-src/docs/v2/14-path-rendering.md
Normal 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.
|
||||
118
rippr-src/docs/v2/15-export-format.md
Normal 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).
|
||||