Add Rippr source snapshot and full-history bundle
Two copies for two jobs. rippr-src/ is a browsable git archive export of the tracked tree at 2f76983 - no build outputs, no local.properties, no nested .git - which is convenient to read in gitea but carries no history and will drift. rippr-full-history.bundle is the real backup: all 18 commits, verified as "records a complete history" and test-cloned before committing. This matters because ~/dojo/rippr has no git remote and otherwise exists only on one machine. rippr-src/SNAPSHOT.md explains the difference and how to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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/
|
||||||
44
rippr-src/SNAPSHOT.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Rippr — source snapshot
|
||||||
|
|
||||||
|
Point-in-time export of the Rippr working tree, exported with `git archive HEAD`
|
||||||
|
(tracked files only — no build outputs, no `local.properties`, no nested `.git`).
|
||||||
|
|
||||||
|
**Commit:** `2f76983` — "Fix live readout and blank map, both found on the first real ride"
|
||||||
|
|
||||||
|
## Two copies live here, for different jobs
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `rippr-src/` | Browsable source. No history — a snapshot that will drift. |
|
||||||
|
| `rippr-full-history.bundle` | Full git history (18 commits). The real backup. |
|
||||||
|
|
||||||
|
The canonical repo is `~/dojo/rippr`, which **has no git remote** — it exists only on that
|
||||||
|
machine. The bundle is therefore the only off-machine copy of the history.
|
||||||
|
|
||||||
|
## Restoring from the bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone rippr-full-history.bundle rippr
|
||||||
|
cd rippr
|
||||||
|
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified: the bundle reports "records a complete history" and clones cleanly to all 18
|
||||||
|
commits.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Needs JDK 17–21 (AGP does not support 25) and the Android SDK with platform 35 and
|
||||||
|
build-tools 35. See `docs/v2/01-baseline.md`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug testDebugUnitTest lintDebug # 84 unit tests
|
||||||
|
./gradlew connectedDebugAndroidTest # 46, needs a device
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where to start reading
|
||||||
|
|
||||||
|
- `docs/v2/README.md` — task index and architecture decisions
|
||||||
|
- `docs/v2/PROGRESS.md` — per-task outcomes, bugs found, and known gaps
|
||||||
|
- `app/src/main/java/com/rippr/TrackingService.kt` — the recording pipeline
|
||||||
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()
|
||||||
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).
|
||||||
109
rippr-src/docs/v2/16-export-ui.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# T16 — Export UI
|
||||||
|
|
||||||
|
**Phase** 6 · **Depends on** T15 · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Get an exported ride off the phone — share sheet or save to a chosen location. Done when
|
||||||
|
a GPX can be shared to another app or written to storage and opens correctly there.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
T15 produces strings; this task moves them somewhere useful. Two paths cover the real
|
||||||
|
uses:
|
||||||
|
|
||||||
|
- **Share** — send to Strava, Drive, email, Slack. The common case.
|
||||||
|
- **Save** — write to Downloads or a chosen folder via SAF. For getting it onto a
|
||||||
|
computer.
|
||||||
|
|
||||||
|
`FileProvider` is required — since Android 7 a raw `file://` URI in an Intent throws
|
||||||
|
`FileUriExposedException`. The app currently has no `<provider>` declared and no
|
||||||
|
`file_paths.xml`; both are new.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### FileProvider
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
Files are written to `cacheDir/exports/`, exposed as a `cache-path`. Cache is right:
|
||||||
|
these are transient handoffs, and the system can reclaim them. Clear stale exports on
|
||||||
|
app start so the directory does not grow unbounded.
|
||||||
|
|
||||||
|
`FLAG_GRANT_READ_URI_PERMISSION` on the share Intent, or the receiving app gets a
|
||||||
|
`SecurityException`.
|
||||||
|
|
||||||
|
### Save via SAF
|
||||||
|
|
||||||
|
`ActivityResultContracts.CreateDocument("application/gpx+xml")` returns a user-chosen
|
||||||
|
URI; write through `contentResolver.openOutputStream`. No storage permission needed,
|
||||||
|
which is the point of SAF.
|
||||||
|
|
||||||
|
### Filename
|
||||||
|
|
||||||
|
`rippr-2026-08-10-1432.gpx` — sortable, unambiguous, no spaces. Derived from
|
||||||
|
`startedAt` in the device timezone (the filename is for a human, unlike the timestamps
|
||||||
|
*inside* the file, which are UTC).
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Export action on trip detail, offering format (GPX / GeoJSON) then destination
|
||||||
|
(Share / Save). Two small choices rather than four buttons.
|
||||||
|
|
||||||
|
Generation happens **off the main thread** with a progress indicator — a large ride is a
|
||||||
|
few MB of string building.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
1. Add the `<provider>` to `AndroidManifest.xml` and create `res/xml/file_paths.xml`.
|
||||||
|
2. `export/ExportManager.kt`: generate on IO, write to `cacheDir/exports/`, return a
|
||||||
|
`FileProvider` URI.
|
||||||
|
3. Share via `Intent.ACTION_SEND` with the correct MIME type and read permission flag.
|
||||||
|
4. SAF save via `CreateDocument`.
|
||||||
|
5. Export UI on trip detail with format and destination choice.
|
||||||
|
6. Clear `cacheDir/exports/` on app start.
|
||||||
|
7. MIME types: `application/gpx+xml`, `application/geo+json`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Share opens the system sheet with a valid attachment
|
||||||
|
- [x] The shared file opens correctly in a receiving app
|
||||||
|
- [ ] SAF save writes to the chosen location — **not implemented**; share only
|
||||||
|
- [x] No `FileUriExposedException`
|
||||||
|
- [x] No storage permission added to the manifest
|
||||||
|
- [x] Generation is off the main thread with a progress indicator
|
||||||
|
- [x] Stale exports cleared on start
|
||||||
|
|
||||||
|
> **Scope reduced.** Only the share sheet shipped. Saving via SAF was dropped: the
|
||||||
|
> share sheet already reaches Drive, Files, email and Strava, which covers getting a
|
||||||
|
> ride off the phone. Add SAF if a real need appears.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Instrumented: `ExportManager` produces a readable `FileProvider` URI whose content
|
||||||
|
matches `GpxWriter.write` exactly; cache clearing works.
|
||||||
|
|
||||||
|
Manual on emulator: share to a file manager, pull the file with
|
||||||
|
`adb pull`, and diff it against locally-generated output.
|
||||||
|
|
||||||
|
## Risks / gotchas
|
||||||
|
|
||||||
|
- **`FileUriExposedException`** is the failure mode if `FileProvider` is skipped —
|
||||||
|
it throws at share time, not build time.
|
||||||
|
- **Missing `FLAG_GRANT_READ_URI_PERMISSION`** produces a `SecurityException` in the
|
||||||
|
*receiving* app, which reads as that app being broken.
|
||||||
|
- **Authority must be unique** — `${applicationId}.fileprovider` guarantees it.
|
||||||
|
- **Do not write to external storage directly.** SAF exists to avoid that permission.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Cloud upload, auto-export on trip completion, import.
|
||||||
91
rippr-src/docs/v2/17-uploader.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# T17 — Uploader trip-awareness
|
||||||
|
|
||||||
|
**Phase** 7 · **Depends on** T02 · **Status** Done
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Include trip and segment identity in the uploaded payload so a future server can
|
||||||
|
reconstruct rides rather than receiving an undifferentiated point stream. Done when the
|
||||||
|
payload carries `trip_id` and `segment_id` and the existing uploader tests still pass.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`TelemetryUploader` works and is well covered — five unit tests using `MockWebServer` and
|
||||||
|
an in-memory fake DAO, all running on the JVM with no device. Its design principle is
|
||||||
|
stated in its own doc comment and must be preserved:
|
||||||
|
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
Two facts shape this task:
|
||||||
|
|
||||||
|
1. **No server consumes this yet.** The endpoint defaults to blank, which disables upload
|
||||||
|
entirely. So the payload shape can change freely — there is no compatibility burden.
|
||||||
|
2. **There is still no UI for the endpoint.** It is reachable only via
|
||||||
|
`Config.setUploadEndpoint()`. That remains true after this task; adding settings UI is
|
||||||
|
out of scope and belongs with the v3+ group-ride work that would give it a purpose.
|
||||||
|
|
||||||
|
`Telemetry.encodeBatch(deviceId, points)` builds the JSON and is directly unit-tested by
|
||||||
|
`TelemetryTest.encodes a batch as the documented payload shape`. That test will need
|
||||||
|
updating in step with the format.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Current payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "device_id": "…", "points": [ { "id":…, "ts":…, "lat":…, "lon":…,
|
||||||
|
"speed_kmh":…, "alt_m":…, "acc_m":…, "bearing":… } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `trip_id` and `segment_id` per point:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "device_id": "…",
|
||||||
|
"points": [ { "id":…, "trip_id":…, "segment_id":…, "ts":…, … } ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Per point, not per batch.** A batch is drawn by `getUnsyncedPoints(BATCH_SIZE)` ordered
|
||||||
|
by id, which can straddle a segment boundary — and, after a discard-and-restart, even a
|
||||||
|
trip boundary. Hoisting the ids to batch level would silently mislabel points. Keeping
|
||||||
|
them per point costs a few bytes and cannot be wrong.
|
||||||
|
|
||||||
|
Segment boundaries are what let a server reconstruct pauses, exactly as GPX `<trkseg>`
|
||||||
|
does in T15.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
1. Extend `Telemetry.encodeBatch` to emit `trip_id` and `segment_id`.
|
||||||
|
2. Update the `TelemetryTest` payload-shape test.
|
||||||
|
3. Confirm `TelemetryUploader` needs no structural change — it passes points through, so
|
||||||
|
the fake DAO in `TelemetryUploaderTest` just needs the new fields.
|
||||||
|
4. Sanity-check batching still works across a segment boundary.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Payload includes `trip_id` and `segment_id` per point
|
||||||
|
- [x] A batch spanning two segments labels each point correctly
|
||||||
|
- [x] All five existing `TelemetryUploaderTest` cases still pass
|
||||||
|
- [x] `TelemetryTest` payload assertions updated, not deleted
|
||||||
|
- [x] Blank endpoint still disables upload with zero requests
|
||||||
|
- [x] Upload failure still leaves points unsynced for retry
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
JVM unit tests, extending the existing suite:
|
||||||
|
- Payload shape assertion including the new fields
|
||||||
|
- A batch containing points from two segments — assert per-point labelling
|
||||||
|
- Existing disabled/failure/retry cases unchanged
|
||||||
|
|
||||||
|
## Risks / gotchas
|
||||||
|
|
||||||
|
- **Do not restructure the uploader.** It is correct, tested, and deliberately isolated
|
||||||
|
from the recording path. This is an additive payload change.
|
||||||
|
- **The endpoint has no UI** and stays that way here. Worth stating in the release notes
|
||||||
|
so the feature is not mistaken for broken.
|
||||||
|
- **Do not add trip-level upload state.** `synced` is per point and the backlog logic
|
||||||
|
depends on that granularity.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Settings UI for the endpoint; server implementation; group-ride streaming (v3+).
|
||||||
118
rippr-src/docs/v2/18-verification.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# T18 — End-to-end verification + remove destructive migration
|
||||||
|
|
||||||
|
**Phase** 7 · **Depends on** all · **Status** Done (automated); real-ride checklist outstanding
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Prove v2 works end to end, and close the one deliberate time bomb left in the schema.
|
||||||
|
Done when the full suite is green, a synthetic paused ride verifies correctly on the
|
||||||
|
emulator, the real-ride checklist passes, and `fallbackToDestructiveMigration()` is gone.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
v1's verification harness is proven and should be reused rather than reinvented — it
|
||||||
|
caught real problems and produced the coordinate-exactness check that confirmed the
|
||||||
|
pipeline end to end.
|
||||||
|
|
||||||
|
Two lessons from that round, both of which cost time:
|
||||||
|
|
||||||
|
- **Wait for the UI, do not sleep.** A cold start took 8.7 s; a 4 s sleep produced a
|
||||||
|
silently-missed tap and a false "the service didn't start" conclusion. Poll
|
||||||
|
`uiautomator dump` for the expected text.
|
||||||
|
- **The emulator cannot produce velocity.** `adb emu geo fix` teleports, so
|
||||||
|
`Location.speed` is always 0 and every recorded `speedKmh` is 0.00. Anything
|
||||||
|
speed-derived — max speed, moving time, average moving speed, speed colouring — is
|
||||||
|
**unverifiable on the emulator** and must be checked on a real ride.
|
||||||
|
|
||||||
|
## The destructive migration — remove it
|
||||||
|
|
||||||
|
`AppDatabase` carries `fallbackToDestructiveMigration()` from T02. It was correct: v1 data
|
||||||
|
was a test and Dylan explicitly authorised nuking it.
|
||||||
|
|
||||||
|
It is **not** correct once real rides are being kept. Left in place, the next schema
|
||||||
|
change silently deletes every ride with no warning and no recovery. This is the single
|
||||||
|
most dangerous line in the v2 codebase.
|
||||||
|
|
||||||
|
Removal:
|
||||||
|
1. Delete the `fallbackToDestructiveMigration()` call.
|
||||||
|
2. Confirm `app/schemas/com.rippr.data.AppDatabase/2.json` is committed — it is the baseline
|
||||||
|
every future migration is written against.
|
||||||
|
3. Add a short note to `docs/v2/README.md` recording that migrations are now mandatory.
|
||||||
|
4. Verify a fresh install still creates the database correctly.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Automated
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug testDebugUnitTest lintDebug connectedDebugAndroidTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected coverage by this point: geo maths, statistics with the noisy-altitude regression
|
||||||
|
case, GPX/GeoJSON structure and escaping, uploader behaviour, DAO relations and CASCADE,
|
||||||
|
trip lifecycle transitions, merge correctness.
|
||||||
|
|
||||||
|
### Emulator end-to-end
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emulator -avd Medium_Phone_API_35 -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect
|
||||||
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION POST_NOTIFICATIONS; do
|
||||||
|
adb shell pm grant com.rippr android.permission.$p
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Drive the UI (the service is `exported=false`, so `am start-foreground-service` is
|
||||||
|
correctly refused). Feed a ride, pause partway, feed more, stop. Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb exec-out run-as com.rippr cat databases/rippr_db > /tmp/check.db # plus -wal
|
||||||
|
```
|
||||||
|
|
||||||
|
Assert: one Trip; two Segments; contiguous point ids; distance non-zero; no point in a
|
||||||
|
closed segment; the rendered path shows a visible gap at the pause.
|
||||||
|
|
||||||
|
### Real-ride checklist
|
||||||
|
|
||||||
|
The only way to validate anything speed-derived:
|
||||||
|
|
||||||
|
1. Start, pocket the phone, ride, stop — matching actual usage
|
||||||
|
2. Max speed plausible (this worked in v1 and must not regress)
|
||||||
|
3. Distance plausible against the bike's odometer
|
||||||
|
4. Moving time excludes stops
|
||||||
|
5. Elevation gain plausible — **not** thousands of metres on flat ground, the classic
|
||||||
|
hysteresis failure
|
||||||
|
6. Path renders with no straight-line artefact across pauses
|
||||||
|
7. Speed colouring visibly varies
|
||||||
|
8. GPX export opens correctly in Google Earth or Strava
|
||||||
|
9. Battery drain over a multi-hour ride acceptable
|
||||||
|
10. Pause/resume survives a screen-off stretch
|
||||||
|
|
||||||
|
## Outstanding work inherited from earlier tasks
|
||||||
|
|
||||||
|
- **Compose UI tests for all four screens (T08–T11).** None exist. Verification there was
|
||||||
|
manual screenshots plus the unit/instrumented suites. Needed: navigation
|
||||||
|
record→trips→detail→back, rotation state retention, empty state, `NotFound`, and chart
|
||||||
|
degradation below two points.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `fallbackToDestructiveMigration()` removed
|
||||||
|
- [x] `2.json` committed as the migration baseline
|
||||||
|
- [x] Full gradle verification green
|
||||||
|
- [x] Emulator paused-ride assertions pass
|
||||||
|
- [ ] Real-ride checklist complete
|
||||||
|
- [ ] Compose UI tests written for T08–T11 screens
|
||||||
|
- [ ] Debug APK copied to `~/dojo/samplez` and committed (matching the v1 handoff)
|
||||||
|
|
||||||
|
## Risks / gotchas
|
||||||
|
|
||||||
|
- **Forgetting the migration removal** is the failure this task exists to prevent. Do it
|
||||||
|
first, not last.
|
||||||
|
- **Do not claim speed-derived features verified from emulator runs.** State plainly what
|
||||||
|
the emulator cannot test.
|
||||||
|
- **Elevation gain is the most likely silent bug.** Flat ground must read near zero.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
v3+ work: live map, group ride view, settings UI for the upload endpoint.
|
||||||
571
rippr-src/docs/v2/PROGRESS.md
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
# v2 Progress Log
|
||||||
|
|
||||||
|
One entry per completed task: what shipped, what went wrong, and what the next tasks
|
||||||
|
should know. Newest at the bottom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T01 — Repo + dependency baseline · `ac8a59c`
|
||||||
|
|
||||||
|
**Shipped.** `git init` + v1 baseline commit (67 files). Added `navigation-compose 2.8.5`,
|
||||||
|
`lifecycle-viewmodel-compose 2.8.7`, `osmdroid-android 6.1.20` to the version catalog.
|
||||||
|
|
||||||
|
**Hiccups.** None. Adding osmdroid early paid off: I checked the merged manifest and it
|
||||||
|
added **no permissions**, closing the one flagged risk before any map code exists.
|
||||||
|
|
||||||
|
**For later tasks.** `lifecycle-viewmodel-compose` is also pulled transitively by
|
||||||
|
navigation at 2.6.2 and correctly resolves up to 2.8.7. `.gitignore` now excludes
|
||||||
|
`.kotlin/`, `__pycache__/`, and `design/preview/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T02 — Schema v2 · `95143be`
|
||||||
|
|
||||||
|
**Shipped.** `Trip → Segment → TrackPoint` with CASCADE FKs; `TrackPoint.kt` split into
|
||||||
|
eight files under `data/`. Database at version 2 with destructive fallback. 12 unit +
|
||||||
|
12 instrumented tests green. Emulator end-to-end: trip and segment opened on Start,
|
||||||
|
both closed on Stop, 29 contiguous points, zero orphans, final coordinate exact.
|
||||||
|
|
||||||
|
**Hiccups.**
|
||||||
|
|
||||||
|
1. **Schema export path moved.** `2.json` now lands at
|
||||||
|
`app/schemas/com.rippr.data.AppDatabase/2.json` because the `@Database` class changed
|
||||||
|
package. I first concluded it had not generated at all. The orphaned
|
||||||
|
`com.rippr.AppDatabase/1.json` directory was removed (git history retains it).
|
||||||
|
2. **A doc claim was wrong.** T02 originally said Room does not enable
|
||||||
|
`PRAGMA foreign_keys` — it does, for its generated code. Corrected.
|
||||||
|
3. **A code comment was wrong.** `SchemaTest` had a comment attributing CASCADE working
|
||||||
|
to `JournalMode.TRUNCATE`, which is unrelated. Removed; the cascade test proves
|
||||||
|
enforcement empirically.
|
||||||
|
|
||||||
|
**Temporary scaffolding introduced** (keeps every commit green and the app installable,
|
||||||
|
but must be removed by the named task):
|
||||||
|
|
||||||
|
| Location | Scaffolding | Removed by |
|
||||||
|
|---|---|---|
|
||||||
|
| `TrackingService.openTripAndSegment()` | Opens trip+segment inline via DAOs | T06 |
|
||||||
|
| `TrackingService.currentTripId/currentSegmentId` | `@Volatile` ids read by the location callback | T06 |
|
||||||
|
| `TrackingService.stopRecording()` | Closes trip on a survivor scope | T06 |
|
||||||
|
| `MainActivity` `flatMapLatest` stats | Per-trip stats wiring | T09 |
|
||||||
|
|
||||||
|
**For later tasks.**
|
||||||
|
- `TripDao`, `SegmentDao`, `TrackPointDao` already expose everything T03/T06/T10–T15
|
||||||
|
need, including `reparent()` on both segments and points for merge (T12).
|
||||||
|
- `RideStats` survives as a *live-recording* projection only. It deliberately excludes
|
||||||
|
distance and elevation, which need Kotlin-side accumulation.
|
||||||
|
- The location callback drops fixes when `tripId == 0`, so a point can never violate the
|
||||||
|
FK. T06 must preserve that guard when it restructures the start path.
|
||||||
|
- `AppDatabase.overrideForTest()` exists as a test seam.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T03 — Trip repository + reactive state
|
||||||
|
|
||||||
|
**Shipped.** `TripRepository` owns all five lifecycle transitions, each wrapped in
|
||||||
|
`db.withTransaction`. `RecordingState` deleted; recording state now derives from
|
||||||
|
`trips.endedAt IS NULL`. Upload-error state relocated to a new `UploadStatus` object.
|
||||||
|
Service and `MainActivity` rewired onto the repository. 15 new instrumented tests
|
||||||
|
(27 total), lint clean.
|
||||||
|
|
||||||
|
**Verified on device.** Started a ride, `am force-stop`ped the app mid-ride, relaunched:
|
||||||
|
the button read **STOP RECORDING**. v1 would have shown START and lied. This was the
|
||||||
|
headline bug for this task and it is fixed.
|
||||||
|
|
||||||
|
**Hiccups.**
|
||||||
|
|
||||||
|
1. **`onToggleClicked` had to become asynchronous.** With no in-memory flag there is no
|
||||||
|
synchronous way to ask "are we recording", so it now launches a coroutine to query
|
||||||
|
`activeTrip()`. That works but is a smell — a button should not suspend to decide what
|
||||||
|
it does. T09's `RecordViewModel` should hold the state and make this synchronous again.
|
||||||
|
2. **Force-stop is not the same as an OS kill.** Android refuses to honour START_STICKY
|
||||||
|
after a user-initiated force-stop, so recording does not auto-resume — only the UI
|
||||||
|
state was correct in the test above. The T06 restart path targets OS-initiated kills,
|
||||||
|
which is the case that actually matters mid-ride. Noted in the T06 doc.
|
||||||
|
3. **Points buffered in the channel are lost to a force-stop.** The survivor scope in
|
||||||
|
`onDestroy` cannot run when the process is killed outright. Expected, and the ≤2 s
|
||||||
|
exposure window is the deliberate trade for not fsyncing at 2 Hz.
|
||||||
|
|
||||||
|
**Design decisions worth carrying forward.**
|
||||||
|
|
||||||
|
- `startTrip()` **adopts** an already-active trip rather than rejecting or duplicating.
|
||||||
|
After a process kill the row still exists and the restarted service must continue it.
|
||||||
|
- `pauseTrip()` closes the segment but leaves `endedAt` null — a paused ride is still
|
||||||
|
active. Only `completeTrip()` ends a trip.
|
||||||
|
- `renameTrip()` collapses blank input to null so the stored value and the UI's
|
||||||
|
"derive a label from the date" branch cannot diverge.
|
||||||
|
- Repository returns a `TripHandle(tripId, segmentId)` — the ids the recorder stamps onto
|
||||||
|
each fix.
|
||||||
|
|
||||||
|
**For later tasks.**
|
||||||
|
- `TripRepository.get(context)` is the singleton accessor; `overrideForTest()` is the seam.
|
||||||
|
- T06 inherits a service that already calls `startTrip`/`completeTrip`; what remains is the
|
||||||
|
five actions, drain ordering, pause/resume, and the restart path.
|
||||||
|
- T09 should eliminate the suspending toggle described above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T04 — Geo utilities
|
||||||
|
|
||||||
|
**Shipped.** `geo/Geo.kt`: Haversine distance, iterative Douglas–Peucker, clamped
|
||||||
|
perpendicular distance, bounds with a `isDegenerate` flag, and `pathLengthMeters`. Plus
|
||||||
|
`LatLon`, `Bounds`, and a `TrackPoint.toLatLon()` extension. Zero Android imports, so all
|
||||||
|
23 new tests run on the JVM. 35 unit tests total, lint clean.
|
||||||
|
|
||||||
|
**Hiccups.**
|
||||||
|
|
||||||
|
1. **A test expectation was wrong, not the code.** I asserted Calgary→Edmonton at 279 km;
|
||||||
|
the implementation returned 280.9 km and I checked by hand before touching anything:
|
||||||
|
2.5014° of latitude ≈ 278.1 km, 0.5781° of longitude at ~52.3° ≈ 39.3 km, giving
|
||||||
|
√(278.1² + 39.3²) ≈ 280.9 km. The code was right. The test now carries that derivation
|
||||||
|
as a comment so the number is not mistaken for a magic constant later. (The ~300 km
|
||||||
|
figure people quote is *road* distance, which is a different measurement.)
|
||||||
|
|
||||||
|
**Design decisions worth carrying forward.**
|
||||||
|
|
||||||
|
- **Haversine, not Vincenty** — spherical assumption costs ~0.5%, far below GPS noise.
|
||||||
|
- **Epsilon in metres, not degrees** — a degree of longitude is ~111 km at the equator and
|
||||||
|
~0 at the poles, so a degree tolerance would behave differently depending where you ride.
|
||||||
|
- **Iterative Douglas–Peucker with an explicit stack** — verified against a 21,600-point
|
||||||
|
ride (three hours at 2 Hz) with no stack overflow, comfortably under a second.
|
||||||
|
- **Perpendicular distance clamps to the segment**, so a point past the end measures to the
|
||||||
|
endpoint rather than to the infinite line.
|
||||||
|
- **`bounds()` returns null on empty** — callers must handle "no path" instead of silently
|
||||||
|
centring on Null Island. `isDegenerate` covers the parked-bike case that would otherwise
|
||||||
|
break map auto-fit.
|
||||||
|
|
||||||
|
**For later tasks.**
|
||||||
|
- T05: use `pathLengthMeters` **per segment**; it has no notion of pauses.
|
||||||
|
- T14: `simplify` is render-only. `Bounds.isDegenerate` must be checked before auto-fit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T05 — Ride statistics
|
||||||
|
|
||||||
|
**Shipped.** `stats/RideStatistics.kt`: distance, moving vs elapsed time, elevation
|
||||||
|
gain/loss, max and average speed, time-weighted speed histogram, distance-sampled
|
||||||
|
elevation profile. Plus a streaming `ElevationAccumulator` the recorder will share.
|
||||||
|
21 new tests, 56 unit tests total, lint clean.
|
||||||
|
|
||||||
|
**Hiccups — three, and the first is the important one.**
|
||||||
|
|
||||||
|
1. **My first "hysteresis" was not hysteresis, and the test caught it.** Summing every
|
||||||
|
delta that exceeded a 3 m threshold reported **1498 m of climbing over a parked bike**,
|
||||||
|
because ±8 m noise crosses a 3 m threshold constantly. The fix needed two mechanisms:
|
||||||
|
a 15-sample moving average *and* reversal-based hysteresis (bank a climb only when
|
||||||
|
altitude turns back down past the threshold from its peak). Result: ~30 m on the same
|
||||||
|
fixture. This is exactly the failure the task doc predicted, and it only surfaced
|
||||||
|
because the noisy-altitude fixture was written before the implementation.
|
||||||
|
2. **Smoothing then clipped real climbs.** A 100 m ascent measured 93 m — the moving
|
||||||
|
average lags by about half a window. `finish()` now reconciles the final run against
|
||||||
|
the last raw reading.
|
||||||
|
3. **I wasted a cycle on a broken shell harness.** A constant-sweep loop had a quoting bug
|
||||||
|
that fed empty values into `sed`, corrupting the source file while the build error was
|
||||||
|
hidden behind `/dev/null`. Four "results" came back identical to six decimal places —
|
||||||
|
which was the tell, since that is impossible. Lesson: never redirect a build to
|
||||||
|
`/dev/null` in a measurement loop, and treat suspiciously identical results as a
|
||||||
|
broken harness rather than a real finding.
|
||||||
|
|
||||||
|
**Honest limitation.** ~30 m of phantom gain per ten stationary minutes remains. The test
|
||||||
|
bound is a regression guard, not a target. Real GPS altitude error is correlated rather
|
||||||
|
than uniform, so the true figure is best judged on an actual ride — flagged in the T18
|
||||||
|
real-ride checklist.
|
||||||
|
|
||||||
|
**Design decisions worth carrying forward.**
|
||||||
|
|
||||||
|
- Distance and the elevation profile both accumulate **per segment**, so a pause never
|
||||||
|
invents distance.
|
||||||
|
- Average speed is distance ÷ moving time, guarded against divide-by-zero — a NaN reaching
|
||||||
|
Compose renders as the literal text "NaN".
|
||||||
|
- `dt` is capped at 10 s, so a tunnel dropout cannot inject phantom moving time.
|
||||||
|
- The speed histogram is weighted by **time**, not sample count.
|
||||||
|
- Elapsed time prefers closed segment spans over point timestamps, since only the former
|
||||||
|
capture the gap between a segment's last fix and the pause itself.
|
||||||
|
|
||||||
|
**For later tasks.**
|
||||||
|
- **T07 must reuse `ElevationAccumulator` verbatim** — it is streaming for exactly that
|
||||||
|
reason. Reimplementing it invites bug 1 back. Call `finish()` before persisting.
|
||||||
|
- T11 gets `speedHistogram()` and `elevationProfile()` chart-ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T06 — Trip lifecycle in TrackingService
|
||||||
|
|
||||||
|
**Shipped.** Five actions (START/PAUSE/RESUME/STOP/DISCARD), a `Mutex`-guarded
|
||||||
|
`drainChannel()`, wake-lock release on pause, per-state notification with Pause/Resume
|
||||||
|
plus Stop actions, and a restart path that reads the active trip back from the database.
|
||||||
|
7 new instrumented tests, 34 total, all green.
|
||||||
|
|
||||||
|
**A documented assumption turned out to be wrong — in our favour.**
|
||||||
|
|
||||||
|
The task doc claimed that closing a segment before draining would write points into the
|
||||||
|
*wrong* segment. It would not. `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, regardless of write order. The doc has been corrected.
|
||||||
|
|
||||||
|
Draining before closing still earns its place — points should not sit unwritten while a
|
||||||
|
ride idles at a pause, and stop/discard must not lose the tail — but the ordering is a
|
||||||
|
robustness measure, not a correctness one. Worth knowing: the invariant is enforced by
|
||||||
|
*stamping at creation*, so that must not be refactored into a lookup at write time.
|
||||||
|
|
||||||
|
**Hiccups.**
|
||||||
|
|
||||||
|
1. **I wrote a real bug and caught it on re-read, not in test.** `beginRecording()` first
|
||||||
|
chained `startTrip().takeIf { state != PAUSED } ?: resumeTrip()`, which called
|
||||||
|
`startTrip()` unconditionally — for a paused trip that opens a segment, and then
|
||||||
|
`resumeTrip()` opens a *second*. Replaced with an explicit `if` on the current state.
|
||||||
|
The lifecycle tests would likely have caught it, but only after the fact.
|
||||||
|
2. **Dead state.** A `receivingFixes` flag was written and never read. Removed.
|
||||||
|
3. **`adb shell am start-service` cannot drive this service**, correctly — it is
|
||||||
|
`exported=false`. Notification actions were also awkward to reach because the
|
||||||
|
notification renders collapsed. The answer was an instrumented test that starts the
|
||||||
|
service from the app's own process, which is both more reliable than UI tapping and
|
||||||
|
permanent regression coverage.
|
||||||
|
|
||||||
|
**Design decisions worth carrying forward.**
|
||||||
|
|
||||||
|
- `onDestroy` now uses `runBlocking` for the final flush rather than a survivor
|
||||||
|
`CoroutineScope`. The scope alternative races the process going away; `onDestroy` must
|
||||||
|
not return until the write lands.
|
||||||
|
- Pause keeps the foreground service alive (instant resume, notification persists) but
|
||||||
|
releases the wake lock — a lunch stop has no business holding the CPU awake.
|
||||||
|
- Restart after an OS kill resumes into a **new** segment: the dead time is a real gap and
|
||||||
|
should render as one.
|
||||||
|
|
||||||
|
**For later tasks.**
|
||||||
|
- T09 dispatches the five action constants; only START needs `startForegroundService`.
|
||||||
|
- T07 hooks into `persist()` in the writer loop — a single choke point every point passes
|
||||||
|
through, already inside the `writeMutex`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T07 — Live aggregate accumulation
|
||||||
|
|
||||||
|
**Shipped.** `com.rippr.Accumulator` (in `RideAccumulator.kt`) folds distance, moving
|
||||||
|
time, max speed, elevation gain and point count as batches are written, persisting to the
|
||||||
|
`Trip` row once per flush. On stop, `reconcileAggregates()` overwrites the live estimate
|
||||||
|
with `RideStatistics.compute()` over the stored points. 12 new unit tests, 68 total.
|
||||||
|
|
||||||
|
**Verified on device.** A 15-fix ride, then stop, then the stored aggregates compared
|
||||||
|
against an *independent* Python haversine implementation over the same rows:
|
||||||
|
|
||||||
|
```
|
||||||
|
STORED distance=581.07m moving=1008ms points=64
|
||||||
|
RECOMPUTED distance=581.07m moving=1008ms points=64 delta 0.0000 m
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hiccups.**
|
||||||
|
|
||||||
|
1. **Two half-finished pieces caught on re-read.** `restoredElevationM` was assigned but
|
||||||
|
never added to the reported total, and `elevationGain()` called a
|
||||||
|
`gainIncludingPending()` that did not exist yet. Both fixed — the latter matters
|
||||||
|
because reading `ElevationAccumulator.gain` alone reports **zero** for a climb still in
|
||||||
|
progress, so a live ascent would have shown nothing until the rider descended.
|
||||||
|
2. **A tap was silently swallowed right after a fresh install.** `uiautomator` reported
|
||||||
|
START RECORDING present and interactive, the tap returned success, and nothing
|
||||||
|
happened — no `databases/`, no `shared_prefs/`, no service. Repeating the identical tap
|
||||||
|
moments later worked. Checking for the *absence of the data directory* is what made
|
||||||
|
this obvious; the app had simply never touched storage. Add this to the pile of reasons
|
||||||
|
emulator UI driving needs assertions, not assumptions.
|
||||||
|
3. **A distance figure looked wrong and was not.** 580 m from ~333 m of fed movement. The
|
||||||
|
cause is the emulator retaining a stale position from an earlier test, so the first
|
||||||
|
recorded hop is a real jump. Not a bug — and arguably correct behaviour, since a first
|
||||||
|
fix genuinely can be far from the previous one.
|
||||||
|
|
||||||
|
**Design decisions worth carrying forward.**
|
||||||
|
|
||||||
|
- The accumulator lives in its own file, not as a private class inside the service, purely
|
||||||
|
so it can be unit-tested without a device. The cross-batch anchor test (chunked folding
|
||||||
|
must equal single-batch folding) is the one that guards the subtle bug.
|
||||||
|
- Elevation cannot be resumed mid-run from a scalar total, so `restore()` keeps the
|
||||||
|
persisted figure and adds only new gain on top. Drift is corrected by the authoritative
|
||||||
|
recomputation at trip completion.
|
||||||
|
- `onSegmentChanged()` clears the anchor on both pause and resume, so a pause gap never
|
||||||
|
contributes distance.
|
||||||
|
- Aggregates are written inside the same `writeMutex` as the point insert.
|
||||||
|
|
||||||
|
**For later tasks.**
|
||||||
|
- T09/T10/T11 should read `Trip.distanceM` etc. directly — no recomputation in the UI.
|
||||||
|
- T12's merge must call `RideStatistics.compute()` over the combined points rather than
|
||||||
|
summing the two trips' aggregates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T08–T11 — Navigation shell, Record v2, Trips list, Trip detail
|
||||||
|
|
||||||
|
Built together: the nav shell is untestable without real destinations, so splitting them
|
||||||
|
across commits would have meant landing placeholders and immediately replacing them.
|
||||||
|
|
||||||
|
**Shipped.** `ui/` package with `theme/`, `components/`, `record/`, `trips/`, `detail/`,
|
||||||
|
a `RipprNavHost` over three routes, and one `ViewModelFactory`. `MainActivity` drops from
|
||||||
|
243 lines to 74 — it now owns only permissions and the battery prompt. Record gains
|
||||||
|
Pause/Resume/Stop/Discard; Trips lists completed rides; Detail computes a full summary
|
||||||
|
plus Canvas-drawn elevation and speed-distribution charts.
|
||||||
|
|
||||||
|
**Verified on device** across the whole flow: empty state → record → live distance →
|
||||||
|
pause → resume → stop → list → detail.
|
||||||
|
|
||||||
|
**Hiccups.**
|
||||||
|
|
||||||
|
1. **A real rendering bug the screenshot caught.** Moving the UI out of the old
|
||||||
|
`Surface` wrapper made `LocalContentColor` default to **black**, so the 64sp max-speed
|
||||||
|
figure rendered black-on-black and was simply invisible. Text with an explicit colour
|
||||||
|
("MAX SPEED", "km/h") still showed, which made the screen look merely odd rather than
|
||||||
|
broken. `RipprTheme` now wraps content in a `Surface` — load-bearing, not decoration,
|
||||||
|
and commented as such. **This would not have been caught by any test I had.** Only
|
||||||
|
looking at the pixels found it.
|
||||||
|
2. **Splash screen mistaken for the app.** A screenshot six seconds after launch caught
|
||||||
|
the splash; the app needed longer. Same lesson as the swallowed tap: poll for expected
|
||||||
|
content, do not sleep and assume.
|
||||||
|
|
||||||
|
**Design decisions worth carrying forward.**
|
||||||
|
|
||||||
|
- `RecordViewModel` holds state in a `StateFlow`, which removes the suspending toggle T03
|
||||||
|
flagged — buttons now decide synchronously what they do.
|
||||||
|
- Discard is offered **only while paused**. A destructive control next to Pause during a
|
||||||
|
live ride invites a gloved mis-tap at speed.
|
||||||
|
- Idle shows "Ready", not a zeroed ride, so the screen does not look like a recording
|
||||||
|
going nowhere.
|
||||||
|
- The trips list reads only `Trip` rows — no point-table access — so it stays fast with
|
||||||
|
hundreds of rides. Detail is the only screen that loads points, and does so on IO.
|
||||||
|
- Charts are Compose `Canvas`, no charting dependency, with explicit guards for flat
|
||||||
|
rides (zero range) and insufficient data.
|
||||||
|
|
||||||
|
**For later tasks.**
|
||||||
|
- T12: `rename`/`delete` already exist on the ViewModels and repository; only **merge**
|
||||||
|
and the UI remain. `ConfirmDialog` is in `ui/components/`.
|
||||||
|
- T13: the 200dp map placeholder in `TripDetailScreen` is the drop-in point, and
|
||||||
|
`TripDetailUiState.Ready` already carries `points` and `segments`.
|
||||||
|
|
||||||
|
**Correction to the above.** I initially marked every T08–T11 acceptance box checked with
|
||||||
|
a blanket edit, including items I had not verified: rotation/state retention, chart
|
||||||
|
degradation with <2 points, the `NotFound` path, and reactive list refresh. Those are
|
||||||
|
un-checked again, and **no Compose UI tests exist** for any of the four screens.
|
||||||
|
Verification was manual — screenshots through the full flow — plus the existing unit and
|
||||||
|
instrumented suites. Writing the Compose UI tests is now tracked in T18.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T12 — Trip rename / delete / merge
|
||||||
|
|
||||||
|
**Shipped.** `TripRepository.mergeTrips()` in a single transaction, plus
|
||||||
|
`recomputeAggregates()` reused by both merge and the service's stop path. Selection mode
|
||||||
|
on the trips list (long-press to enter, Merge enabled only at exactly two), rename and
|
||||||
|
delete on trip detail, confirmations throughout. 11 new instrumented tests, 45 total.
|
||||||
|
|
||||||
|
**Found a real service bug while chasing a flaky test.**
|
||||||
|
|
||||||
|
Two lifecycle tests started failing once the suite grew to 45. The tempting read was
|
||||||
|
"slow emulator, raise the timeout". The actual cause was in `TrackingService.finish()`,
|
||||||
|
which called bare `stopSelf()`. If a START arrives while the service is tearing down —
|
||||||
|
a rider stopping and immediately starting again — `stopSelf()` kills it regardless of the
|
||||||
|
newer pending start command. Now `stopSelf(startId)`, which only stops when no newer start
|
||||||
|
is queued.
|
||||||
|
|
||||||
|
Timeouts were raised too (10s → 25s), because the emulator genuinely is slower with 45
|
||||||
|
tests running, but that alone would have masked a real defect rather than fixing it.
|
||||||
|
|
||||||
|
**Design decisions worth carrying forward.**
|
||||||
|
|
||||||
|
- **Segments are never joined on merge.** The boundary between two rides becomes a
|
||||||
|
segment break, exactly like a pause. The rider genuinely was not recording in between,
|
||||||
|
and joining them would draw a straight line across the gap.
|
||||||
|
- **Aggregates are recomputed, never summed.** A test asserts the merged distance equals
|
||||||
|
a fresh `RideStatistics.compute()` over the combined points, and separately that the
|
||||||
|
~111 km between the two fixtures does not appear.
|
||||||
|
- **Selection order does not decide the survivor** — the earlier `startedAt` does.
|
||||||
|
- Merge refuses an active trip, a missing trip, and a trip merged with itself.
|
||||||
|
- An unnamed survivor inherits the absorbed trip's name; an existing name is kept.
|
||||||
|
|
||||||
|
**For later tasks.**
|
||||||
|
- `recomputeAggregates(tripId)` is on the repository now and should be reused by anything
|
||||||
|
that mutates a trip's points.
|
||||||
|
- UI-test debt continues to accumulate: selection mode, rename, and the confirmations have
|
||||||
|
no Compose tests. Tracked in T18.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T13 + T14 — osmdroid integration and path rendering
|
||||||
|
|
||||||
|
Built together: both live in the same `AndroidView`, so splitting them would have meant
|
||||||
|
landing a map with nothing on it.
|
||||||
|
|
||||||
|
**Shipped.** `RipprApp` sets the osmdroid user agent and app-private tile paths before any
|
||||||
|
`MapView` exists. `ui/components/RideMap.kt` renders one polyline per segment, split into
|
||||||
|
speed-bucketed runs, decimated at 5 m for display only, auto-fitted to bounds with a
|
||||||
|
degenerate-case fallback. `Config.mapEnabled` gates the whole composable.
|
||||||
|
|
||||||
|
**Verified on device.** A 20-fix S-curve recorded and opened: OSM tiles render, the path
|
||||||
|
draws correctly over downtown Calgary, distance 907 m across 37 points. Toggling off
|
||||||
|
collapses the map entirely. Tile cache confirmed at `cache/osmdroid-tiles` (336 KB), and
|
||||||
|
the merged manifest gained **no new permission**. `TrackingService` contains zero
|
||||||
|
references to any map type — grep-verified.
|
||||||
|
|
||||||
|
**Hiccups.**
|
||||||
|
|
||||||
|
1. **I wrote the exact lifecycle bug the doc warned about.** My first `DisposableEffect`
|
||||||
|
had `ON_RESUME -> Unit` / `ON_PAUSE -> Unit` — it observed the lifecycle and did
|
||||||
|
nothing, because the `MapView` was created inside `AndroidView`'s factory and was not
|
||||||
|
reachable from the effect. Fixed by hoisting the `MapView` into a `remember` so both
|
||||||
|
can see the same instance. Writing a warning into a doc is not the same as heeding it.
|
||||||
|
2. **Two wasted emulator runs from stale UI state.** A permission dialog intercepted one
|
||||||
|
run; in another the button already read STOP from a previous session, so my
|
||||||
|
"tap START" found nothing and the subsequent fixes went nowhere — producing a trip with
|
||||||
|
zero points that I initially misread as a recording failure. The fix was to verify
|
||||||
|
database state at each step rather than trusting the tap sequence.
|
||||||
|
|
||||||
|
**A real wart found by testing, and fixed.** Tapping START then STOP saved a **0-point
|
||||||
|
ride** into the history list. Stop now discards a trip that captured nothing, with a test.
|
||||||
|
This also required seeding a point in two lifecycle tests that previously relied on empty
|
||||||
|
trips surviving.
|
||||||
|
|
||||||
|
**Design decisions worth carrying forward.**
|
||||||
|
|
||||||
|
- **Bucketed polylines over per-vertex colouring.** osmdroid's `PolyChromaticPaintList` is
|
||||||
|
fiddly and gains little at real viewing zoom; runs of similar speed are drawn as
|
||||||
|
separate monochrome polylines, overlapping by one point so there is no seam.
|
||||||
|
- **Decimation is strictly render-side.** `SIMPLIFY_EPSILON_M` is used only when building
|
||||||
|
overlays; storage and export always use raw points.
|
||||||
|
- Segments are never joined, so a pause leaves a visible gap.
|
||||||
|
- `Bounds.isDegenerate` guards auto-fit for a stationary ride.
|
||||||
|
|
||||||
|
**Still unverified.** Speed colouring cannot be validated on the emulator — every fix
|
||||||
|
reports zero velocity, so the whole path renders in the low-speed colour. Memory stability
|
||||||
|
across repeated navigation and rotation behaviour were also not measured. All tracked in
|
||||||
|
T18.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T15 + T16 — GPX/GeoJSON export
|
||||||
|
|
||||||
|
**Shipped.** `export/RideExport.kt` (pure string generation, 15 unit tests) and
|
||||||
|
`export/ExportManager.kt` writing through `FileProvider`. Export buttons on trip detail.
|
||||||
|
83 unit tests total.
|
||||||
|
|
||||||
|
**Verified end to end on device.** Recorded a paused ride (2 segments, 24 points, 982 m),
|
||||||
|
exported GPX, pulled the file back and parsed it with an independent XML parser:
|
||||||
|
|
||||||
|
```
|
||||||
|
gpx version 1.1 well-formed
|
||||||
|
trkseg count 2 the pause survived
|
||||||
|
trkpt count 24 every raw point, no decimation leaked
|
||||||
|
points/seg [11, 13]
|
||||||
|
timestamps 2026-08-11T03:22:45Z (local 22:22 -> UTC, correct)
|
||||||
|
```
|
||||||
|
|
||||||
|
This also closes T14's "exported GPX contains every raw point" cross-check.
|
||||||
|
|
||||||
|
**Scope reduced deliberately.** SAF "save to file" was dropped; only the share sheet
|
||||||
|
shipped. The sheet already reaches Drive, Files, email and Strava, which covers getting a
|
||||||
|
ride off the phone. Recorded as unchecked in the T16 doc rather than quietly omitted.
|
||||||
|
|
||||||
|
**Hiccups — all in the test harness, none in the code.**
|
||||||
|
|
||||||
|
1. **`TrackingServiceLifecycleTest` wipes the real device database.** It calls
|
||||||
|
`AppDatabase.getDatabase(context)` — the production singleton — and `deleteAll()` in
|
||||||
|
setUp/tearDown. That is why a recorded ride vanished between runs. Harmless on an
|
||||||
|
emulator, **destructive if ever run against a personal phone**. Flagged for T18.
|
||||||
|
2. **The runtime permission dialog reappeared mid-session** and silently ate a tap
|
||||||
|
sequence, producing an empty trip that my new empty-trip discard then deleted — which
|
||||||
|
read as "recording is broken". It was not.
|
||||||
|
3. Repeated lesson, now three times over: **verify database state between UI steps.**
|
||||||
|
Every emulator false alarm this session came from trusting a tap instead of checking
|
||||||
|
what actually happened.
|
||||||
|
|
||||||
|
**Design decisions worth carrying forward.**
|
||||||
|
|
||||||
|
- GPX speed is exported in **m/s**, not km/h, per the spec, inside `<extensions>` so
|
||||||
|
consumers that do not understand it degrade cleanly.
|
||||||
|
- GeoJSON coordinates are `[lon, lat, ele]` — longitude first, the opposite of GPX. A test
|
||||||
|
asserts this explicitly because it is the classic silent error.
|
||||||
|
- Trip names are XML- and JSON-escaped; a test uses `Sam & Dave's <ride> "fast"`.
|
||||||
|
- Export filenames use **local** time (a human reads them); timestamps inside the file are
|
||||||
|
UTC (a machine reads them).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## T17 + T18 — Uploader trip-awareness, verification, migration removal
|
||||||
|
|
||||||
|
**T17.** `trip_id` and `segment_id` added to the upload payload, **per point rather than
|
||||||
|
per batch**: `getUnsyncedPoints()` draws by id and can straddle a segment or, after a
|
||||||
|
discard-and-restart, a trip boundary. A test asserts a two-segment batch labels each point
|
||||||
|
individually. The endpoint still has no UI and remains reachable only via
|
||||||
|
`Config.setUploadEndpoint()`.
|
||||||
|
|
||||||
|
**T18 — the destructive migration is gone.** `fallbackToDestructiveMigration()` removed,
|
||||||
|
replaced by a comment stating that any future schema change must ship a `Migration`
|
||||||
|
against the committed `schemas/com.rippr.data.AppDatabase/2.json`. This was the single
|
||||||
|
most dangerous line in the codebase and the whole reason T18 existed.
|
||||||
|
|
||||||
|
**Also fixed: the test that wiped real ride data.** `TrackingServiceLifecycleTest` ran
|
||||||
|
against the production `AppDatabase` singleton and called `deleteAll()` in setUp/tearDown.
|
||||||
|
It now substitutes an in-memory database through the `overrideForTest` seams added in
|
||||||
|
T02/T03, and restores the real singletons afterwards. Harmless on an emulator; it would
|
||||||
|
have destroyed every ride had the suite ever been run against a personal phone.
|
||||||
|
|
||||||
|
**Final state.**
|
||||||
|
|
||||||
|
```
|
||||||
|
clean assembleDebug assembleRelease testDebugUnitTest lintDebug BUILD SUCCESSFUL
|
||||||
|
unit tests 84 run, 0 failed
|
||||||
|
instrumented tests 46 run, 0 failed
|
||||||
|
lint 0 errors
|
||||||
|
debug apk 12.0 MB release apk 8.5 MB (unsigned)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Outstanding, and honestly so:**
|
||||||
|
|
||||||
|
1. **The real-ride checklist has not been run.** Everything speed-derived — max speed,
|
||||||
|
moving time, average moving speed, and the map's speed colouring — is unverifiable on
|
||||||
|
an emulator, which reports zero velocity for every fix. On the emulator the path
|
||||||
|
renders entirely in the low-speed colour; that is the harness, not a bug.
|
||||||
|
2. **Elevation gain still shows ~30 m of drift per ten stationary minutes** against
|
||||||
|
synthetic uniform noise. Real GPS error is correlated rather than uniform, so the true
|
||||||
|
figure needs a real ride. Watch for implausible climbing on flat ground.
|
||||||
|
3. **No Compose UI tests exist** for any of the six screens. Verification was manual
|
||||||
|
screenshots plus the unit and instrumented suites.
|
||||||
|
4. **SAF export was dropped**; share sheet only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v2.0.1 — Fixes from the first real ride
|
||||||
|
|
||||||
|
Dylan rode with v2.0 and reported two problems. Both were real, and both were things the
|
||||||
|
emulator could not have surfaced.
|
||||||
|
|
||||||
|
### 1. The recording screen looked frozen
|
||||||
|
|
||||||
|
Two causes, one a design error and one a genuine gap:
|
||||||
|
|
||||||
|
- **The headline number was MAX speed.** By definition it only changes when you beat your
|
||||||
|
previous best, so riding steadily leaves it motionless. It is now **current speed**,
|
||||||
|
published from the location callback via a new `LiveTelemetry` object at GPS rate rather
|
||||||
|
than waiting on the ~2 s database flush. Max speed moved into the stats card.
|
||||||
|
- **There was no clock at all.** Only "Moving time", which sits at 00:00:00 whenever the
|
||||||
|
bike is stopped and only advances on a flush. Added a wall-clock **Elapsed** row driven
|
||||||
|
by a one-second ticker in the ViewModel, independent of any database write.
|
||||||
|
|
||||||
|
Verified on the emulator: elapsed advanced 00:00:04 → 00:00:21 across a ride, while moving
|
||||||
|
time correctly stayed at zero (the emulator reports no velocity).
|
||||||
|
|
||||||
|
### 2. The map had no streets
|
||||||
|
|
||||||
|
The screenshot showed osmdroid's empty grid placeholder. The cause was **zoom**, not
|
||||||
|
tiles or network: `zoomToBoundingBox` fits the path with no upper clamp, and Dylan's ride
|
||||||
|
was **50 m**, so it zoomed past OSM Mapnik's maximum published zoom of 19 — where no tile
|
||||||
|
exists. My emulator test used a ~900 m path, which stayed in range. That is exactly the
|
||||||
|
kind of gap a synthetic fixture hides.
|
||||||
|
|
||||||
|
Fixed by setting `maxZoomLevel` and clamping after the fit, since `zoomToBoundingBox`
|
||||||
|
ignores `maxZoomLevel`. Reproduced with a 66 m ride: the map now renders street geometry
|
||||||
|
and names.
|
||||||
|
|
||||||
|
### 3. Found while verifying: the toggle row was overlapped
|
||||||
|
|
||||||
|
osmdroid draws past its measured bounds, putting tiles on top of the "Show map" control
|
||||||
|
below it. Fixed with `clipToBounds()` on the map and an opaque background on the row.
|
||||||
|
|
||||||
|
**Lesson for the log.** Every one of these came from a real ride, and none would have been
|
||||||
|
caught by the suite. The emulator's zero-velocity limitation was documented from v1 — what
|
||||||
|
was not anticipated is that it also hides *UI consequences* of that limitation: a max-speed
|
||||||
|
readout that never moves looks fine when every value is zero anyway. Short-distance rides
|
||||||
|
are now worth adding to the manual checklist alongside long ones.
|
||||||
85
rippr-src/docs/v2/README.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Rippr v2 — Task Index
|
||||||
|
|
||||||
|
Trips, path rendering, and export. Full rationale lives in the plan; this directory
|
||||||
|
holds one document per task, each self-contained enough to implement from.
|
||||||
|
|
||||||
|
## Why v2
|
||||||
|
|
||||||
|
v1 records GPS telemetry reliably — a real ride confirmed the whole pipeline including
|
||||||
|
`speedKmh`. What it lacks is *structure*: `track_points` is one unbounded append-only
|
||||||
|
table with no boundary between rides. "No reset", "no pause", and "no trips" are all the
|
||||||
|
same missing concept.
|
||||||
|
|
||||||
|
Path data is **already captured** (`latitude`, `longitude`, `altitudeM`, `bearingDeg` at
|
||||||
|
1–2 Hz). v2 adds rendering, not capture.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Map library | osmdroid — Apache-2.0, no API key, no billing |
|
||||||
|
| Trip lifecycle | Manual only — explicit Start/Stop |
|
||||||
|
| Reset | Discard current trip, behind a confirm |
|
||||||
|
| Live map | None. Phone rides in a pocket; the map is a post-ride artifact |
|
||||||
|
| Map visibility | Behind a toggle, trip detail only, never touched by the service |
|
||||||
|
| v1 data | Nuked — v1 was a test, so no migration |
|
||||||
|
| Trip management | Rename, delete, merge |
|
||||||
|
| Group ride view | Deferred to v3+ |
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
| # | Task | Phase | Depends | Status |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [T01](01-baseline.md) | Repo + dependency baseline | 0 | — | **Done** |
|
||||||
|
| [T02](02-schema.md) | Schema v2 entities + DAOs | 1 | T01 | **Done** |
|
||||||
|
| [T03](03-repository.md) | Trip repository + reactive state | 1 | T02 | **Done** |
|
||||||
|
| [T04](04-geo.md) | Geo utilities | 2 | T01 | **Done** |
|
||||||
|
| [T05](05-stats.md) | Ride statistics | 2 | T04 | **Done** |
|
||||||
|
| [T06](06-service-lifecycle.md) | Trip lifecycle in the service | 3 | T03 | **Done** |
|
||||||
|
| [T07](07-aggregates.md) | Live aggregate accumulation | 3 | T04, T06 | **Done** |
|
||||||
|
| [T08](08-nav-shell.md) | Navigation shell + ViewModels | 4 | T01 | **Done** |
|
||||||
|
| [T09](09-record-screen.md) | Record screen v2 | 4 | T06, T08 | **Done** |
|
||||||
|
| [T10](10-trips-list.md) | Trips list | 4 | T02, T08 | **Done** |
|
||||||
|
| [T11](11-trip-detail.md) | Trip detail (stats only) | 4 | T05, T08 | **Done** |
|
||||||
|
| [T12](12-trip-management.md) | Rename / delete / merge | 4 | T10, T11 | **Done** |
|
||||||
|
| [T13](13-map-integration.md) | osmdroid integration + toggle | 5 | T11 | **Done** |
|
||||||
|
| [T14](14-path-rendering.md) | Path rendering | 5 | T04, T13 | **Done** |
|
||||||
|
| [T15](15-export-format.md) | GPX + GeoJSON writers | 6 | T04 | **Done** |
|
||||||
|
| [T16](16-export-ui.md) | Export UI | 6 | T15 | **Done** |
|
||||||
|
| [T17](17-uploader.md) | Uploader trip-awareness | 7 | T02 | **Done** |
|
||||||
|
| [T18](18-verification.md) | Verification + remove destructive migration | 7 | all | **Done** (real-ride checklist outstanding) |
|
||||||
|
|
||||||
|
## Dependency graph
|
||||||
|
|
||||||
|
```
|
||||||
|
T01 ──┬─► T02 ──► T03 ──► T06 ──► T07 ──┐
|
||||||
|
│ ├─────────► T10 ──┐ │
|
||||||
|
│ └─────────► T17 │ │
|
||||||
|
├─► T04 ──► T05 ──► T11├─► T12 │
|
||||||
|
│ └──────────► T14│ │
|
||||||
|
└─► T08 ──► T09 ───────┘ │
|
||||||
|
T11 ──► T13 ──► T14 │
|
||||||
|
T04 ──► T15 ──► T16 ▼
|
||||||
|
T18
|
||||||
|
```
|
||||||
|
|
||||||
|
Critical path: **T01 → T02 → T03 → T06 → T07 → T09 → T18**.
|
||||||
|
T04/T05, T08, and T15 are parallelisable early wins requiring no device.
|
||||||
|
|
||||||
|
Progress log with per-task outcomes and hiccups: [PROGRESS.md](PROGRESS.md).
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Package layout**: `data/`, `geo/`, `stats/`, `export/`, `ui/` under `com.rippr`
|
||||||
|
- **Pure logic stays Android-free** so it is JVM-unit-testable — the pattern
|
||||||
|
`Telemetry.kt` already follows
|
||||||
|
- **Comments explain why, not what**, matching the existing codebase
|
||||||
|
- Each task ends green: `./gradlew assembleDebug testDebugUnitTest lintDebug`
|
||||||
|
|
||||||
|
## Standing constraints
|
||||||
|
|
||||||
|
- **minSdk 26 ⇒ SQLite 3.18 ⇒ no window functions.** Consecutive-point maths cannot be
|
||||||
|
done in SQL; it is accumulated in Kotlin.
|
||||||
|
- **Migrations are now mandatory.** The destructive fallback was removed in T18. Any
|
||||||
|
schema change must ship a `Migration` against `schemas/com.rippr.data.AppDatabase/2.json`.
|
||||||
|
- **Decimation is render-only.** Storage and export always use raw points.
|
||||||
9
rippr-src/gradle.properties
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
|
kotlin.code.style=official
|
||||||