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

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)
}
}