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>
This commit is contained in:
2026-08-11 08:30:25 -05:00
parent 4debdfa518
commit 280fd7f988
106 changed files with 10377 additions and 0 deletions

BIN
rippr-full-history.bundle Normal file

Binary file not shown.

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

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

44
rippr-src/SNAPSHOT.md Normal file
View 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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 589 B

View File

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

After

Width:  |  Height:  |  Size: 542 B

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

View File

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

After

Width:  |  Height:  |  Size: 547 B

View File

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

After

Width:  |  Height:  |  Size: 271 B

View File

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

After

Width:  |  Height:  |  Size: 205 B

View File

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

After

Width:  |  Height:  |  Size: 705 B

View File

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

After

Width:  |  Height:  |  Size: 511 B

View File

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

After

Width:  |  Height:  |  Size: 537 B

View File

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

After

Width:  |  Height:  |  Size: 659 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.

View 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+).

View 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.

View 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.

View 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.

View 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

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