diff --git a/rippr-full-history.bundle b/rippr-full-history.bundle new file mode 100644 index 0000000..5f9f6c9 Binary files /dev/null and b/rippr-full-history.bundle differ diff --git a/rippr-src/.gitignore b/rippr-src/.gitignore new file mode 100644 index 0000000..3aa0daa --- /dev/null +++ b/rippr-src/.gitignore @@ -0,0 +1,13 @@ +*.iml +.gradle/ +local.properties +.idea/ +.DS_Store +build/ +captures/ +.externalNativeBuild +.cxx +.kotlin/ +__pycache__/ +*.pyc +design/preview/ diff --git a/rippr-src/SNAPSHOT.md b/rippr-src/SNAPSHOT.md new file mode 100644 index 0000000..811f670 --- /dev/null +++ b/rippr-src/SNAPSHOT.md @@ -0,0 +1,44 @@ +# Rippr — source snapshot + +Point-in-time export of the Rippr working tree, exported with `git archive HEAD` +(tracked files only — no build outputs, no `local.properties`, no nested `.git`). + +**Commit:** `2f76983` — "Fix live readout and blank map, both found on the first real ride" + +## Two copies live here, for different jobs + +| File | Purpose | +|---|---| +| `rippr-src/` | Browsable source. No history — a snapshot that will drift. | +| `rippr-full-history.bundle` | Full git history (18 commits). The real backup. | + +The canonical repo is `~/dojo/rippr`, which **has no git remote** — it exists only on that +machine. The bundle is therefore the only off-machine copy of the history. + +## Restoring from the bundle + +```bash +git clone rippr-full-history.bundle rippr +cd rippr +echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties +./gradlew assembleDebug +``` + +Verified: the bundle reports "records a complete history" and clones cleanly to all 18 +commits. + +## Building + +Needs JDK 17–21 (AGP does not support 25) and the Android SDK with platform 35 and +build-tools 35. See `docs/v2/01-baseline.md`. + +```bash +./gradlew assembleDebug testDebugUnitTest lintDebug # 84 unit tests +./gradlew connectedDebugAndroidTest # 46, needs a device +``` + +## Where to start reading + +- `docs/v2/README.md` — task index and architecture decisions +- `docs/v2/PROGRESS.md` — per-task outcomes, bugs found, and known gaps +- `app/src/main/java/com/rippr/TrackingService.kt` — the recording pipeline diff --git a/rippr-src/app/build.gradle.kts b/rippr-src/app/build.gradle.kts new file mode 100644 index 0000000..d3ef4b7 --- /dev/null +++ b/rippr-src/app/build.gradle.kts @@ -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) +} diff --git a/rippr-src/app/proguard-rules.pro b/rippr-src/app/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/rippr-src/app/schemas/com.rippr.data.AppDatabase/2.json b/rippr-src/app/schemas/com.rippr.data.AppDatabase/2.json new file mode 100644 index 0000000..73826b6 --- /dev/null +++ b/rippr-src/app/schemas/com.rippr.data.AppDatabase/2.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/rippr-src/app/src/androidTest/java/com/rippr/TrackingServiceLifecycleTest.kt b/rippr-src/app/src/androidTest/java/com/rippr/TrackingServiceLifecycleTest.kt new file mode 100644 index 0000000..23ea403 --- /dev/null +++ b/rippr-src/app/src/androidTest/java/com/rippr/TrackingServiceLifecycleTest.kt @@ -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() + 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) + } + } +} diff --git a/rippr-src/app/src/androidTest/java/com/rippr/data/MergeTest.kt b/rippr-src/app/src/androidTest/java/com/rippr/data/MergeTest.kt new file mode 100644 index 0000000..784b6ba --- /dev/null +++ b/rippr-src/app/src/androidTest/java/com/rippr/data/MergeTest.kt @@ -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) + } +} diff --git a/rippr-src/app/src/androidTest/java/com/rippr/data/SchemaTest.kt b/rippr-src/app/src/androidTest/java/com/rippr/data/SchemaTest.kt new file mode 100644 index 0000000..447ec9e --- /dev/null +++ b/rippr-src/app/src/androidTest/java/com/rippr/data/SchemaTest.kt @@ -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 { + 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) + } +} diff --git a/rippr-src/app/src/androidTest/java/com/rippr/data/TripRepositoryTest.kt b/rippr-src/app/src/androidTest/java/com/rippr/data/TripRepositoryTest.kt new file mode 100644 index 0000000..cc19d2c --- /dev/null +++ b/rippr-src/app/src/androidTest/java/com/rippr/data/TripRepositoryTest.kt @@ -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) + } +} diff --git a/rippr-src/app/src/main/AndroidManifest.xml b/rippr-src/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c779db4 --- /dev/null +++ b/rippr-src/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rippr-src/app/src/main/java/com/rippr/Config.kt b/rippr-src/app/src/main/java/com/rippr/Config.kt new file mode 100644 index 0000000..42ea0f6 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/Config.kt @@ -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) +} diff --git a/rippr-src/app/src/main/java/com/rippr/LiveTelemetry.kt b/rippr-src/app/src/main/java/com/rippr/LiveTelemetry.kt new file mode 100644 index 0000000..8a5697e --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/LiveTelemetry.kt @@ -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 = _speedKmh.asStateFlow() + + private val _accuracyM = MutableStateFlow(0f) + val accuracyM: StateFlow = _accuracyM.asStateFlow() + + fun update(speedKmh: Float, accuracyM: Float) { + _speedKmh.value = speedKmh + _accuracyM.value = accuracyM + } + + fun clear() { + _speedKmh.value = 0f + _accuracyM.value = 0f + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/MainActivity.kt b/rippr-src/app/src/main/java/com/rippr/MainActivity.kt new file mode 100644 index 0000000..81f9970 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/MainActivity.kt @@ -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)) } + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/RideAccumulator.kt b/rippr-src/app/src/main/java/com/rippr/RideAccumulator.kt new file mode 100644 index 0000000..af41ff3 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/RideAccumulator.kt @@ -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) { + 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 + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/RipprApp.kt b/rippr-src/app/src/main/java/com/rippr/RipprApp.kt new file mode 100644 index 0000000..4b38315 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/RipprApp.kt @@ -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) + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/Telemetry.kt b/rippr-src/app/src/main/java/com/rippr/Telemetry.kt new file mode 100644 index 0000000..80053ff --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/Telemetry.kt @@ -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): 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() + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/TelemetryUploader.kt b/rippr-src/app/src/main/java/com/rippr/TelemetryUploader.kt new file mode 100644 index 0000000..34a009c --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/TelemetryUploader.kt @@ -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): 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() + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/TrackingService.kt b/rippr-src/app/src/main/java/com/rippr/TrackingService.kt new file mode 100644 index 0000000..0b5e0fe --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/TrackingService.kt @@ -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(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(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) { + 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")) + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/UploadStatus.kt b/rippr-src/app/src/main/java/com/rippr/UploadStatus.kt new file mode 100644 index 0000000..c3ff3df --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/UploadStatus.kt @@ -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(null) + val lastError: StateFlow = _lastError.asStateFlow() + + fun setError(message: String?) { + _lastError.value = message + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/data/AppDatabase.kt b/rippr-src/app/src/main/java/com/rippr/data/AppDatabase.kt new file mode 100644 index 0000000..9168d81 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/data/AppDatabase.kt @@ -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 + } + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/data/RideStats.kt b/rippr-src/app/src/main/java/com/rippr/data/RideStats.kt new file mode 100644 index 0000000..81a1216 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/data/RideStats.kt @@ -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) + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/data/Segment.kt b/rippr-src/app/src/main/java/com/rippr/data/Segment.kt new file mode 100644 index 0000000..a8d681f --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/data/Segment.kt @@ -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 `` 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 +} diff --git a/rippr-src/app/src/main/java/com/rippr/data/SegmentDao.kt b/rippr-src/app/src/main/java/com/rippr/data/SegmentDao.kt new file mode 100644 index 0000000..cb5fff5 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/data/SegmentDao.kt @@ -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 + + /** 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 +} diff --git a/rippr-src/app/src/main/java/com/rippr/data/TrackPoint.kt b/rippr-src/app/src/main/java/com/rippr/data/TrackPoint.kt new file mode 100644 index 0000000..2064e3c --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/data/TrackPoint.kt @@ -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, +) diff --git a/rippr-src/app/src/main/java/com/rippr/data/TrackPointDao.kt b/rippr-src/app/src/main/java/com/rippr/data/TrackPointDao.kt new file mode 100644 index 0000000..869cd7e --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/data/TrackPointDao.kt @@ -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): List + + /** + * 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 + + @Query("SELECT * FROM track_points WHERE segmentId = :segmentId ORDER BY id ASC") + suspend fun forSegment(segmentId: Long): List + + /** 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 + + // --- 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 + + @Query("UPDATE track_points SET synced = 1 WHERE id IN (:ids)") + suspend fun markSynced(ids: List) + + @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 + + // --- 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 + + @Query("DELETE FROM track_points") + suspend fun clearAll() +} diff --git a/rippr-src/app/src/main/java/com/rippr/data/Trip.kt b/rippr-src/app/src/main/java/com/rippr/data/Trip.kt new file mode 100644 index 0000000..5a0d0c0 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/data/Trip.kt @@ -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 +} diff --git a/rippr-src/app/src/main/java/com/rippr/data/TripDao.kt b/rippr-src/app/src/main/java/com/rippr/data/TripDao.kt new file mode 100644 index 0000000..6cc42b5 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/data/TripDao.kt @@ -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 + + @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> + + @Query("SELECT * FROM trips WHERE id = :id") + fun observeById(id: Long): Flow + + @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() +} diff --git a/rippr-src/app/src/main/java/com/rippr/data/TripRepository.kt b/rippr-src/app/src/main/java/com/rippr/data/TripRepository.kt new file mode 100644 index 0000000..4f790d7 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/data/TripRepository.kt @@ -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 = tripDao.observeActive() + + fun observeTrip(id: Long): Flow = tripDao.observeById(id) + + fun observeCompletedTrips(): Flow> = 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 = pointDao.forTrip(id) + + suspend fun segmentsForTrip(id: Long): List = 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) diff --git a/rippr-src/app/src/main/java/com/rippr/export/ExportManager.kt b/rippr-src/app/src/main/java/com/rippr/export/ExportManager.kt new file mode 100644 index 0000000..a3b51b0 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/export/ExportManager.kt @@ -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, + points: List, + 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() } + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/export/RideExport.kt b/rippr-src/app/src/main/java/com/rippr/export/RideExport.kt new file mode 100644 index 0000000..1995d0a --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/export/RideExport.kt @@ -0,0 +1,151 @@ +package com.rippr.export + +import com.rippr.data.Segment +import com.rippr.data.TrackPoint +import com.rippr.data.Trip +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Serialises a ride to formats other tools understand. + * + * Pure string generation with no Android dependency, so it is fully unit-testable. + * + * **Never decimate here.** Simplification exists only in the map render path; an export + * must carry every raw point. The tests assert exact counts to catch any leak. + */ +object RideExport { + + /** ISO 8601 in UTC. `Location.time` is already UTC epoch millis, so no zone maths. */ + private val iso: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC) + + private fun time(epochMillis: Long): String = iso.format(Instant.ofEpochMilli(epochMillis)) + + private fun coord(value: Double): String = String.format(Locale.ROOT, "%.7f", value) + + private fun num(value: Double): String = String.format(Locale.ROOT, "%.1f", value) + + /** + * XML-escapes text destined for an attribute or element body. + * + * A trip named "Sam & Dave's" would otherwise produce malformed XML, and the failure + * is invisible until an import rejects the file. + */ + internal fun escapeXml(text: String): String = buildString(text.length) { + for (c in text) { + when (c) { + '&' -> append("&") + '<' -> append("<") + '>' -> append(">") + '"' -> append(""") + '\'' -> append("'") + else -> append(c) + } + } + } + + /** + * GPX 1.1. + * + * One `` 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 ``; 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, points: List, 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("""""").append('\n') + append( + """""" + ).append('\n') + append(" \n") + append(" ").append(escapeXml(name)).append("\n") + append(" \n") + append(" \n") + append(" \n") + append(" ").append(escapeXml(name)).append("\n") + + for (segmentId in ordered) { + append(" \n") + for (p in bySegment.getValue(segmentId)) { + append(" \n") + append(" ").append(num(p.altitudeM)).append("\n") + append(" \n") + append(" \n") + append(" ") + .append(num(p.speedKmh / 3.6)) // GPX speed is metres per second + .append("\n") + append(" \n") + append(" \n") + } + append(" \n") + } + + append(" \n") + append("\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, points: List, 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('"') + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/geo/Geo.kt b/rippr-src/app/src/main/java/com/rippr/geo/Geo.kt new file mode 100644 index 0000000..07f1f30 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/geo/Geo.kt @@ -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, epsilonMeters: Double): List { + if (points.size <= 2 || epsilonMeters <= 0.0) return points + + val keep = BooleanArray(points.size) + keep[0] = true + keep[points.lastIndex] = true + + val stack = ArrayDeque>() + 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): 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): 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 + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt b/rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt new file mode 100644 index 0000000..a799429 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt @@ -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, segments: List = 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, segments: List): 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, bucketKmh: Int = 10): List { + if (points.size < 2 || bucketKmh <= 0) return emptyList() + + val millisByBucket = sortedMapOf() + 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, maxSamples: Int = 200): List { + if (points.isEmpty()) return emptyList() + if (points.size == 1) return listOf(ElevationSample(0.0, points[0].altitudeM)) + + val full = ArrayList(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(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 + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/Format.kt b/rippr-src/app/src/main/java/com/rippr/ui/Format.kt new file mode 100644 index 0000000..2827b1a --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/Format.kt @@ -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" +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/RipprNavHost.kt b/rippr-src/app/src/main/java/com/rippr/ui/RipprNavHost.kt new file mode 100644 index 0000000..3ecbdfe --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/RipprNavHost.kt @@ -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() }, + ) + } + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/RipprViewModels.kt b/rippr-src/app/src/main/java/com/rippr/ui/RipprViewModels.kt new file mode 100644 index 0000000..ea44f9f --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/RipprViewModels.kt @@ -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 create(modelClass: Class, 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 ripprViewModel(tripId: Long = 0): T = + viewModel(factory = RipprViewModelFactory(tripId), key = "${T::class.java.name}:$tripId") diff --git a/rippr-src/app/src/main/java/com/rippr/ui/components/RideMap.kt b/rippr-src/app/src/main/java/com/rippr/ui/components/RideMap.kt new file mode 100644 index 0000000..d6f29ac --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/components/RideMap.kt @@ -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, + 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, + 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, + simplified: List, +): List> { + val byCoord = HashMap, 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) { + 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)) + } + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/components/Stats.kt b/rippr-src/app/src/main/java/com/rippr/ui/components/Stats.kt new file mode 100644 index 0000000..2f69d6f --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/components/Stats.kt @@ -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") } }, + ) +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/detail/TripDetailScreen.kt b/rippr-src/app/src/main/java/com/rippr/ui/detail/TripDetailScreen.kt new file mode 100644 index 0000000..d02ebe2 --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/detail/TripDetailScreen.kt @@ -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) { + 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) { + 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") } }, + ) +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/detail/TripDetailViewModel.kt b/rippr-src/app/src/main/java/com/rippr/ui/detail/TripDetailViewModel.kt new file mode 100644 index 0000000..162e90e --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/detail/TripDetailViewModel.kt @@ -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, + val points: List, + val elevation: List, + val histogram: List, + ) : TripDetailUiState { + val isMultiSegment: Boolean get() = segments.size > 1 + } +} + +class TripDetailViewModel( + private val repo: TripRepository, + private val tripId: Long, +) : ViewModel() { + + private val _uiState = MutableStateFlow(TripDetailUiState.Loading) + val uiState: StateFlow = _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() + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/record/RecordScreen.kt b/rippr-src/app/src/main/java/com/rippr/ui/record/RecordScreen.kt new file mode 100644 index 0000000..bf503ec --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/record/RecordScreen.kt @@ -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) + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/record/RecordViewModel.kt b/rippr-src/app/src/main/java/com/rippr/ui/record/RecordViewModel.kt new file mode 100644 index 0000000..9049c0a --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/record/RecordViewModel.kt @@ -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 = + 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() + 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) + } + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/theme/Theme.kt b/rippr-src/app/src/main/java/com/rippr/ui/theme/Theme.kt new file mode 100644 index 0000000..f1d577f --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/theme/Theme.kt @@ -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, + ) + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/trips/TripsScreen.kt b/rippr-src/app/src/main/java/com/rippr/ui/trips/TripsScreen.kt new file mode 100644 index 0000000..013f8bd --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/trips/TripsScreen.kt @@ -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, + ) + } + } +} diff --git a/rippr-src/app/src/main/java/com/rippr/ui/trips/TripsViewModel.kt b/rippr-src/app/src/main/java/com/rippr/ui/trips/TripsViewModel.kt new file mode 100644 index 0000000..d27364c --- /dev/null +++ b/rippr-src/app/src/main/java/com/rippr/ui/trips/TripsViewModel.kt @@ -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 = emptyList(), + val loaded: Boolean = false, + val selection: Set = 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>(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 = + 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() + } +} diff --git a/rippr-src/app/src/main/res/drawable/ic_launcher_foreground.xml b/rippr-src/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..3a58f6b --- /dev/null +++ b/rippr-src/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + diff --git a/rippr-src/app/src/main/res/drawable/ic_launcher_monochrome.xml b/rippr-src/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..06028d6 --- /dev/null +++ b/rippr-src/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/rippr-src/app/src/main/res/drawable/ic_stat_rippr.xml b/rippr-src/app/src/main/res/drawable/ic_stat_rippr.xml new file mode 100644 index 0000000..824c279 --- /dev/null +++ b/rippr-src/app/src/main/res/drawable/ic_stat_rippr.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/rippr-src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/rippr-src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/rippr-src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/rippr-src/app/src/main/res/values/colors.xml b/rippr-src/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..160d5bc --- /dev/null +++ b/rippr-src/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #101418 + diff --git a/rippr-src/app/src/main/res/values/strings.xml b/rippr-src/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1f51567 --- /dev/null +++ b/rippr-src/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Rippr + diff --git a/rippr-src/app/src/main/res/values/themes.xml b/rippr-src/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..3500e50 --- /dev/null +++ b/rippr-src/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/rippr-src/app/src/main/res/xml/backup_rules.xml b/rippr-src/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..1b0854f --- /dev/null +++ b/rippr-src/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,2 @@ + + diff --git a/rippr-src/app/src/main/res/xml/file_paths.xml b/rippr-src/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..ced14e5 --- /dev/null +++ b/rippr-src/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/rippr-src/app/src/test/java/com/rippr/AccumulatorTest.kt b/rippr-src/app/src/test/java/com/rippr/AccumulatorTest.kt new file mode 100644 index 0000000..64561d1 --- /dev/null +++ b/rippr-src/app/src/test/java/com/rippr/AccumulatorTest.kt @@ -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) + } +} diff --git a/rippr-src/app/src/test/java/com/rippr/TelemetryTest.kt b/rippr-src/app/src/test/java/com/rippr/TelemetryTest.kt new file mode 100644 index 0000000..bf94298 --- /dev/null +++ b/rippr-src/app/src/test/java/com/rippr/TelemetryTest.kt @@ -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()) + } +} diff --git a/rippr-src/app/src/test/java/com/rippr/TelemetryUploaderTest.kt b/rippr-src/app/src/test/java/com/rippr/TelemetryUploaderTest.kt new file mode 100644 index 0000000..a206f38 --- /dev/null +++ b/rippr-src/app/src/test/java/com/rippr/TelemetryUploaderTest.kt @@ -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() + 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): List = + points.map { insertPoint(it) } + + override suspend fun forTrip(tripId: Long): List = points.filter { it.tripId == tripId } + override suspend fun forSegment(segmentId: Long): List = + 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 = flowOf(RideStats.EMPTY) + override fun observePendingUpload(tripId: Long): Flow = + 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 = points.toList() + + override suspend fun getUnsyncedPoints(limit: Int): List = + points.filter { !it.synced }.take(limit) + + override suspend fun markSynced(ids: List) { + 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) + } +} diff --git a/rippr-src/app/src/test/java/com/rippr/export/RideExportTest.kt b/rippr-src/app/src/test/java/com/rippr/export/RideExportTest.kt new file mode 100644 index 0000000..80897a9 --- /dev/null +++ b/rippr-src/app/src/test/java/com/rippr/export/RideExportTest.kt @@ -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, 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 "fast" """ + + // Would throw if the name were interpolated unescaped. + val doc = parse(RideExport.gpx(trip, segments, points, nasty)) + + assertEquals(nasty, doc.getElementsByTagName("name").item(0).textContent) + } + + @Test + fun `escaping covers every xml metacharacter`() { + assertEquals( + "&<>"'", + RideExport.escapeXml("""&<>"'"""), + ) + } + + @Test + fun `an empty ride still produces a valid document`() { + val doc = parse(RideExport.gpx(trip, emptyList(), emptyList(), "Empty")) + + assertEquals("gpx", doc.documentElement.tagName) + assertEquals(0, doc.getElementsByTagName("trkpt").length) + assertEquals(0, doc.getElementsByTagName("trkseg").length) + } + + @Test + fun `points whose segment is missing are still exported`() { + val orphan = listOf(point(segmentId = 99, i = 0)) + + val doc = parse(RideExport.gpx(trip, emptyList(), orphan, "Ride")) + + assertEquals("no point may be silently dropped", 1, doc.getElementsByTagName("trkpt").length) + } + + // --- GeoJSON --------------------------------------------------------------- + + @Test + fun `geojson coordinates are longitude first`() { + val (segments, points, _) = ride(segmentCount = 1, perSegment = 1) + + val json = JSONObject(RideExport.geoJson(trip, segments, points, "Ride")) + val coords = json.getJSONArray("features") + .getJSONObject(0) + .getJSONObject("geometry") + .getJSONArray("coordinates") + .getJSONArray(0) + + assertEquals("longitude must come first", -114.0719, coords.getDouble(0), 1e-6) + assertEquals(51.0447, coords.getDouble(1), 1e-6) + assertEquals(1045.0, coords.getDouble(2), 1e-6) + } + + @Test + fun `geojson has one LineString feature per segment`() { + val (segments, points, _) = ride(segmentCount = 3, perSegment = 4) + + val json = JSONObject(RideExport.geoJson(trip, segments, points, "Ride")) + val features = json.getJSONArray("features") + + assertEquals(3, features.length()) + assertEquals("LineString", features.getJSONObject(0).getJSONObject("geometry").getString("type")) + } + + @Test + fun `geojson exports every raw point`() { + val (segments, points, total) = ride(segmentCount = 2, perSegment = 120) + + val json = JSONObject(RideExport.geoJson(trip, segments, points, "Ride")) + val exported = (0 until json.getJSONArray("features").length()).sumOf { i -> + json.getJSONArray("features").getJSONObject(i) + .getJSONObject("geometry").getJSONArray("coordinates").length() + } + + assertEquals(total, exported) + } + + @Test + fun `a hostile name is escaped in json too`() { + val (segments, points, _) = ride(segmentCount = 1, perSegment = 1) + + val json = JSONObject(RideExport.geoJson(trip, segments, points, """He said "go" \ fast""")) + + assertEquals("""He said "go" \ fast""", json.getJSONObject("properties").getString("name")) + } + + @Test + fun `geojson of an empty ride parses`() { + val json = JSONObject(RideExport.geoJson(trip, emptyList(), emptyList(), "Empty")) + assertEquals(0, json.getJSONArray("features").length()) + } +} diff --git a/rippr-src/app/src/test/java/com/rippr/geo/GeoTest.kt b/rippr-src/app/src/test/java/com/rippr/geo/GeoTest.kt new file mode 100644 index 0000000..282c294 --- /dev/null +++ b/rippr-src/app/src/test/java/com/rippr/geo/GeoTest.kt @@ -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) + } +} diff --git a/rippr-src/app/src/test/java/com/rippr/stats/RideStatisticsTest.kt b/rippr-src/app/src/test/java/com/rippr/stats/RideStatisticsTest.kt new file mode 100644 index 0000000..c7016b5 --- /dev/null +++ b/rippr-src/app/src/test/java/com/rippr/stats/RideStatisticsTest.kt @@ -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) + } +} diff --git a/rippr-src/build.gradle.kts b/rippr-src/build.gradle.kts new file mode 100644 index 0000000..0ae4042 --- /dev/null +++ b/rippr-src/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false +} diff --git a/rippr-src/design/build_preview.py b/rippr-src/design/build_preview.py new file mode 100644 index 0000000..cafa0bd --- /dev/null +++ b/rippr-src/design/build_preview.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Builds the logo concept review page, inlining each generated SVG.""" + +import pathlib +import re + +import gen_logos + +HERE = pathlib.Path(__file__).parent +LOGOS = HERE / "logos" + +NOTES = { + "apex": ( + "Strongest speed signal in the set, and the gauge reads as an instrument cluster " + "rather than a generic dial. Verified weakness: the tick marks disappear by 24px " + "and the needle loses its point, so the status-bar version needs the ticks removed." + ), + "route": ( + "Describes what the app records rather than what it rides on — the most specific " + "idea of the three. The accent segment reads as recording progress. " + "Ambiguous at 24px — the trace could be almost anything." + ), + "heading": ( + "Cleanest silhouette of the outlined concepts and the second-best small-size " + "performer. The accent arc marks where the arrow points, so ring and arrow read as " + "one object, and it reduces to a single flat color more cleanly than the other two — which makes it the natural pick for the status-bar icon." + ), +} + +# The status-bar icon must be a flat single-color silhouette; Android discards +# color and alpha nuance. Worth previewing since it rules some concepts out. +MONO_CSS = """ +.mono svg *, .mono svg { fill: #fff !important; stroke: #fff !important; } +""" + + +def inner_svg(key: str) -> str: + raw = (LOGOS / f"rippr-{key}.svg").read_text() + return re.sub(r"^]*>|$", "", raw).strip() + + +def card(letter: str, key: str, name: str, desc: str) -> str: + body = inner_svg(key) + + def mark(size, extra_class="", guide=False): + g = '' if guide else "" + return ( + f'' + f'{body}{g}' + ) + + sizes = "".join( + f'
{mark(n)}
' + f'{n}px
' + for n in (48, 32, 24) + ) + + return f""" +
+
+ {letter} +
+

{name}

+

{desc}

+
+
+ +
{mark(168, guide=True)}
+ +
+
+
{mark(72)}
+ circle mask +
+
+
{mark(72)}
+ squircle +
+
+
{mark(24)}
+ status bar +
+ {sizes} +
+ +

{NOTES[key]}

+
""" + + +def main(): + gen_logos.main() + cards = "".join( + card(chr(65 + i), key, name, desc) + for i, (key, (name, desc, _)) in enumerate(gen_logos.CONCEPTS.items()) + ) + html = TEMPLATE.replace("{{CARDS}}", cards).replace("{{MONO_CSS}}", MONO_CSS) + out = HERE / "logo-review.html" + out.write_text(html) + print(f"wrote {out}") + + +TEMPLATE = """Rippr — logo concepts + + +
+

Rippr · icon direction

+

Three marks for a ride recorder

+

+ Each concept is drawn on Android's 108-unit adaptive-icon canvas, so + whichever you pick converts straight to a vector drawable with no redrawing. The dashed + circle marks the safe zone — anything outside it can be cropped by an OEM mask. The + small tiles are the real test: a launcher icon is seen at 48px and the status-bar icon + is a flat 24px silhouette. +

+ +
{{CARDS}}
+ +
+

What happens after you pick one

+

+ The chosen mark becomes ic_launcher_foreground.xml as a vector drawable, + paired with #101418 as the adaptive background, replacing the placeholder + compass. Its monochrome variant replaces + android.R.drawable.ic_menu_compass in the recording notification. Source + SVGs live in design/logos/; regenerate with + python3 design/gen_logos.py. +

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

Rippr · icon direction

+

Three marks for a ride recorder

+

+ Each concept is drawn on Android's 108-unit adaptive-icon canvas, so + whichever you pick converts straight to a vector drawable with no redrawing. The dashed + circle marks the safe zone — anything outside it can be cropped by an OEM mask. The + small tiles are the real test: a launcher icon is seen at 48px and the status-bar icon + is a flat 24px silhouette. +

+ +
+
+
+ A +
+

Apex

+

Tachometer sweep with redline

+
+
+ +
+ +
+
+
+ circle mask +
+
+
+ squircle +
+
+
+ status bar +
+
48px
32px
24px
+
+ +

Strongest speed signal in the set, and the gauge reads as an instrument cluster rather than a generic dial. Verified weakness: the tick marks disappear by 24px and the needle loses its point, so the status-bar version needs the ticks removed.

+
+
+
+ B +
+

Route

+

Recorded trace inside a ring

+
+
+ +
+ +
+
+
+ circle mask +
+
+
+ squircle +
+
+
+ status bar +
+
48px
32px
24px
+
+ +

Describes what the app records rather than what it rides on — the most specific idea of the three. The accent segment reads as recording progress. Ambiguous at 24px — the trace could be almost anything.

+
+
+
+ C +
+

Heading

+

Navigation arrow with heading arc

+
+
+ +
+ +
+
+
+ circle mask +
+
+
+ squircle +
+
+
+ status bar +
+
48px
32px
24px
+
+ +

Cleanest silhouette of the outlined concepts and the second-best small-size performer. The accent arc marks where the arrow points, so ring and arrow read as one object, and it reduces to a single flat color more cleanly than the other two — which makes it the natural pick for the status-bar icon.

+
+ +
+

What happens after you pick one

+

+ The chosen mark becomes ic_launcher_foreground.xml as a vector drawable, + paired with #101418 as the adaptive background, replacing the placeholder + compass. Its monochrome variant replaces + android.R.drawable.ic_menu_compass in the recording notification. Source + SVGs live in design/logos/; regenerate with + python3 design/gen_logos.py. +

+
+
diff --git a/rippr-src/design/logos/rippr-apex-onbg.svg b/rippr-src/design/logos/rippr-apex-onbg.svg new file mode 100644 index 0000000..2d6b845 --- /dev/null +++ b/rippr-src/design/logos/rippr-apex-onbg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/logos/rippr-apex.svg b/rippr-src/design/logos/rippr-apex.svg new file mode 100644 index 0000000..40baa19 --- /dev/null +++ b/rippr-src/design/logos/rippr-apex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/logos/rippr-heading-onbg.svg b/rippr-src/design/logos/rippr-heading-onbg.svg new file mode 100644 index 0000000..47b73c8 --- /dev/null +++ b/rippr-src/design/logos/rippr-heading-onbg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/logos/rippr-heading.svg b/rippr-src/design/logos/rippr-heading.svg new file mode 100644 index 0000000..a017727 --- /dev/null +++ b/rippr-src/design/logos/rippr-heading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/logos/rippr-route-onbg.svg b/rippr-src/design/logos/rippr-route-onbg.svg new file mode 100644 index 0000000..4998174 --- /dev/null +++ b/rippr-src/design/logos/rippr-route-onbg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/logos/rippr-route.svg b/rippr-src/design/logos/rippr-route.svg new file mode 100644 index 0000000..dbe8521 --- /dev/null +++ b/rippr-src/design/logos/rippr-route.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/material/LICENSE.md b/rippr-src/design/material/LICENSE.md new file mode 100644 index 0000000..c3feafb --- /dev/null +++ b/rippr-src/design/material/LICENSE.md @@ -0,0 +1,7 @@ +# Material Symbols + +Source glyphs fetched from Google Fonts (Material Symbols, Outlined, 24px). +Licensed under the Apache License 2.0 — https://www.apache.org/licenses/LICENSE-2.0 + +These are vendored as reference geometry for the Rippr icon set. Glyph viewBox is +"0 -960 960 960" (x: 0..960, y: -960..0). diff --git a/rippr-src/design/material/explore.svg b/rippr-src/design/material/explore.svg new file mode 100644 index 0000000..bb8ba1f --- /dev/null +++ b/rippr-src/design/material/explore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/material/moving.svg b/rippr-src/design/material/moving.svg new file mode 100644 index 0000000..ab44e3c --- /dev/null +++ b/rippr-src/design/material/moving.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/material/navigation.svg b/rippr-src/design/material/navigation.svg new file mode 100644 index 0000000..eff7d61 --- /dev/null +++ b/rippr-src/design/material/navigation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/material/route.svg b/rippr-src/design/material/route.svg new file mode 100644 index 0000000..76ebd7f --- /dev/null +++ b/rippr-src/design/material/route.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/material/speed.svg b/rippr-src/design/material/speed.svg new file mode 100644 index 0000000..8b28d17 --- /dev/null +++ b/rippr-src/design/material/speed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/material/sports_motorsports.svg b/rippr-src/design/material/sports_motorsports.svg new file mode 100644 index 0000000..3385c6f --- /dev/null +++ b/rippr-src/design/material/sports_motorsports.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/material/two_wheeler.svg b/rippr-src/design/material/two_wheeler.svg new file mode 100644 index 0000000..b6afabe --- /dev/null +++ b/rippr-src/design/material/two_wheeler.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rippr-src/design/to_vector_drawable.py b/rippr-src/design/to_vector_drawable.py new file mode 100644 index 0000000..55eb659 --- /dev/null +++ b/rippr-src/design/to_vector_drawable.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Emits the Android vector drawables for the chosen Route mark. + +This is a real conversion, not a copy: VectorDrawable understands a subset of SVG. +Three things in the source SVG have no direct equivalent and are translated here. + + -> two arc commands, since only exists + stroke-dasharray -> trimPathStart / trimPathEnd, which cut one segment out of + a path; the dash offset becomes the trim start, expressed + as a fraction of the circle's circumference + opacity -> strokeAlpha / fillAlpha + +Run after gen_logos.py; writes straight into app/src/main/res/drawable/. +""" + +import math +import pathlib + +import gen_logos + +HERE = pathlib.Path(__file__).parent +RES = HERE.parent / "app" / "src" / "main" / "res" / "drawable" + +CX = CY = 54.0 + +# The standalone logo draws its ring at r=43, which is outside the adaptive-icon safe +# circle (r=33) — a launcher mask crops it clean off, taking the accent segment with +# it. The launcher build therefore shrinks the whole mark to fit inside the safe zone. +DESIGN_RING_R = 43.0 +RING_R = 32.0 +GLYPH_SIZE = 40.0 + +# Must match concept_route() in gen_logos.py. +DASH_LEN = 34.0 +DASH_OFFSET = 100.0 + +# Trim fractions describe an angular span, so they are derived from the design radius +# and stay correct at any ring size. +DESIGN_CIRCUMFERENCE = 2 * math.pi * DESIGN_RING_R + +# Starts at due east and sweeps clockwise, matching how a browser renders , +# so the trim fractions below land the accent in the same place as the preview. +RING_PATH = ( + f"M{CX + RING_R},{CY} " + f"A{RING_R},{RING_R} 0 1 1 {CX - RING_R},{CY} " + f"A{RING_R},{RING_R} 0 1 1 {CX + RING_R},{CY}" +) + +LAUNCHER = """ + + + + + + + + + + + + + + +""" + +# Status-bar icons are flattened to a single color with only alpha preserved, so the +# ring and accent are dropped — at 24dp they would collapse into a grey smudge. +MONO = """ + + + + + + +""" + +# Themed ("monochrome") launcher icons keep the 108 canvas and are tinted by the +# system, so this one keeps the ring for a fuller shape at launcher size. +THEMED = """ + + + + + + + +""" + + +def main(): + glyph = gen_logos.glyph_path("route") + + launcher_scale = GLYPH_SIZE / gen_logos.GLYPH_BOX + common = dict( + ring=RING_PATH, + glyph=glyph, + dim=gen_logos.DIM, + ink=gen_logos.INK, + accent=gen_logos.ACCENT, + tx=f"{CX - GLYPH_SIZE / 2:g}", + ty=f"{CY + GLYPH_SIZE / 2:g}", + scale=f"{launcher_scale:.6f}", + trim_start=DASH_OFFSET / DESIGN_CIRCUMFERENCE, + trim_end=(DASH_OFFSET + DASH_LEN) / DESIGN_CIRCUMFERENCE, + ) + + RES.mkdir(parents=True, exist_ok=True) + (RES / "ic_launcher_foreground.xml").write_text(LAUNCHER.format(**common)) + (RES / "ic_launcher_monochrome.xml").write_text(THEMED.format(**common)) + + # The status-bar glyph fills a 24dp box edge to edge, minus a little padding. + mono_size = 20.0 + mono_scale = mono_size / gen_logos.GLYPH_BOX + (RES / "ic_stat_rippr.xml").write_text( + MONO.format( + glyph=glyph, + tx=f"{(24 - mono_size) / 2:g}", + ty=f"{(24 + mono_size) / 2:g}", + scale=f"{mono_scale:.6f}", + ) + ) + + for name in ("ic_launcher_foreground", "ic_launcher_monochrome", "ic_stat_rippr"): + print(f"wrote {RES / (name + '.xml')}") + + +if __name__ == "__main__": + main() diff --git a/rippr-src/docs/v2/01-baseline.md b/rippr-src/docs/v2/01-baseline.md new file mode 100644 index 0000000..249a21e --- /dev/null +++ b/rippr-src/docs/v2/01-baseline.md @@ -0,0 +1,76 @@ +# T01 — Repo + dependency baseline + +**Phase** 0 · **Depends on** — · **Status** Done + +## Goal + +Put the project under version control and add every dependency v2 needs, verifying the +build stays green before a single line of feature code changes. Done when `git log` shows +v1 committed, the new libraries resolve, and `assembleDebug testDebugUnitTest lintDebug` +all pass. + +## Context + +- `~/dojo/rippr` is **not currently a git repo**. A multi-phase rewrite with no version + control and no way to diff or revert is reckless — this is the first thing to fix. +- `.gitignore` already exists and covers `build/`, `.gradle/`, `local.properties`, + `.idea/`, `.DS_Store`. +- `gradle/libs.versions.toml` is the single source of dependency truth. Everything goes + through the catalog; no inline coordinates in `app/build.gradle.kts`. +- `app/build.gradle.kts` already carries a `constraints` block forcing + `androidx.fragment:1.8.5` — play-services drags in 1.1.0 transitively and lint rejects + it. Leave that alone. + +## Design + +Three new dependencies, all in the catalog: + +| Library | Why | +|---|---| +| `org.osmdroid:osmdroid-android` | Map rendering (T13/T14). Apache-2.0, no API key | +| `androidx.navigation:navigation-compose` | Three destinations (T08) | +| `androidx.lifecycle:lifecycle-viewmodel-compose` | ViewModels (T08) | + +osmdroid is added now rather than at T13 so any resolution or manifest-merge surprise +surfaces against a known-green build instead of tangled up with map code. + +`local.properties` stays gitignored — it holds a machine-specific `sdk.dir`. + +## Implementation + +1. `git init` in `~/dojo/rippr`, confirm `.gitignore` covers build outputs, and verify + `git status` shows no `build/` or `local.properties` noise. +2. Commit v1 as the baseline — source, design generators, vendored Material glyphs, and + the v2 docs. +3. Add to `[versions]`: `osmdroid`, `navigation`, `lifecycleViewmodel`. +4. Add to `[libraries]`: `osmdroid-android`, `androidx-navigation-compose`, + `androidx-lifecycle-viewmodel-compose`. +5. Wire the three into `app/build.gradle.kts` `dependencies`. +6. Build and test. +7. Commit the dependency bump separately, so a resolution problem is bisectable. + +## Acceptance criteria + +- [ ] `git log` shows at least two commits: v1 baseline, then dependency bump +- [ ] `git status` clean — no build artefacts or `local.properties` tracked +- [ ] All three libraries resolve +- [ ] `./gradlew assembleDebug testDebugUnitTest lintDebug` passes +- [ ] 12/12 existing unit tests still green + +## Tests + +No new tests. This task's job is proving the existing suite still passes with the new +dependency graph — particularly that osmdroid does not introduce a manifest-merge +conflict or a duplicate-class error. + +## Risks / gotchas + +- **osmdroid manifest merge.** It historically declared storage permissions. Modern + versions use app-private cache, but check the merged manifest afterward: + `aapt2 dump xmltree --file AndroidManifest.xml app/build/outputs/apk/debug/app-debug.apk` + and confirm no unexpected permission appeared. +- **Do not commit the APK.** Release artefacts belong in `~/dojo/samplez`, not here. + +## Out of scope + +Any use of the new libraries. This task only proves they resolve. diff --git a/rippr-src/docs/v2/02-schema.md b/rippr-src/docs/v2/02-schema.md new file mode 100644 index 0000000..a54b9c2 --- /dev/null +++ b/rippr-src/docs/v2/02-schema.md @@ -0,0 +1,128 @@ +# T02 — Schema v2 entities + DAOs + +**Phase** 1 · **Depends on** T01 · **Status** Done + +## Goal + +Replace the flat single-table schema with `Trip → Segment → TrackPoint`, split the +monolithic `TrackPoint.kt` into a `data/` package, and add the queries the v2 screens +need. Done when the new schema builds, exports to `app/schemas/com.rippr.data.AppDatabase/2.json`, and the +instrumented DAO tests pass. + +## Context + +`app/src/main/java/com/rippr/TrackPoint.kt` (113 lines) currently holds the entity, the +`RideStats` projection, the DAO, **and** the `AppDatabase` all in one file. That was fine +for one table and is not fine for three. + +Existing details that matter: + +- `AppDatabase` uses `JournalMode.WRITE_AHEAD_LOGGING` — deliberate, keep it. WAL with + NORMAL sync survives app crashes without fsyncing every 2 Hz write. +- `exportSchema = true`, and `app/schemas/com.rippr.AppDatabase/1.json` exists. +- `observeStats()` returns a single-row aggregate with `COALESCE` guards so an empty + table does not null-crash the non-null `RideStats` fields. **That COALESCE lesson + carries over** — the same trap applies to per-trip aggregates. +- The `synced` column drives the upload backlog; it stays. + +## Design + +```kotlin +enum class TripState { RECORDING, PAUSED, COMPLETED } + +@Entity(tableName = "trips") +data class Trip( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val startedAt: Long, + val endedAt: Long? = null, // null ⇒ active + val name: String? = null, // null ⇒ UI derives a name from startedAt + val state: TripState = TripState.RECORDING, + val distanceM: Double = 0.0, + val movingMillis: Long = 0, + val maxSpeedKmh: Float = 0f, + val elevationGainM: Double = 0.0, + val pointCount: Int = 0, +) + +@Entity( + tableName = "segments", + foreignKeys = [ForeignKey(Trip::class, ["id"], ["tripId"], onDelete = CASCADE)], + indices = [Index("tripId")], +) +data class Segment( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val tripId: Long, + val startedAt: Long, + val endedAt: Long? = null, // null ⇒ open +) +``` + +`TrackPoint` gains `tripId` and `segmentId`, both with CASCADE foreign keys and indices. +Deleting a trip therefore removes its segments and points in one statement — which is +exactly what Discard (T06) and Delete (T12) need. + +**Why `state` as well as `endedAt`:** `endedAt == null` distinguishes active from +finished, but cannot distinguish RECORDING from PAUSED. Both are needed — the service +resumes differently depending on which it finds after a process restart. + +**Destructive migration.** v1 data is intentionally discarded, so bump to version 2 with +`fallbackToDestructiveMigration()`. No `Migration` object, no `MigrationTestHelper`. + +> ⚠ This is correct **only** while there is no ride data worth keeping. Once v2 is in +> daily use, a future schema change would silently wipe real rides. **T18 removes it.** + +### New queries + +| Query | Used by | +|---|---| +| `observeActiveTrip(): Flow` — `WHERE endedAt IS NULL` | T03, T09 | +| `observeCompletedTrips(): Flow>` — ordered `startedAt DESC` | T10 | +| `observeTrip(id): Flow` | T11 | +| `pointsForTrip(id): List` — ordered `segmentId, id` | T11, T14, T15 | +| `segmentsForTrip(id): List` | T14, T15 | +| `openSegment(tripId): Segment?` | T06 | +| `deleteTrip(id)` — CASCADE handles the rest | T06, T12 | + +Ordering by `segmentId, id` rather than `timestamp` matters: `timestamp` is GPS time and +can jump, whereas the autoincrement id is genuinely monotonic in write order. + +## Implementation + +1. Create `data/` package; move and split the existing file. +2. Add `Trip`, `Segment`, `TripState` (+ a Room `TypeConverter` for the enum, or store + its `name` as `String`). +3. Add `tripId`/`segmentId` to `TrackPoint` with FKs and indices. +4. Split DAOs: `TripDao`, `SegmentDao`, `TrackPointDao`. +5. Bump `AppDatabase` to `version = 2`, add all three entities, add + `fallbackToDestructiveMigration()` with a comment pointing at T18. +6. Verify CASCADE actually fires in a test rather than assuming — Room does enable + foreign keys for its generated code, but that is worth proving, not trusting. +7. Update `TrackPointDaoTest.kt` for the new shape; add trip/segment tests. + +## Acceptance criteria + +- [x] `app/schemas/com.rippr.data.AppDatabase/2.json` generated (path follows the + package, which moved to `com.rippr.data`; the orphaned v1 dir was removed) +- [x] Deleting a trip cascades to its segments and points +- [x] `observeActiveTrip()` emits null on an empty database, not a crash +- [x] Per-trip aggregate queries return zeros, not nulls, for a trip with no points +- [x] Existing `TelemetryTest` / `TelemetryUploaderTest` untouched and green + +## Tests + +Instrumented (`app/src/androidTest/…`): +- CASCADE delete removes segments and points +- `observeActiveTrip` emits on insert, and emits null once `endedAt` is set +- Points ordered by `segmentId, id` across multiple segments +- Empty-trip aggregates are zero, not null (the COALESCE trap from v1) + +## Risks / gotchas + +- **`fallbackToDestructiveMigration()` silently wipes.** Correct here, dangerous later. +- **Foreign keys.** Room enables them for its generated code; the cascade test + confirms this empirically rather than relying on it. +- **The `synced` column must survive** the refactor or the upload backlog logic breaks. + +## Out of scope + +The repository layer (T03), any service changes (T06), any aggregate computation (T07). diff --git a/rippr-src/docs/v2/03-repository.md b/rippr-src/docs/v2/03-repository.md new file mode 100644 index 0000000..2a2302b --- /dev/null +++ b/rippr-src/docs/v2/03-repository.md @@ -0,0 +1,112 @@ +# T03 — Trip repository + reactive state + +**Phase** 1 · **Depends on** T02 · **Status** Done + +## Goal + +Own trip lifecycle transitions in one place and make recording state a function of the +database rather than an in-memory flag. Done when `RecordingState` is deleted, the UI +observes trip state through a Flow, and killing the app process no longer loses track of +whether a ride is in progress. + +## State as of T02 + +`TripDao`/`SegmentDao` already expose every query this task needs (`observeActive`, `getActive`, `openSegment`, `close`, `setState`, `deleteById`). + +T02 left **temporary scaffolding** that this task and T06 replace: +`TrackingService.openTripAndSegment()` opens a trip and segment inline via the DAOs, and +`@Volatile currentTripId/currentSegmentId` are read by the location callback. The callback +drops fixes while those are 0 so a point can never violate the FK — **preserve that guard**. + +## Context + +v1 keeps state in a process-wide singleton in +`app/src/main/java/com/rippr/Telemetry.kt`: + +```kotlin +object RecordingState { + private val _isRecording = MutableStateFlow(false) + … +} +``` + +This was already an improvement over the Activity-local `isRecording` the original spec +used, but it still lies after process death: `START_STICKY` restarts the service with a +null intent and the flag resets to `false` while a ride is genuinely underway. + +The fix is that the database already knows. A row in `trips` with `endedAt IS NULL` *is* +the fact of an in-progress ride, and it survives anything short of uninstall. + +**Keep the rest of `Telemetry.kt` exactly as it is.** `msToKmh`, `sanitizeSpeedKmh`, +`isUsableFix`, `formatDuration`, and `encodeBatch` are pure, already unit-tested by +`TelemetryTest`, and have no reason to change. Only `RecordingState` goes. + +`RecordingState.lastUploadError` is also used by `TelemetryUploader` and surfaced on the +Record screen — that one is genuinely ephemeral UI state and can stay as a small +`UploadStatus` object, or move onto the repository. Do not silently drop it. + +## Design + +```kotlin +class TripRepository( + private val tripDao: TripDao, + private val segmentDao: SegmentDao, + private val pointDao: TrackPointDao, +) { + fun observeActiveTrip(): Flow + fun observeTrip(id: Long): Flow + fun observeCompletedTrips(): Flow> + + suspend fun startTrip(now: Long): Trip // creates trip + first segment + suspend fun pauseTrip(now: Long) // closes open segment, state=PAUSED + suspend fun resumeTrip(now: Long) // opens new segment, state=RECORDING + suspend fun completeTrip(now: Long) // closes segment, endedAt, COMPLETED + suspend fun discardTrip() // deletes active trip (CASCADE) +} +``` + +Every transition is idempotent and safe to call from an unexpected state — the service +can be restarted by the OS at any moment, so `resumeTrip` on an already-recording trip +must be a no-op rather than opening a duplicate segment. + +**Single instance.** v1 uses a `@Volatile` double-checked singleton for `AppDatabase`; +follow the same pattern for the repository rather than introducing a DI framework. The +app is small and Hilt would be more ceremony than it earns. + +## Implementation + +1. Create `data/TripRepository.kt` with the interface above. +2. Implement each transition as a single Room `@Transaction` so a crash mid-transition + cannot leave a trip with two open segments. +3. Guard each transition on current state; log and no-op on nonsensical transitions. +4. Delete `RecordingState` from `Telemetry.kt`. +5. Relocate upload-error state so the Record screen keeps its indicator. +6. Update `TelemetryUploader` and `TrackingService` references. + +## Acceptance criteria + +- [x] `RecordingState` no longer exists +- [x] `observeActiveTrip()` reflects reality after a simulated process death +- [x] Double `startTrip()` adopts rather than duplicating +- [x] `resumeTrip()` while already RECORDING is a no-op +- [x] Upload-error surfacing still works, now via `UploadStatus` +- [x] `TelemetryTest` still green (the pure functions are untouched) + +## Tests + +Instrumented, against a real in-memory database: +- start → pause → resume → complete produces exactly two segments +- discard removes the trip and all its points +- transitions from wrong states are no-ops +- `observeActiveTrip` emits null after complete + +## Risks / gotchas + +- **Transitions race the writer loop.** The service writes points continuously; pausing + mid-flush must not orphan points into a closed segment. T06 handles ordering — pause + drains the channel *before* closing the segment. +- **Do not delete the pure Telemetry functions.** They are load-bearing and tested. + +## Out of scope + +Wiring transitions to service actions (T06) and aggregate maths (T07). diff --git a/rippr-src/docs/v2/04-geo.md b/rippr-src/docs/v2/04-geo.md new file mode 100644 index 0000000..5f6ec30 --- /dev/null +++ b/rippr-src/docs/v2/04-geo.md @@ -0,0 +1,92 @@ +# T04 — Geo utilities + +**Phase** 2 · **Depends on** T01 · **Status** Done + +## Goal + +Pure geographic maths with no Android dependencies: distance between fixes, polyline +simplification for rendering, and bounding boxes for map auto-fit. Done when +`geo/Geo.kt` exists, imports nothing from `android.*`, and is covered by JVM unit tests +that run without a device. + +## Context + +This is the pattern `Telemetry.kt` already establishes and the reason its logic is +cheaply testable — the conversion and sanitisation functions there are covered by +`TelemetryTest` with no emulator involved. `Geo` follows the same rule. + +Three consumers depend on this task: T05 (statistics), T14 (path rendering), T15 +(export). It is on nobody's blocking path from the UI side, so it can be built in +parallel with the whole data-layer track. + +## Design + +```kotlin +object Geo { + const val EARTH_RADIUS_M = 6_371_008.8 // IUGG mean radius + + fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double + + /** Douglas–Peucker. Always preserves first and last points. */ + fun simplify(points: List, epsilonMeters: Double): List + + fun bounds(points: List): Bounds? // null for empty input +} + +data class LatLon(val lat: Double, val lon: Double) +data class Bounds(val minLat: Double, val minLon: Double, + val maxLat: Double, val maxLon: Double) +``` + +**Haversine, not Vincenty.** Haversine assumes a sphere and is accurate to roughly 0.5% +— a few metres per kilometre. For motorcycle ride distances that is far below GPS noise, +and Vincenty's iterative solution would be false precision at real cost. + +**Douglas–Peucker with a metre epsilon**, not a degree epsilon. A degree of longitude is +~111 km at the equator and ~0 at the poles, so a degree-based tolerance behaves +differently depending where you ride. Perpendicular distance is computed with a local +equirectangular approximation — valid over the short spans between consecutive fixes and +far cheaper than a full geodesic. + +**Recursion depth.** A naive recursive Douglas–Peucker on 20,000+ points can blow the +stack in a pathological case. Implement iteratively with an explicit work stack. + +## Implementation + +1. Create `geo/Geo.kt` with `LatLon`, `Bounds`, and the three functions. +2. Haversine in double precision throughout — float loses metres over a long ride. +3. Iterative Douglas–Peucker with an explicit stack. +4. `bounds()` returns null on empty rather than a degenerate zero box, so callers must + handle "no path" explicitly instead of silently centring on Null Island. +5. Add an extension to map `TrackPoint` → `LatLon` so callers stay tidy. + +## Acceptance criteria + +- [x] No `android.*` imports in `geo/` +- [x] Haversine matches known reference distances within 0.5% +- [x] `simplify()` always returns first and last points unchanged +- [x] `simplify()` with epsilon 0 returns the input unchanged +- [x] `simplify()` on 25,000 synthetic points completes quickly and does not stack-overflow +- [x] `bounds()` returns null for an empty list + +## Tests + +JVM unit tests (`app/src/test/…`), no device needed: +- Known pairs: Calgary→Edmonton ≈ 280.9 km (great-circle, not road); a 1° latitude step ≈ 111.2 km; identical points → 0 +- Antimeridian and equator crossings behave sanely +- Douglas–Peucker: a straight line collapses to two points; a zigzag above epsilon is + preserved; endpoints always survive +- 25,000-point performance and stack-safety check +- Bounds across a mixed-sign coordinate set + +## Risks / gotchas + +- **Float vs double.** `TrackPoint.latitude/longitude` are already `Double`; keep them + that way through every calculation. `speedKmh` is `Float`, which is fine — it is + display data, not accumulated. +- **Epsilon tuning is a T14 concern**, not this one. Expose it as a parameter and let the + render layer choose. + +## Out of scope + +Anything that consumes these functions — statistics (T05), rendering (T14), export (T15). diff --git a/rippr-src/docs/v2/05-stats.md b/rippr-src/docs/v2/05-stats.md new file mode 100644 index 0000000..6b14cf3 --- /dev/null +++ b/rippr-src/docs/v2/05-stats.md @@ -0,0 +1,126 @@ +# T05 — Ride statistics + +**Phase** 2 · **Depends on** T04 · **Status** Done + +## Goal + +Derive every number the trip detail screen shows from a list of points, as pure +functions. Done when distance, moving time, elevation gain, speed histogram, and +elevation profile are computed and unit-tested with no device. + +## Context + +v1 shows max speed, point count, and a naive `lastTimestamp - firstTimestamp` duration — +computed in SQL by `TrackPointDao.observeStats()`. That query cannot survive into v2 for +the derived values, because **minSdk 26 means SQLite 3.18, which has no window +functions**, and distance/elevation both need consecutive-row differences. + +So these move to Kotlin. T07 accumulates them live during recording; this task provides +the authoritative batch computation used on trip completion and on the detail screen. + +Reuse from `Telemetry.kt`: +- `SPEED_NOISE_FLOOR_KMH` (1.5) — the moving/stopped threshold, so "moving" means the + same thing here as it does to the recorder +- `formatDuration` — already tested, used for display + +## Available from T04 + +`com.rippr.geo.Geo` provides `haversineMeters` (both `Double` and `LatLon` overloads), +`simplify`, `perpendicularDistanceMeters`, `bounds`, and `pathLengthMeters`. `LatLon`, +`Bounds` (with `isDegenerate` for the stationary-ride case) and a `TrackPoint.toLatLon()` +extension are there too. All Android-free and unit-tested. + +Use `Geo.pathLengthMeters` per segment rather than summing across the whole ride — it has +no notion of pause boundaries and will happily span them. + +## Design + +```kotlin +object RideStatistics { + fun compute(points: List, segments: List): RideSummary +} + +data class RideSummary( + val distanceM: Double, + val elapsedMillis: Long, // wall clock, first fix to last + val movingMillis: Long, // time above the speed noise floor + val maxSpeedKmh: Float, + val avgMovingSpeedKmh: Float, // distance / movingMillis, not the mean of samples + val elevationGainM: Double, + val elevationLossM: Double, + val pointCount: Int, +) +``` + +**Distance never crosses a segment boundary.** Points either side of a pause may be +kilometres apart; summing across the gap would invent distance the rider never covered. +Accumulate per segment and total. + +**Elevation gain needs two mechanisms, not one.** A threshold alone is not enough — +measured, not assumed: a parked bike with ±8 m noise reported **1498 m** of climbing with +simple thresholding, because noise crosses any small threshold constantly. The shipped +implementation combines a 15-sample moving average (cuts noise by ~sqrt(window)) with +**reversal** hysteresis (a climb banks only once altitude turns back down by more than +3 m from its peak). That brings the same fixture to ~30 m. + +The moving average lags the true altitude by about half a window, which clipped a 100 m +climb to 93 m, so `finish()` reconciles the final run against the last raw reading. + +**Average speed is distance ÷ moving time**, not the arithmetic mean of `speedKmh` +samples. The mean of samples over-weights the time spent stopped and under-reports the +real pace. + +**Speed histogram and elevation profile** are series for T11's charts: + +```kotlin +fun speedHistogram(points: List, bucketKmh: Int = 10): List +fun elevationProfile(points: List, maxSamples: Int = 200): List +``` + +The profile is downsampled by distance-along-path, not by index, so a stretch where the +bike sat idle at 2 Hz does not dominate the chart. + +## Implementation + +1. Create `stats/RideStatistics.kt`. +2. `compute()` walks points grouped by `segmentId`, using `Geo.haversineMeters`. +3. Moving time accumulates `dt` only where `speedKmh >= SPEED_NOISE_FLOOR_KMH`; cap any + single `dt` (say 10 s) so a GPS dropout does not inject phantom moving time. +4. Elevation gain/loss with the 3 m hysteresis state machine. +5. Histogram and profile helpers. +6. Unit tests throughout. + +## Acceptance criteria + +- [x] Distance excludes inter-segment gaps +- [x] A stationary point cloud with ±8 m altitude noise yields ~0 m elevation gain +- [x] Moving time excludes stopped periods +- [x] Average speed uses moving time, not elapsed +- [x] Empty input returns a zeroed summary, never a crash or NaN +- [x] Single-point input returns zero distance, not NaN + +## Tests + +JVM unit tests: +- Synthetic straight-line ride: distance matches Haversine within rounding +- Two segments separated by a large jump: distance excludes the gap +- Stationary noisy-altitude fixture: elevation gain ≈ 0 (the regression test that + matters most) +- A genuine 100 m climb registers ~100 m +- Half-stopped ride: moving time ≈ half of elapsed +- Empty and single-point inputs +- `avgMovingSpeedKmh` on a known distance and duration + +## Risks / gotchas + +- **NaN propagation.** Division by zero moving time must return 0, not NaN — a NaN + reaching Compose renders as literal "NaN" on screen. +- **`dt` capping matters.** Without it, a two-minute tunnel dropout counts as two minutes + of moving time at the last known speed. +- **Keep this in sync with T07.** The live accumulator and this batch computation must + agree, or the number changes when a ride finishes. T07 recomputes with *this* code on + completion, which keeps them consistent by construction. + +## Out of scope + +Charts and rendering (T11); live accumulation during recording (T07). diff --git a/rippr-src/docs/v2/06-service-lifecycle.md b/rippr-src/docs/v2/06-service-lifecycle.md new file mode 100644 index 0000000..882d8b6 --- /dev/null +++ b/rippr-src/docs/v2/06-service-lifecycle.md @@ -0,0 +1,146 @@ +# T06 — Trip lifecycle in TrackingService + +**Phase** 3 · **Depends on** T03 · **Status** Done + +## Goal + +Teach the foreground service the full ride lifecycle — start, pause, resume, stop, +discard — with segments opening and closing correctly and the notification reflecting +actual state. Done when a paused ride keeps its notification, stops consuming GPS, and +resumes into a *new* segment. + +## Context + +`app/src/main/java/com/rippr/TrackingService.kt` (265 lines) already gets the hard parts +right, and **none of this architecture should be disturbed**: + +- Location callbacks `trySend` into an **unbounded `Channel`**; a single writer coroutine + drains it in batches of 25 every 2 s. This is why slow disk I/O can never block the GPS + thread, and why no fix is dropped under back pressure. +- `onDestroy` drains whatever remains on a scope that *outlives* `serviceScope`, so the + final points still land. Verified on the emulator: 81 points, ids 1..81 contiguous. +- A `PARTIAL_WAKE_LOCK` keeps the writer running with the screen off — a foreground + service alone does not guarantee this on all OEMs. +- `startForegroundCompat()` runs before anything else, because Android allows roughly + five seconds from `startForegroundService()` before killing the process. + +What changes is lifecycle, not plumbing. + +### State after T03 + +The repository is already wired in: the service holds `TripRepository`, start calls +`trips.startTrip()`, and `stopRecording()` calls `trips.completeTrip()` on a survivor +scope. `@Volatile currentTripId`/`currentSegmentId` remain (the location callback runs on +another thread) and are now populated from the returned `TripHandle`. + +Still to do here: +- The five actions; today there is only start and `ACTION_STOP` +- `drainChannel()` and drain-before-close ordering +- Pause/resume, wake-lock release on pause, per-state notification +- The null-intent restart path (`trips.activeTrip()` already provides what it needs) +- The `tripId == 0L` guard in the location callback — **keep it** + +**Note on force-stop.** A user-initiated `am force-stop` is more aggressive than an OS +kill: Android will not honour START_STICKY afterwards. The database state stays correct +and the UI reads it correctly, but recording does not auto-resume. The restart path here +covers OS-initiated kills, which is the case that matters on a long ride. + +## Design + +### Actions + +| Action | Behaviour | +|---|---| +| `ACTION_START` | `repo.startTrip()`, acquire wake lock, request location updates | +| `ACTION_PAUSE` | **Drain channel first**, close segment, `removeLocationUpdates`, release wake lock, keep service alive | +| `ACTION_RESUME` | `repo.resumeTrip()` (new segment), re-acquire wake lock, re-request updates | +| `ACTION_STOP` | Drain, close segment, `completeTrip()`, recompute aggregates (T07), stop service | +| `ACTION_DISCARD` | Drain and discard buffered points, `discardTrip()` (CASCADE), stop service | + +**Correction to the original reasoning.** This doc previously claimed that closing a +segment before draining would write points into the *wrong* segment. That is wrong: +`segmentId` is stamped onto each `TrackPoint` at creation in the location callback, so a +fix already queued always lands in the segment it was recorded during, whatever order the +writes happen in. + +Draining before closing still matters, just for different reasons: points should not sit +unwritten while a ride idles at a pause, and stop/discard must not lose the tail. The +order used is stop-the-source → drain → close. + +### Pause keeps the service, drops the wake lock + +The foreground service stays alive so the notification persists and resuming is instant. +But with no location updates arriving there is nothing for the writer to do, so the wake +lock is released — a paused ride at a long lunch stop should not hold the CPU awake. + +### Notification states + +| State | Title | Text | Actions | +|---|---|---|---| +| Recording | Rippr Active | Recording ride telemetry… | Pause, Stop | +| Paused | Rippr Paused | Ride paused — not recording | Resume, Stop | + +`NotificationCompat.Builder` is rebuilt and re-notified on transition; the channel and id +(101) stay as they are. The small icon stays `R.drawable.ic_stat_rippr`. + +### Restart after process death + +`START_STICKY` redelivers a null intent. On a null intent the service asks the repository +for the active trip: + +- **RECORDING** → resume recording into a new segment (the gap is real; the phone was off) +- **PAUSED** → restore the paused notification, do not request location +- **none** → `stopSelf()` + +This is only possible because T03 moved state into the database. + +## Implementation + +1. Replace the single `ACTION_STOP` constant with the five actions. +2. Inject `TripRepository`; drop all `RecordingState` references. +3. Add `drainChannel()` — a suspend function that empties the channel into the DB and + returns once flushed; reuse it from pause, stop, discard, and `onDestroy`. +4. Split wake lock acquire/release out of `onStartCommand` so pause/resume can call them. +5. Rebuild the notification per state; add `buildNotification(state: TripState)`. +6. Handle the null-intent restart path. +7. `stopWithTask="false"` in the manifest already survives task swipe — keep it. + +## Acceptance criteria + +- [x] Start → pause → resume → stop produces exactly two segments +- [x] No point is written into a closed segment +- [x] Paused: notification persists, no location updates, wake lock released +- [x] Discard deletes the trip and every one of its points +- [x] Force-stop then restart with an active RECORDING trip resumes into a new segment +- [x] Contiguous point ids across a pause (no gaps, no duplicates) + +## Tests + +Instrumented plus emulator end-to-end: +- Feed fixes, pause, feed more, resume, feed more, stop → assert two segments with the + right point counts either side +- Assert no point has a `segmentId` whose segment closed before its timestamp +- Discard leaves zero rows + +Emulator harness (proven in v1): +``` +adb shell am start-foreground-service -n com.rippr/.TrackingService # blocked: not exported +``` +Drive through the UI instead — `adb shell input tap`, after waiting for the UI to +actually exist. A fixed `sleep` is not enough; the activity took 8.7 s to display on a +cold start during v1 testing and a premature tap silently does nothing. + +## Risks / gotchas + +- **Do not replace the Channel with a direct write.** It is the reason the GPS callback + never blocks. +- **Drain ordering** as described above; get this wrong and points land in the wrong + segment, which only shows up later as a rendering artefact. +- **Wake lock double-release** throws. `setReferenceCounted(false)` is already set; still + guard with `isHeld`. +- **`onDestroy` already spawns a survivor scope** for the final flush — do not remove it + while refactoring. + +## Out of scope + +Aggregate maths (T07) and any UI (T09). diff --git a/rippr-src/docs/v2/07-aggregates.md b/rippr-src/docs/v2/07-aggregates.md new file mode 100644 index 0000000..2aa9819 --- /dev/null +++ b/rippr-src/docs/v2/07-aggregates.md @@ -0,0 +1,113 @@ +# T07 — Live aggregate accumulation + +**Phase** 3 · **Depends on** T04, T06 · **Status** Done + +## Goal + +Keep distance, moving time, elevation gain, and max speed current on the active `Trip` +row while recording, so the Record screen shows live numbers without rescanning the +point table. Done when those values update during a ride and are reconciled exactly on +completion. + +## Context + +v1's `observeStats()` recomputes aggregates in SQL on every emission. That worked for +`MAX`, `AVG`, and `COUNT` over one table, but v2 needs **distance** and **elevation +gain**, which require consecutive-row differences. + +**SQLite 3.18 on minSdk 26 has no window functions** — no `LAG`, no `OVER`. There is no +SQL expression for "distance from the previous point". So the maths moves into Kotlin, +into the writer loop that is already touching every point as it lands. + +`TrackingService` already batches writes every ~2 s (`FLUSH_INTERVAL_MS = 2000`, +`FLUSH_SIZE = 25`), so one extra `UPDATE trips …` per flush is negligible — roughly one +write every two seconds against a table with a single active row. + +## Design + +The writer loop keeps the **last point of the previous batch** in memory as the anchor +for cross-batch distance. Without it, distance resets at every flush boundary and +under-reports by roughly one inter-point hop per 25 points. + +**Reuse `ElevationAccumulator` from T05 verbatim.** It is already streaming (fed one +altitude at a time) precisely so the recorder can share it. Do **not** reimplement the +hysteresis here — a naive version measured 1498 m of phantom climbing on a parked bike. +Remember to call `finish()` before persisting final values. + +Implemented as `com.rippr.Accumulator` in `RideAccumulator.kt` rather than a private +class inside the service, so it is unit-testable without a device. + +```kotlin +private data class Accumulator( + var lastPoint: TrackPoint? = null, + var distanceM: Double = 0.0, + var movingMillis: Long = 0, + var maxSpeedKmh: Float = 0f, + var elevationGainM: Double = 0.0, + var climbBuffer: Double = 0.0, // hysteresis state, see T05 + var pointCount: Int = 0, +) +``` + +Rules, matching T05 exactly: +- Distance accumulates only **within** a segment. On resume, `lastPoint` resets to null + so the pause gap contributes nothing. +- Moving time accumulates `dt` only where `speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH`, + with `dt` capped (10 s) so a tunnel dropout cannot inject phantom movement. +- Elevation gain uses the same 3 m hysteresis. + +### Reconciliation on completion + +Incremental accumulation can drift — a process kill mid-ride loses the in-memory +accumulator, and the restarted service resumes from zero while the DB row holds a stale +partial. So on `ACTION_STOP`, after the final drain, **recompute authoritatively** with +`RideStatistics.compute()` over all stored points and overwrite the row. + +That is why T05 and T07 must agree: the live number is an estimate, the finished number +is computed by T05's code. Using the same functions for both keeps them consistent by +construction rather than by discipline. + +## Implementation + +1. Add `Accumulator` to `TrackingService`, owned by the writer coroutine. +2. After each batch insert, fold the batch into the accumulator and `UPDATE` the trip row + in the same transaction as the point insert — so a crash cannot commit points without + their aggregate contribution. +3. Reset `lastPoint` on segment change (pause/resume). +4. On restart-with-active-trip, seed the accumulator from the persisted trip row and set + `lastPoint` to the last stored point of the open segment. +5. On stop, recompute with `RideStatistics.compute()` and overwrite. + +## Acceptance criteria + +- [x] Distance updates live during recording +- [x] Distance does not jump across a pause +- [x] Distance is correct across flush boundaries (no per-batch reset) +- [x] Stationary noisy-altitude recording yields ~0 m elevation gain +- [x] Post-stop values exactly match `RideStatistics.compute()` over the stored points +- [x] Restart mid-ride resumes accumulation instead of restarting from zero + +## Tests + +Instrumented: +- Insert 100 synthetic points across four flushes; assert accumulated distance equals the + batch computation over the same points (the cross-batch anchor regression test) +- Pause/resume with a 5 km jump between segments; assert the gap is excluded +- Simulate restart: seed accumulator from DB, continue, assert continuity + +Emulator: feed a synthetic ride and compare the live displayed distance against the +post-stop recomputation — they should differ by nothing. + +## Risks / gotchas + +- **The cross-batch anchor is the subtle bug here.** Losing `lastPoint` between flushes + silently under-reports distance by a few percent, which is exactly the kind of error + nobody notices until they compare against a bike odometer. +- **Same transaction as the insert.** Points and their aggregate contribution must commit + atomically or a crash leaves them disagreeing. +- **Do not let the accumulator's definitions drift from T05.** Share constants; do not + copy magic numbers. + +## Out of scope + +Displaying any of this (T09, T11). diff --git a/rippr-src/docs/v2/08-nav-shell.md b/rippr-src/docs/v2/08-nav-shell.md new file mode 100644 index 0000000..9e97d7e --- /dev/null +++ b/rippr-src/docs/v2/08-nav-shell.md @@ -0,0 +1,98 @@ +# T08 — Navigation shell + ViewModels + +**Phase** 4 · **Depends on** T01 · **Status** Done + +## Goal + +Three navigable destinations, theme extracted out of the Activity, and state moved into +ViewModels. Done when Record, Trips, and Trip Detail are reachable with a working back +stack and `MainActivity` no longer owns screen state. + +## Context + +`app/src/main/java/com/rippr/MainActivity.kt` (216 lines) currently does everything: +permission launcher, service intents, theme definition, screen composition, and state. +That was proportionate for one screen and will not survive three. + +Existing pieces to preserve rather than rewrite: +- The permission flow (`RequestMultiplePermissions`, with `POST_NOTIFICATIONS` added only + on TIRAMISU+) works and should move largely as-is. +- The battery-optimisation exemption prompt is genuinely useful for an app that must + survive a multi-hour ride; keep it on the Record screen. +- `RipprTheme` is currently a private composable applying `darkColorScheme()`. Dylan + called the UI "simple and clean" and explicitly likes it — **do not redesign it**. + Extract, do not restyle. +- `StatRow` and `BigStat` are reusable; move them into a shared `ui/components/`. + +## Design + +``` +ui/ + theme/ Theme.kt, Color.kt + components/ StatRow.kt, BigStat.kt, ConfirmDialog.kt + record/ RecordScreen.kt, RecordViewModel.kt + trips/ TripsScreen.kt, TripsViewModel.kt + detail/ TripDetailScreen.kt, TripDetailViewModel.kt + RipprNavHost.kt +``` + +Routes: + +| Route | Screen | +|---|---| +| `record` | Start destination | +| `trips` | Trip list | +| `trip/{tripId}` | Detail, `tripId: Long` argument | + +`navigation-compose` rather than hand-rolled state switching: the back stack from detail +→ list is worth not writing by hand, and it gives correct behaviour on the system back +gesture for free. + +**ViewModels** get the repository through a simple factory — the app already uses a +`@Volatile` singleton for `AppDatabase` and T03 adds one for `TripRepository`. Hilt would +be more ceremony than a three-screen app earns. + +**Navigation from Record → Trips** goes in a top bar action, keeping the Record screen's +centre free for the numeric readout. + +## Implementation + +1. Extract `ui/theme/` from `MainActivity`. +2. Move `StatRow` / `BigStat` into `ui/components/`, unchanged. +3. Create `RipprNavHost` with the three routes. +4. Add a ViewModel per screen with a shared factory. +5. Reduce `MainActivity` to: permissions, battery exemption, `setContent { RipprTheme { RipprNavHost() } }`. +6. Move service-intent dispatch into `RecordViewModel`. + +## Acceptance criteria + +- [x] All three destinations reachable; system back works from detail +- [x] `MainActivity` holds no screen state +- [x] Visual appearance of the Record screen is unchanged +- [x] Permission flow and battery-optimisation prompt still function +- [ ] Config change (rotation) does not lose state + +> **Not verified.** The unchecked items above, and the Compose UI tests below, were +> not done. Verification for T08–T11 was manual on the emulator (screenshots through +> the full flow) plus the existing unit and instrumented suites. Compose UI tests are +> outstanding — tracked in T18. + +## Tests + +Instrumented Compose UI tests: +- Navigate record → trips → detail → back → back +- Rotation preserves screen state +- Existing suites stay green + +## Risks / gotchas + +- **Do not restyle.** The brief was "simple and clean, I like that". This task is + structural only. +- **`tripId` argument type.** Room ids are `Long`; declare `NavType.LongType` explicitly + or it silently arrives as a String. +- The v1 UI reads state via `collectAsStateWithLifecycle` already — keep that, it is the + correct choice over `collectAsState` for lifecycle-aware collection. + +## Out of scope + +Screen content beyond wiring (T09, T10, T11); any map (T13). diff --git a/rippr-src/docs/v2/09-record-screen.md b/rippr-src/docs/v2/09-record-screen.md new file mode 100644 index 0000000..1ed3147 --- /dev/null +++ b/rippr-src/docs/v2/09-record-screen.md @@ -0,0 +1,114 @@ +# T09 — Record screen v2 + +**Phase** 4 · **Depends on** T06, T08 · **Status** Done + +## Goal + +Add Pause/Resume, Stop, and Discard to the recording screen while keeping the clean +numeric readout intact, and show per-trip rather than lifetime stats. Done when a ride +can be fully controlled from the screen and the numbers reflect the current trip only. + +## Context + +Direct feedback: *"app is simple and clean. I like that but it's missing stuff."* The +missing stuff is controls, not decoration. **Resist adding visual weight.** + +v1's screen shows `MAX SPEED` as a large monospace figure, then a card with points +captured, avg speed, duration, and pending upload, then one full-width button. That +layout stays; it gains controls and loses its lifetime scope. + +The current single button toggles start/stop via `RecordingState.isRecording`. v2 has +three states and needs a different control arrangement. + +## Design + +### Control layout by state + +| State | Primary | Secondary | +|---|---|---| +| Idle | **START RECORDING** (full width) | — | + +Service actions to dispatch: `TrackingService.ACTION_START` / `ACTION_PAUSE` / +`ACTION_RESUME` / `ACTION_STOP` / `ACTION_DISCARD`. Only START uses +`startForegroundService`; the rest are plain `startService` on the running service. +| Recording | **PAUSE** | STOP | +| Paused | **RESUME** | STOP · DISCARD | + +Discard appears **only when paused** — deliberately. Offering a destructive action next +to Pause during an active ride invites a gloved mis-tap at 100 km/h. Pausing first is a +natural speed bump. + +Discard shows a confirmation dialog stating what is lost ("Delete this ride? N points +recorded over X km will be permanently deleted."). Stop and Pause need no confirmation. + +### Replacing T02 scaffolding + +`MainActivity` derives stats with `flatMapLatest` over `TripRepository.observeActiveTrip()` +into `observeTripStats(tripId)`, and `isRecording` from `observeActiveTrip().map { it != null }`. +Upload errors come from the `UploadStatus` object. All of that moves into `RecordViewModel`, +and once T07 lands the screen reads persisted aggregates off the `Trip` row rather than +recomputing in SQL. + +`onToggleClicked` is now a `lifecycleScope.launch` that queries `trips.activeTrip()` — there +is no synchronous recording flag any more. The ViewModel should expose state instead so the +screen never has to suspend to decide what a button does. + +### Stats become per-trip + +Driven by `observeActiveTrip()` from T03, showing the live aggregates from T07: + +- Max speed (the big figure — it is the number Dylan cares about) +- Distance — **new in v2**, read straight off `Trip.distanceM` (no SQL recomputation) +- Duration, moving time +- Points captured +- Pending upload (only when non-zero, and only if an endpoint is configured) + +When idle, the screen shows a resting state rather than stale numbers from the last ride. + +## Implementation + +1. `RecordViewModel` exposes `RecordUiState` derived from `observeActiveTrip()`. +2. Map `TripState` to the control layout above. +3. Dispatch service intents for each action. +4. Add `ConfirmDialog` for Discard. +5. Reuse `Telemetry.formatDuration` for both duration fields. +6. Keep the battery-optimisation prompt. +7. Add a top-bar action to reach the Trips list. + +## Acceptance criteria + +- [x] All five transitions work from the UI +- [x] Discard is unavailable while actively recording +- [x] Discard confirms, and states what will be lost +- [ ] Stats reset between trips — no bleed from the previous ride +- [x] Distance appears and increases during a ride +- [x] Idle state is visibly distinct from a zeroed ride +- [ ] Screen still reads at a glance; no added clutter + +> **Not verified.** The unchecked items above, and the Compose UI tests below, were +> not done. Verification for T08–T11 was manual on the emulator (screenshots through +> the full flow) plus the existing unit and instrumented suites. Compose UI tests are +> outstanding — tracked in T18. + +## Tests + +Compose UI tests: +- Each state renders the right controls +- Discard dialog appears, cancel is non-destructive +- State survives rotation mid-ride + +Emulator end-to-end: drive the full lifecycle through taps, then verify the database +matches what the screen claimed. + +## Risks / gotchas + +- **Tap targets.** These get used in gloves. Keep the 64–72dp button height v1 uses. +- **Wait for the UI before tapping in tests.** v1's cold start took 8.7 s; a fixed sleep + produced a silently-missed tap and a false "service didn't start" conclusion. Poll + `uiautomator dump` for the button text instead. +- **Do not show "Pending upload" when no endpoint is configured** — it reads as an error + when it is just an unused feature. + +## Out of scope + +Trips list (T10), any map, live path preview. diff --git a/rippr-src/docs/v2/10-trips-list.md b/rippr-src/docs/v2/10-trips-list.md new file mode 100644 index 0000000..6879ff9 --- /dev/null +++ b/rippr-src/docs/v2/10-trips-list.md @@ -0,0 +1,89 @@ +# T10 — Trips list screen + +**Phase** 4 · **Depends on** T02, T08 · **Status** Done + +## Goal + +A scrollable list of completed rides, each showing enough to identify it, tapping through +to detail. Done when past rides are browsable and the list updates reactively as rides +complete. + +## Context + +There is currently no way to see a past ride at all — v1 has one screen showing lifetime +totals. This is the first place trips become visible as objects. + +`TripDao.observeCompletedTrips()` from T02 provides the data, ordered `startedAt DESC`. +All displayed values are already persisted on the `Trip` row by T07, so **this screen +runs no computation** — no loading points, no statistics. That keeps it fast with +hundreds of rides. + +## Design + +One row per trip: + +``` +Sat 9 Aug · 14:32 47.2 km +1h 12m moving · max 118 km/h ▸ +``` + +- Name if set, otherwise a date-derived label ("Sat 9 Aug · 14:32") +- Distance as the right-aligned prominent figure +- Moving time and max speed as the secondary line +- Monospace with `tabular-nums` for the figures so columns align down the list + +**Empty state** matters here — a new install has no trips, and an empty screen with no +explanation reads as broken. Show a short line pointing at the Record tab. + +**An in-progress trip is excluded** (`endedAt IS NULL`). A half-finished ride in the +history list with a growing distance would be confusing; the Record screen owns that. + +Grouping by month is tempting but premature. Revisit when there are enough rides to +justify it. + +## Implementation + +1. `TripsViewModel` exposing `observeCompletedTrips()` as state. +2. `LazyColumn` of `TripRow`. +3. Date formatting via `java.time` with the device locale and zone — **not** hardcoded + patterns. Ride timestamps come from `Location.time`, which is UTC epoch millis. +4. Reuse `Telemetry.formatDuration`. +5. Distance formatting helper — km with one decimal; metres below 1 km. +6. Tap navigates to `trip/{tripId}`. +7. Empty state. + +## Acceptance criteria + +- [x] Completed trips listed newest first +- [x] Active trip excluded +- [x] Empty state shown on a fresh install +- [x] Tap opens the right trip +- [ ] List updates when a ride completes, without manual refresh +- [x] No point-table reads — the screen uses only `Trip` rows + +> **Not verified.** The unchecked items above, and the Compose UI tests below, were +> not done. Verification for T08–T11 was manual on the emulator (screenshots through +> the full flow) plus the existing unit and instrumented suites. Compose UI tests are +> outstanding — tracked in T18. + +## Tests + +Compose UI tests: +- Empty state on no data +- Rows render in the right order +- Tap emits the right navigation event +- Active trip absent from the list + +Instrumented DAO test for ordering and the `endedAt IS NULL` exclusion. + +## Risks / gotchas + +- **Timezone.** `Location.time` is UTC. Format in the device zone or a ride at 21:00 + local shows as tomorrow. +- **Do not compute stats here.** If a value is missing from the `Trip` row, fix T07 + rather than loading points in the list — that path leads to a screen that janks after + fifty rides. + +## Out of scope + +Rename/delete/merge (T12); detail content (T11). diff --git a/rippr-src/docs/v2/11-trip-detail.md b/rippr-src/docs/v2/11-trip-detail.md new file mode 100644 index 0000000..2615e92 --- /dev/null +++ b/rippr-src/docs/v2/11-trip-detail.md @@ -0,0 +1,86 @@ +# T11 — Trip detail screen (stats only) + +**Phase** 4 · **Depends on** T05, T08 · **Status** Done + +## Goal + +A full breakdown of a single ride — summary figures, elevation profile, speed +distribution — with a deliberate placeholder where the map will go. Done when every +statistic renders correctly and the screen is verifiable before osmdroid lands. + +## Context + +Splitting this from the map is intentional. osmdroid brings tile caching, `MapView` +lifecycle, and coordinate projection — three sources of subtle failure. Building the +screen first means that when the map misbehaves in T13, the surrounding screen is already +known-good and the bug has nowhere to hide. + +Data comes from `RideStatistics.compute()` (T05) over `pointsForTrip(id)` and +`segmentsForTrip(id)`. Unlike the list screen, this one **does** load points — a few +thousand rows for one trip is fine, and the derived series need them. + +## Design + +Sections top to bottom: + +1. **Header** — name (inline-editable in T12) and start date/time +2. **Map placeholder** — a bordered box captioned "Map coming in T13", replaced wholesale + by T13/T14. Reserving the space now means the layout does not shift later. +3. **Summary grid** — distance, moving time, elapsed time, max speed, average moving + speed, elevation gain +4. **Elevation profile** — line chart over distance-along-path +5. **Speed distribution** — histogram in 10 km/h buckets +6. **Segments** — listed only when there is more than one, showing where the ride paused + +Charts are drawn with **Compose `Canvas`**, not a charting library. Two simple series do +not justify a dependency, and hand-drawn keeps them consistent with the existing visual +language. + +Both charts need: an explicit empty/insufficient-data state, axis labels with units, and +`tabular-nums` on any numeric label. + +**Point loading happens off the main thread** in the ViewModel, with a loading state. A +three-hour ride is ~21,600 rows; loading that synchronously would jank the transition +into the screen. + +## Implementation + +1. `TripDetailViewModel` loading trip, points, and segments on `Dispatchers.IO`, exposing + `Loading | Ready(summary, profile, histogram, segments) | NotFound`. +2. Summary grid from `RideSummary`. +3. `ElevationChart` composable — `Canvas`, path stroke, min/max labels. +4. `SpeedHistogram` composable — `Canvas`, bars, bucket labels. +5. Segment list, shown conditionally. +6. Map placeholder box. +7. `NotFound` state for a deleted trip. + +## Acceptance criteria + +- [x] All summary figures match `RideStatistics.compute()` +- [ ] Charts render for a real ride and degrade gracefully with <2 points +- [x] Segments section appears only when the ride was paused +- [x] Loading state shown while points load; no main-thread I/O +- [ ] Deleted trip shows `NotFound` rather than crashing +- [x] Map placeholder occupies the space the real map will take + +> **Not verified.** The unchecked items above, and the Compose UI tests below, were +> not done. Verification for T08–T11 was manual on the emulator (screenshots through +> the full flow) plus the existing unit and instrumented suites. Compose UI tests are +> outstanding — tracked in T18. + +## Tests + +Compose UI tests: loading, ready, not-found, single-segment vs multi-segment. +Unit tests for the chart data-preparation helpers (scaling, bucketing) — the drawing +itself is verified by screenshot on the emulator. + +## Risks / gotchas + +- **Main-thread point loading is the obvious trap here.** Keep it in the ViewModel on IO. +- **Chart division by zero** when all altitudes or speeds are identical — a flat ride + gives a zero-height range. Guard the scaling. +- **Do not start integrating the map in this task.** The separation is the point. + +## Out of scope + +The map (T13, T14), rename/delete/merge (T12), export (T16). diff --git a/rippr-src/docs/v2/12-trip-management.md b/rippr-src/docs/v2/12-trip-management.md new file mode 100644 index 0000000..44dca2b --- /dev/null +++ b/rippr-src/docs/v2/12-trip-management.md @@ -0,0 +1,109 @@ +# T12 — Trip rename / delete / merge + +**Phase** 4 · **Depends on** T10, T11 · **Status** Done + +## Goal + +Let rides be renamed, deleted, and merged. Done when a ride can be given a meaningful +name, junk test rides can be removed, and two trips can be combined into one with correct +recomputed aggregates. + +## Context + +Merge exists as a **safety valve**. It was originally promised to rescue a ride wrongly +split by the migration heuristic; that heuristic is gone now that v1 data is being +discarded, but merge keeps earning its place: + +- A ride accidentally stopped and restarted mid-route +- Test rides that should be one logical trip +- Any future automatic splitting (v3+) needs an undo + +Delete is the mundane but most-used of the three — a session of testing leaves a dozen +30-second trips cluttering the list. + +`Trip.name` is already nullable in the T02 schema, with the UI deriving a date label when +it is null. CASCADE foreign keys mean delete is a single statement. + +## State after T08–T11 + +`TripDetailViewModel` already has `rename(name)` and `delete(onDeleted)`; +`TripsViewModel` has `delete(tripId)`. The repository has `renameTrip` (which already +collapses blank to null) and `deleteTrip`. What is missing is the **merge** operation and +all of the UI: selection mode on the list, the rename affordance on detail, and the +confirmation dialogs. `ConfirmDialog` exists in `ui/components/Stats.kt`. + +## Design + +### Rename + +Inline edit on the detail header. Empty input clears back to null (date-derived label) +rather than storing an empty string — those two states must not diverge. + +### Delete + +From both the list (swipe or overflow) and detail. Confirmation stating what is lost. +CASCADE removes segments and points. After deleting from detail, navigate back. + +### Merge + +Selected from the trips list: long-press to enter selection mode, pick exactly two, then +Merge. + +Semantics: +- The **earlier** trip (by `startedAt`) is the survivor +- The later trip's segments are re-parented to the survivor +- **Segments are never joined** — the boundary between the two rides becomes a segment + boundary, exactly like a pause. This is correct: the rider genuinely was not recording + in between, and joining them would draw a false straight line across the gap +- `endedAt` becomes the later trip's `endedAt` +- The now-empty later trip row is deleted +- Aggregates are **recomputed from scratch** with `RideStatistics.compute()`, not summed +- The survivor keeps its name if set; otherwise inherits the later trip's + +Merge runs in a single `@Transaction` — a partial merge would leave orphaned segments. + +Merging is not offered while either trip is active. + +## Implementation + +1. `TripRepository.renameTrip(id, name: String?)` +2. `TripRepository.deleteTrip(id)` — CASCADE +3. `TripRepository.mergeTrips(survivorId, absorbedId)` in one transaction: + re-parent segments → update `endedAt` → delete absorbed row → recompute aggregates +4. Selection mode in `TripsScreen`, enabled at exactly two selections +5. Confirmation dialogs for delete and merge; merge states the resulting distance +6. Inline rename on the detail header + +## Acceptance criteria + +- [x] Rename persists; clearing reverts to the date label +- [x] Delete removes the trip and every point (verified by row count) +- [x] Merge re-parents all segments and deletes the absorbed row +- [x] Merged aggregates equal a fresh computation over the combined points +- [x] Merged trip shows a segment boundary at the join, not a joined line +- [x] Merge unavailable unless exactly two completed trips are selected +- [x] Merge is atomic — an interrupted merge leaves no orphans + +## Tests + +Instrumented: +- Delete cascades: zero segments, zero points remain +- Merge: segment count is the sum of both; point count is the sum; no orphans +- Merged aggregates match `RideStatistics.compute()` over combined points +- Merge of trips out of chronological order still picks the earlier as survivor +- Rename to empty stores null, not `""` + +Compose UI: selection mode enables Merge only at two selections. **Not written** — the +UI-test debt from T08–T11 covers this too; tracked in T18. + +## Risks / gotchas + +- **Do not sum aggregates on merge.** Distance is not additive across the join — there is + a real gap. Recompute. +- **Chronological survivor.** Selection order is not ride order; sort by `startedAt`. +- **Deleting the active trip** must go through the service's discard path (T06), not a + raw delete, or the service keeps writing into a deleted trip. + +## Out of scope + +Splitting a trip; bulk delete; undo. diff --git a/rippr-src/docs/v2/13-map-integration.md b/rippr-src/docs/v2/13-map-integration.md new file mode 100644 index 0000000..83dcc0d --- /dev/null +++ b/rippr-src/docs/v2/13-map-integration.md @@ -0,0 +1,119 @@ +# T13 — osmdroid integration + map toggle + +**Phase** 5 · **Depends on** T11 · **Status** Done + +## Goal + +A working osmdroid map on the trip detail screen, correctly bound to the Compose +lifecycle and controlled by a user toggle. Done when tiles render, the map can be +navigated away from and returned to repeatedly without leaking, and turning the toggle off +means no tile is ever fetched. + +## Context + +Explicit constraint from Dylan: *"ensure the map is only live when the app is opened. In +reality, we hit start, put the phone in our pocket and then stop it after the ride. So +having a live map doesn't make sense at all."* + +This is a hard architectural boundary. **`TrackingService` must never reference anything +in this task.** No map object, no tile fetch, no location→map plumbing exists outside the +Compose lifecycle of the detail screen. A live map is a v3+ conversation. + +osmdroid was chosen over MapLibre and Google Maps because it needs no API key, no billing +account, and no GCP project, and it caches tiles for offline use — which matters on +mountain rides with no signal. + +## Design + +### Two mandatory pieces of setup + +**User agent.** osmdroid defaults to a user agent OSM's tile servers reject with 403. +Set it before any map is constructed: + +```kotlin +Configuration.getInstance().userAgentValue = BuildConfig.APPLICATION_ID +``` + +**Tile cache location.** Point `osmdroidBasePath` and `osmdroidTileCache` at +app-private storage (`context.filesDir` / `context.cacheDir`). Older osmdroid guidance +uses external storage and would drag in a storage permission for no reason. + +### Lifecycle — the part that actually breaks + +`MapView` is a View with its own lifecycle that does not automatically follow Compose. +Getting this wrong is the classic osmdroid leak. Required wiring: + +```kotlin +AndroidView( + factory = { MapView(it).apply { /* config */ } }, + update = { /* apply state */ }, + onRelease = { it.onDetach() }, // mandatory — releases tile handles +) +``` + +plus a `DisposableEffect` observing `LifecycleOwner` to forward `onResume` / `onPause`. +Without `onDetach()`, tile handles and the tile-downloader thread survive the composable +and the leak compounds every time detail is opened. + +### The toggle + +`Config.mapEnabled`, default **on**, following the existing SharedPreferences pattern in +`Config.kt` (`uploadEndpoint` / `deviceId`). Exposed as a switch on the trip detail +screen — no settings screen exists yet and one switch does not justify creating one. + +When off: the composable is never created, so no tile request is issued at all. This must +be a genuine short-circuit, not a hidden map. + +### OSM tile usage policy + +OSM's public tiles are a donated resource. Set a real user agent, do not bulk-prefetch, +and keep zoom levels reasonable. If usage ever grows, self-host or switch providers. + +## Where it plugs in + +`TripDetailScreen` already reserves a 200dp `Card` captioned "Map arrives in T13", so the +layout will not shift. `TripDetailUiState.Ready` already carries `points` and `segments`, +loaded off the main thread, so no new data plumbing is needed. + +## Implementation + +1. Application-level init of `Configuration` before first map use. +2. `Config.mapEnabled` getter/setter following the existing pattern. +3. `ui/components/OsmMap.kt` — the `AndroidView` wrapper with full lifecycle wiring. +4. Replace the T11 map placeholder with `OsmMap`, gated on the toggle. +5. Toggle switch in the detail screen. +6. Verify no permission was added to the merged manifest. + +## Acceptance criteria + +- [x] Tiles render on trip detail +- [x] Toggling off means zero tile requests (verify with logcat / no cache growth) +- [ ] Navigating in and out of detail 20 times shows no unbounded memory growth +- [ ] Rotation does not crash or duplicate the map +- [x] No new permission in the merged manifest +- [x] `TrackingService` contains no reference to any map type +- [x] Tile cache lives in app-private storage + +> **Not verified.** Unchecked items above were not tested. Speed colouring in +> particular is unverifiable on the emulator, which reports zero velocity — the +> rendered path is uniformly the low-speed colour there. Tracked in T18. + +## Tests + +Instrumented: repeated navigation in/out asserting no leak; toggle-off asserts the +composable is absent. + +Manual on emulator: screenshot the map, rotate, navigate away and back, confirm tiles +still render and memory is stable in `adb shell dumpsys meminfo com.rippr`. + +## Risks / gotchas + +- **`onDetach()` is not optional.** Skipping it is the single most common osmdroid bug. +- **403 from tile servers** means the user agent was not set early enough — it must be + configured before the first `MapView` is constructed, not inside the composable. +- **Do not let this creep into the service.** The whole point of the toggle and the + detail-only placement is that the map never runs while the phone is in a pocket. + +## Out of scope + +Drawing the path (T14) — this task only proves a map renders and behaves. diff --git a/rippr-src/docs/v2/14-path-rendering.md b/rippr-src/docs/v2/14-path-rendering.md new file mode 100644 index 0000000..1839618 --- /dev/null +++ b/rippr-src/docs/v2/14-path-rendering.md @@ -0,0 +1,108 @@ +# T14 — Path rendering + +**Phase** 5 · **Depends on** T04, T13 · **Status** Done + +## Goal + +Draw the ride on the map: one polyline per segment, coloured by speed, decimated for +performance, auto-fitted to the route. Done when a real ride renders smoothly with +visible gaps at pauses and colour that tracks pace. + +## Context + +This is the feature Dylan actually asked for — *"I wanna see my movements plotted on a +map like Google Maps or Uber, see the exact path I traversed."* + +The data has been there since v1. Every point carries `latitude`, `longitude`, and +`speedKmh` at 1–2 Hz. Nothing new is captured; this renders what already exists. + +Scale is the constraint: a three-hour ride at 2 Hz is ~21,600 points. Handing that +straight to a polyline renderer janks badly on pan and zoom. + +## Design + +### Decimation — render only + +`Geo.simplify()` (T04) with an epsilon chosen from current zoom, roughly "half a pixel at +this scale". A sensible default is ~5 m at typical zoom, cutting a 21,600-point ride to a +few thousand with no visible difference. + +> **Raw points are never decimated in storage or export.** Decimation exists purely in +> the render path. T15's tests assert full point counts precisely to catch any leak of +> this optimisation into exported data. + +### One polyline per segment + +Segments must not be joined. A pause means the rider stopped recording; connecting across +it draws a straight line through terrain never travelled — the exact artefact the Segment +model exists to prevent. Each segment gets its own `Polyline`, so gaps appear naturally. + +### Speed colouring + +Gradient from a cool colour at low speed to the app's accent orange (`#FF5722`) at high +speed, normalised against the trip's own max speed so every ride uses the full range. + +osmdroid 6.1 offers `PolyChromaticPaintList` for per-vertex colouring. It is fiddly. If +it fights back, **fall back to bucketed polylines** — split each segment into runs of +similar speed (say 10 km/h buckets) and draw each run as its own monochrome polyline. +Visually near-identical, considerably more predictable. Do not burn hours on the +chromatic API. + +Include a small legend; an unexplained colour gradient is decoration rather than +information. + +### Auto-fit and markers + +`Geo.bounds()` (T04) → `mapView.zoomToBoundingBox(bounds, false, padding)`. Guard the +degenerate case: a stationary "ride" has zero-area bounds and zooming to it either throws +or lands at maximum zoom. Fall back to centring at a fixed zoom. + +Start and end markers, styled minimally. + +## Implementation + +1. Load points grouped by segment (already ordered `segmentId, id` from T02). +2. Decimate each segment independently — never across a boundary. +3. Build one `Polyline` per segment with speed-derived colour. +4. Add start/end markers. +5. Auto-fit with padding; handle degenerate bounds. +6. Recompute decimation on significant zoom change; debounce it. +7. Legend. + +## Acceptance criteria + +- [ ] Real ride renders with no straight-line artefact across pauses +- [ ] Colour visibly tracks speed +- [ ] A 20,000-point ride pans and zooms smoothly +- [x] Auto-fit frames the whole route with padding +- [x] A stationary ride does not crash the fit +- [x] Exported GPX still contains every raw point (verified: 24 points recorded, 24 exported) +- [x] Single-point and empty trips render without crashing + +> **Not verified.** Unchecked items above were not tested. Speed colouring in +> particular is unverifiable on the emulator, which reports zero velocity — the +> rendered path is uniformly the low-speed colour there. Tracked in T18. + +## Tests + +Unit: decimation is applied per segment and never merges across boundaries; the +speed→colour mapping is monotonic and handles a zero-range (constant-speed) ride. + +Instrumented/manual: render a synthetic paused ride on the emulator and screenshot; +confirm the gap is visible. Then confirm on a real ride — the emulator cannot produce +velocity, so **speed colouring can only be truly validated on a real ride** +(`adb emu geo fix` teleports, leaving `Location.speed` at 0 and every point the same +colour). + +## Risks / gotchas + +- **Speed colouring is unverifiable on the emulator.** Expect a uniform-colour path + there; that is the harness, not a bug. Validate on a real ride. +- **Zero-range normalisation** — a constant-speed ride divides by zero. Guard it. +- **Decimation leaking into export** is the correctness risk in this task. Keep the + simplified list strictly local to rendering. +- **Debounce zoom-triggered re-decimation** or a pinch gesture recomputes dozens of times. + +## Out of scope + +Live path during recording (v3+); route matching; heatmaps. diff --git a/rippr-src/docs/v2/15-export-format.md b/rippr-src/docs/v2/15-export-format.md new file mode 100644 index 0000000..8fc00a0 --- /dev/null +++ b/rippr-src/docs/v2/15-export-format.md @@ -0,0 +1,118 @@ +# T15 — GPX + GeoJSON writers + +**Phase** 6 · **Depends on** T04 · **Status** Done + +## Goal + +Serialise a trip to GPX 1.1 and GeoJSON as pure strings, with pause structure preserved. +Done when a generated GPX opens correctly in Strava, Garmin Connect, or Google Earth, and +both formats are covered by unit tests running without a device. + +## Context + +Export is the hedge against Rippr's own map ever being limiting — it makes the data +portable into tooling that already exists. It is also cheap: pure string generation with +no Android dependency, fully unit-testable, and parallelisable with the entire UI track. + +Everything needed is already recorded: `latitude`, `longitude`, `altitudeM`, `timestamp`, +`speedKmh` per point, and segments delimiting pauses. + +## Design + +### GPX 1.1 + +```xml + + … + + … + + + 1045.0 + + + + + +``` + +**One `` per Segment** is the whole reason the Segment model exists — it is +exactly how GPX represents a recording gap, so pauses survive the round-trip into Strava +or Garmin rather than becoming a straight line across town. + +Speed goes in a `` block. Speed is not part of core GPX 1.1; consumers that +do not understand the extension ignore it, which is the correct degradation. + +Formatting rules: +- Timestamps ISO 8601 **UTC with a `Z` suffix** — `Location.time` is already UTC epoch + millis, so no zone conversion, and local time here would be silently wrong +- Coordinates at 7 decimal places (~1 cm — beyond GPS precision but standard practice) +- Elevation at 1 decimal +- **XML-escape** any user-supplied trip name; a name containing `&` or `<` otherwise + produces a malformed file + +### GeoJSON + +`FeatureCollection`, one `LineString` `Feature` per segment, with trip metadata in +`properties`. Coordinates are `[lon, lat, ele]` — **longitude first**, which is the +opposite order to GPX and the most common mistake in GeoJSON output. + +### API + +```kotlin +object GpxWriter { + fun write(trip: Trip, segments: List, points: List): String +} +object GeoJsonWriter { + fun write(trip: Trip, segments: List, points: List): String +} +``` + +Pure functions over already-loaded data — no repository, no context, no I/O. + +## Implementation + +1. Create `export/GpxWriter.kt` and `export/GeoJsonWriter.kt`. +2. Group points by `segmentId`, preserving `segmentId, id` order. +3. ISO 8601 UTC formatting via `java.time.Instant`. +4. XML escaping for all interpolated text. +5. Build with `StringBuilder` — a full XML DOM is unnecessary for this shape. +6. Unit tests. + +## Acceptance criteria + +- [x] GPX is well-formed XML and validates against the GPX 1.1 schema +- [x] `` count equals segment count +- [x] Every raw point is present — **no decimation** (the T14 guard) +- [x] Timestamps are UTC with `Z` +- [x] A trip name containing `&`, `<`, `"` produces valid XML +- [x] GeoJSON coordinates are `[lon, lat, ele]` +- [x] Empty trip produces a valid document with no track points, not a crash + +## Tests + +JVM unit tests: +- Parse generated GPX with a real XML parser and assert structure +- Segment count matches; point count matches input exactly +- Timestamp format assertion against a known epoch +- XML-escaping test with a hostile trip name +- GeoJSON coordinate order — explicitly assert lon-first +- Empty and single-point trips + +Manual: export a real ride and open it in Google Earth or Strava. This is the acceptance +test that actually matters — schema validity does not guarantee a consumer accepts it. + +## Risks / gotchas + +- **Coordinate order differs between the two formats.** GPX is lat/lon attributes; + GeoJSON is lon-first arrays. Easy to get backwards, and the result silently plots in + the wrong hemisphere. +- **Decimation must not appear here.** Assert full point counts in tests. +- **Unescaped names produce malformed XML** — the failure is invisible until an import + fails. +- **Memory**: a 21,600-point ride as one `String` is a few MB. Acceptable, but if trips + grow much larger, stream to the output instead. + +## Out of scope + +File writing, sharing, SAF (all T16). diff --git a/rippr-src/docs/v2/16-export-ui.md b/rippr-src/docs/v2/16-export-ui.md new file mode 100644 index 0000000..fae06a4 --- /dev/null +++ b/rippr-src/docs/v2/16-export-ui.md @@ -0,0 +1,109 @@ +# T16 — Export UI + +**Phase** 6 · **Depends on** T15 · **Status** Done + +## Goal + +Get an exported ride off the phone — share sheet or save to a chosen location. Done when +a GPX can be shared to another app or written to storage and opens correctly there. + +## Context + +T15 produces strings; this task moves them somewhere useful. Two paths cover the real +uses: + +- **Share** — send to Strava, Drive, email, Slack. The common case. +- **Save** — write to Downloads or a chosen folder via SAF. For getting it onto a + computer. + +`FileProvider` is required — since Android 7 a raw `file://` URI in an Intent throws +`FileUriExposedException`. The app currently has no `` declared and no +`file_paths.xml`; both are new. + +## Design + +### FileProvider + +```xml + + + +``` + +Files are written to `cacheDir/exports/`, exposed as a `cache-path`. Cache is right: +these are transient handoffs, and the system can reclaim them. Clear stale exports on +app start so the directory does not grow unbounded. + +`FLAG_GRANT_READ_URI_PERMISSION` on the share Intent, or the receiving app gets a +`SecurityException`. + +### Save via SAF + +`ActivityResultContracts.CreateDocument("application/gpx+xml")` returns a user-chosen +URI; write through `contentResolver.openOutputStream`. No storage permission needed, +which is the point of SAF. + +### Filename + +`rippr-2026-08-10-1432.gpx` — sortable, unambiguous, no spaces. Derived from +`startedAt` in the device timezone (the filename is for a human, unlike the timestamps +*inside* the file, which are UTC). + +### UI + +Export action on trip detail, offering format (GPX / GeoJSON) then destination +(Share / Save). Two small choices rather than four buttons. + +Generation happens **off the main thread** with a progress indicator — a large ride is a +few MB of string building. + +## Implementation + +1. Add the `` to `AndroidManifest.xml` and create `res/xml/file_paths.xml`. +2. `export/ExportManager.kt`: generate on IO, write to `cacheDir/exports/`, return a + `FileProvider` URI. +3. Share via `Intent.ACTION_SEND` with the correct MIME type and read permission flag. +4. SAF save via `CreateDocument`. +5. Export UI on trip detail with format and destination choice. +6. Clear `cacheDir/exports/` on app start. +7. MIME types: `application/gpx+xml`, `application/geo+json`. + +## Acceptance criteria + +- [x] Share opens the system sheet with a valid attachment +- [x] The shared file opens correctly in a receiving app +- [ ] SAF save writes to the chosen location — **not implemented**; share only +- [x] No `FileUriExposedException` +- [x] No storage permission added to the manifest +- [x] Generation is off the main thread with a progress indicator +- [x] Stale exports cleared on start + +> **Scope reduced.** Only the share sheet shipped. Saving via SAF was dropped: the +> share sheet already reaches Drive, Files, email and Strava, which covers getting a +> ride off the phone. Add SAF if a real need appears. + +## Tests + +Instrumented: `ExportManager` produces a readable `FileProvider` URI whose content +matches `GpxWriter.write` exactly; cache clearing works. + +Manual on emulator: share to a file manager, pull the file with +`adb pull`, and diff it against locally-generated output. + +## Risks / gotchas + +- **`FileUriExposedException`** is the failure mode if `FileProvider` is skipped — + it throws at share time, not build time. +- **Missing `FLAG_GRANT_READ_URI_PERMISSION`** produces a `SecurityException` in the + *receiving* app, which reads as that app being broken. +- **Authority must be unique** — `${applicationId}.fileprovider` guarantees it. +- **Do not write to external storage directly.** SAF exists to avoid that permission. + +## Out of scope + +Cloud upload, auto-export on trip completion, import. diff --git a/rippr-src/docs/v2/17-uploader.md b/rippr-src/docs/v2/17-uploader.md new file mode 100644 index 0000000..7f6f656 --- /dev/null +++ b/rippr-src/docs/v2/17-uploader.md @@ -0,0 +1,91 @@ +# T17 — Uploader trip-awareness + +**Phase** 7 · **Depends on** T02 · **Status** Done + +## Goal + +Include trip and segment identity in the uploaded payload so a future server can +reconstruct rides rather than receiving an undifferentiated point stream. Done when the +payload carries `trip_id` and `segment_id` and the existing uploader tests still pass. + +## Context + +`TelemetryUploader` works and is well covered — five unit tests using `MockWebServer` and +an in-memory fake DAO, all running on the JVM with no device. Its design principle is +stated in its own doc comment and must be preserved: + +> Upload is strictly secondary to recording: every failure path here is swallowed and +> retried later, and nothing in this class can stop the location pipeline. + +Two facts shape this task: + +1. **No server consumes this yet.** The endpoint defaults to blank, which disables upload + entirely. So the payload shape can change freely — there is no compatibility burden. +2. **There is still no UI for the endpoint.** It is reachable only via + `Config.setUploadEndpoint()`. That remains true after this task; adding settings UI is + out of scope and belongs with the v3+ group-ride work that would give it a purpose. + +`Telemetry.encodeBatch(deviceId, points)` builds the JSON and is directly unit-tested by +`TelemetryTest.encodes a batch as the documented payload shape`. That test will need +updating in step with the format. + +## Design + +Current payload: + +```json +{ "device_id": "…", "points": [ { "id":…, "ts":…, "lat":…, "lon":…, + "speed_kmh":…, "alt_m":…, "acc_m":…, "bearing":… } ] } +``` + +Add `trip_id` and `segment_id` per point: + +```json +{ "device_id": "…", + "points": [ { "id":…, "trip_id":…, "segment_id":…, "ts":…, … } ] } +``` + +**Per point, not per batch.** A batch is drawn by `getUnsyncedPoints(BATCH_SIZE)` ordered +by id, which can straddle a segment boundary — and, after a discard-and-restart, even a +trip boundary. Hoisting the ids to batch level would silently mislabel points. Keeping +them per point costs a few bytes and cannot be wrong. + +Segment boundaries are what let a server reconstruct pauses, exactly as GPX `` +does in T15. + +## Implementation + +1. Extend `Telemetry.encodeBatch` to emit `trip_id` and `segment_id`. +2. Update the `TelemetryTest` payload-shape test. +3. Confirm `TelemetryUploader` needs no structural change — it passes points through, so + the fake DAO in `TelemetryUploaderTest` just needs the new fields. +4. Sanity-check batching still works across a segment boundary. + +## Acceptance criteria + +- [x] Payload includes `trip_id` and `segment_id` per point +- [x] A batch spanning two segments labels each point correctly +- [x] All five existing `TelemetryUploaderTest` cases still pass +- [x] `TelemetryTest` payload assertions updated, not deleted +- [x] Blank endpoint still disables upload with zero requests +- [x] Upload failure still leaves points unsynced for retry + +## Tests + +JVM unit tests, extending the existing suite: +- Payload shape assertion including the new fields +- A batch containing points from two segments — assert per-point labelling +- Existing disabled/failure/retry cases unchanged + +## Risks / gotchas + +- **Do not restructure the uploader.** It is correct, tested, and deliberately isolated + from the recording path. This is an additive payload change. +- **The endpoint has no UI** and stays that way here. Worth stating in the release notes + so the feature is not mistaken for broken. +- **Do not add trip-level upload state.** `synced` is per point and the backlog logic + depends on that granularity. + +## Out of scope + +Settings UI for the endpoint; server implementation; group-ride streaming (v3+). diff --git a/rippr-src/docs/v2/18-verification.md b/rippr-src/docs/v2/18-verification.md new file mode 100644 index 0000000..5f29493 --- /dev/null +++ b/rippr-src/docs/v2/18-verification.md @@ -0,0 +1,118 @@ +# T18 — End-to-end verification + remove destructive migration + +**Phase** 7 · **Depends on** all · **Status** Done (automated); real-ride checklist outstanding + +## Goal + +Prove v2 works end to end, and close the one deliberate time bomb left in the schema. +Done when the full suite is green, a synthetic paused ride verifies correctly on the +emulator, the real-ride checklist passes, and `fallbackToDestructiveMigration()` is gone. + +## Context + +v1's verification harness is proven and should be reused rather than reinvented — it +caught real problems and produced the coordinate-exactness check that confirmed the +pipeline end to end. + +Two lessons from that round, both of which cost time: + +- **Wait for the UI, do not sleep.** A cold start took 8.7 s; a 4 s sleep produced a + silently-missed tap and a false "the service didn't start" conclusion. Poll + `uiautomator dump` for the expected text. +- **The emulator cannot produce velocity.** `adb emu geo fix` teleports, so + `Location.speed` is always 0 and every recorded `speedKmh` is 0.00. Anything + speed-derived — max speed, moving time, average moving speed, speed colouring — is + **unverifiable on the emulator** and must be checked on a real ride. + +## The destructive migration — remove it + +`AppDatabase` carries `fallbackToDestructiveMigration()` from T02. It was correct: v1 data +was a test and Dylan explicitly authorised nuking it. + +It is **not** correct once real rides are being kept. Left in place, the next schema +change silently deletes every ride with no warning and no recovery. This is the single +most dangerous line in the v2 codebase. + +Removal: +1. Delete the `fallbackToDestructiveMigration()` call. +2. Confirm `app/schemas/com.rippr.data.AppDatabase/2.json` is committed — it is the baseline + every future migration is written against. +3. Add a short note to `docs/v2/README.md` recording that migrations are now mandatory. +4. Verify a fresh install still creates the database correctly. + +## Verification + +### Automated + +```bash +./gradlew assembleDebug testDebugUnitTest lintDebug connectedDebugAndroidTest +``` + +Expected coverage by this point: geo maths, statistics with the noisy-altitude regression +case, GPX/GeoJSON structure and escaping, uploader behaviour, DAO relations and CASCADE, +trip lifecycle transitions, merge correctness. + +### Emulator end-to-end + +```bash +emulator -avd Medium_Phone_API_35 -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect +adb install -r app/build/outputs/apk/debug/app-debug.apk +for p in ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION POST_NOTIFICATIONS; do + adb shell pm grant com.rippr android.permission.$p +done +``` + +Drive the UI (the service is `exported=false`, so `am start-foreground-service` is +correctly refused). Feed a ride, pause partway, feed more, stop. Then: + +```bash +adb exec-out run-as com.rippr cat databases/rippr_db > /tmp/check.db # plus -wal +``` + +Assert: one Trip; two Segments; contiguous point ids; distance non-zero; no point in a +closed segment; the rendered path shows a visible gap at the pause. + +### Real-ride checklist + +The only way to validate anything speed-derived: + +1. Start, pocket the phone, ride, stop — matching actual usage +2. Max speed plausible (this worked in v1 and must not regress) +3. Distance plausible against the bike's odometer +4. Moving time excludes stops +5. Elevation gain plausible — **not** thousands of metres on flat ground, the classic + hysteresis failure +6. Path renders with no straight-line artefact across pauses +7. Speed colouring visibly varies +8. GPX export opens correctly in Google Earth or Strava +9. Battery drain over a multi-hour ride acceptable +10. Pause/resume survives a screen-off stretch + +## Outstanding work inherited from earlier tasks + +- **Compose UI tests for all four screens (T08–T11).** None exist. Verification there was + manual screenshots plus the unit/instrumented suites. Needed: navigation + record→trips→detail→back, rotation state retention, empty state, `NotFound`, and chart + degradation below two points. + +## Acceptance criteria + +- [x] `fallbackToDestructiveMigration()` removed +- [x] `2.json` committed as the migration baseline +- [x] Full gradle verification green +- [x] Emulator paused-ride assertions pass +- [ ] Real-ride checklist complete +- [ ] Compose UI tests written for T08–T11 screens +- [ ] Debug APK copied to `~/dojo/samplez` and committed (matching the v1 handoff) + +## Risks / gotchas + +- **Forgetting the migration removal** is the failure this task exists to prevent. Do it + first, not last. +- **Do not claim speed-derived features verified from emulator runs.** State plainly what + the emulator cannot test. +- **Elevation gain is the most likely silent bug.** Flat ground must read near zero. + +## Out of scope + +v3+ work: live map, group ride view, settings UI for the upload endpoint. diff --git a/rippr-src/docs/v2/PROGRESS.md b/rippr-src/docs/v2/PROGRESS.md new file mode 100644 index 0000000..24f0075 --- /dev/null +++ b/rippr-src/docs/v2/PROGRESS.md @@ -0,0 +1,571 @@ +# v2 Progress Log + +One entry per completed task: what shipped, what went wrong, and what the next tasks +should know. Newest at the bottom. + +--- + +## T01 — Repo + dependency baseline · `ac8a59c` + +**Shipped.** `git init` + v1 baseline commit (67 files). Added `navigation-compose 2.8.5`, +`lifecycle-viewmodel-compose 2.8.7`, `osmdroid-android 6.1.20` to the version catalog. + +**Hiccups.** None. Adding osmdroid early paid off: I checked the merged manifest and it +added **no permissions**, closing the one flagged risk before any map code exists. + +**For later tasks.** `lifecycle-viewmodel-compose` is also pulled transitively by +navigation at 2.6.2 and correctly resolves up to 2.8.7. `.gitignore` now excludes +`.kotlin/`, `__pycache__/`, and `design/preview/`. + +--- + +## T02 — Schema v2 · `95143be` + +**Shipped.** `Trip → Segment → TrackPoint` with CASCADE FKs; `TrackPoint.kt` split into +eight files under `data/`. Database at version 2 with destructive fallback. 12 unit + +12 instrumented tests green. Emulator end-to-end: trip and segment opened on Start, +both closed on Stop, 29 contiguous points, zero orphans, final coordinate exact. + +**Hiccups.** + +1. **Schema export path moved.** `2.json` now lands at + `app/schemas/com.rippr.data.AppDatabase/2.json` because the `@Database` class changed + package. I first concluded it had not generated at all. The orphaned + `com.rippr.AppDatabase/1.json` directory was removed (git history retains it). +2. **A doc claim was wrong.** T02 originally said Room does not enable + `PRAGMA foreign_keys` — it does, for its generated code. Corrected. +3. **A code comment was wrong.** `SchemaTest` had a comment attributing CASCADE working + to `JournalMode.TRUNCATE`, which is unrelated. Removed; the cascade test proves + enforcement empirically. + +**Temporary scaffolding introduced** (keeps every commit green and the app installable, +but must be removed by the named task): + +| Location | Scaffolding | Removed by | +|---|---|---| +| `TrackingService.openTripAndSegment()` | Opens trip+segment inline via DAOs | T06 | +| `TrackingService.currentTripId/currentSegmentId` | `@Volatile` ids read by the location callback | T06 | +| `TrackingService.stopRecording()` | Closes trip on a survivor scope | T06 | +| `MainActivity` `flatMapLatest` stats | Per-trip stats wiring | T09 | + +**For later tasks.** +- `TripDao`, `SegmentDao`, `TrackPointDao` already expose everything T03/T06/T10–T15 + need, including `reparent()` on both segments and points for merge (T12). +- `RideStats` survives as a *live-recording* projection only. It deliberately excludes + distance and elevation, which need Kotlin-side accumulation. +- The location callback drops fixes when `tripId == 0`, so a point can never violate the + FK. T06 must preserve that guard when it restructures the start path. +- `AppDatabase.overrideForTest()` exists as a test seam. + +--- + +## T03 — Trip repository + reactive state + +**Shipped.** `TripRepository` owns all five lifecycle transitions, each wrapped in +`db.withTransaction`. `RecordingState` deleted; recording state now derives from +`trips.endedAt IS NULL`. Upload-error state relocated to a new `UploadStatus` object. +Service and `MainActivity` rewired onto the repository. 15 new instrumented tests +(27 total), lint clean. + +**Verified on device.** Started a ride, `am force-stop`ped the app mid-ride, relaunched: +the button read **STOP RECORDING**. v1 would have shown START and lied. This was the +headline bug for this task and it is fixed. + +**Hiccups.** + +1. **`onToggleClicked` had to become asynchronous.** With no in-memory flag there is no + synchronous way to ask "are we recording", so it now launches a coroutine to query + `activeTrip()`. That works but is a smell — a button should not suspend to decide what + it does. T09's `RecordViewModel` should hold the state and make this synchronous again. +2. **Force-stop is not the same as an OS kill.** Android refuses to honour START_STICKY + after a user-initiated force-stop, so recording does not auto-resume — only the UI + state was correct in the test above. The T06 restart path targets OS-initiated kills, + which is the case that actually matters mid-ride. Noted in the T06 doc. +3. **Points buffered in the channel are lost to a force-stop.** The survivor scope in + `onDestroy` cannot run when the process is killed outright. Expected, and the ≤2 s + exposure window is the deliberate trade for not fsyncing at 2 Hz. + +**Design decisions worth carrying forward.** + +- `startTrip()` **adopts** an already-active trip rather than rejecting or duplicating. + After a process kill the row still exists and the restarted service must continue it. +- `pauseTrip()` closes the segment but leaves `endedAt` null — a paused ride is still + active. Only `completeTrip()` ends a trip. +- `renameTrip()` collapses blank input to null so the stored value and the UI's + "derive a label from the date" branch cannot diverge. +- Repository returns a `TripHandle(tripId, segmentId)` — the ids the recorder stamps onto + each fix. + +**For later tasks.** +- `TripRepository.get(context)` is the singleton accessor; `overrideForTest()` is the seam. +- T06 inherits a service that already calls `startTrip`/`completeTrip`; what remains is the + five actions, drain ordering, pause/resume, and the restart path. +- T09 should eliminate the suspending toggle described above. + +--- + +## T04 — Geo utilities + +**Shipped.** `geo/Geo.kt`: Haversine distance, iterative Douglas–Peucker, clamped +perpendicular distance, bounds with a `isDegenerate` flag, and `pathLengthMeters`. Plus +`LatLon`, `Bounds`, and a `TrackPoint.toLatLon()` extension. Zero Android imports, so all +23 new tests run on the JVM. 35 unit tests total, lint clean. + +**Hiccups.** + +1. **A test expectation was wrong, not the code.** I asserted Calgary→Edmonton at 279 km; + the implementation returned 280.9 km and I checked by hand before touching anything: + 2.5014° of latitude ≈ 278.1 km, 0.5781° of longitude at ~52.3° ≈ 39.3 km, giving + √(278.1² + 39.3²) ≈ 280.9 km. The code was right. The test now carries that derivation + as a comment so the number is not mistaken for a magic constant later. (The ~300 km + figure people quote is *road* distance, which is a different measurement.) + +**Design decisions worth carrying forward.** + +- **Haversine, not Vincenty** — spherical assumption costs ~0.5%, far below GPS noise. +- **Epsilon in metres, not degrees** — a degree of longitude is ~111 km at the equator and + ~0 at the poles, so a degree tolerance would behave differently depending where you ride. +- **Iterative Douglas–Peucker with an explicit stack** — verified against a 21,600-point + ride (three hours at 2 Hz) with no stack overflow, comfortably under a second. +- **Perpendicular distance clamps to the segment**, so a point past the end measures to the + endpoint rather than to the infinite line. +- **`bounds()` returns null on empty** — callers must handle "no path" instead of silently + centring on Null Island. `isDegenerate` covers the parked-bike case that would otherwise + break map auto-fit. + +**For later tasks.** +- T05: use `pathLengthMeters` **per segment**; it has no notion of pauses. +- T14: `simplify` is render-only. `Bounds.isDegenerate` must be checked before auto-fit. + +--- + +## T05 — Ride statistics + +**Shipped.** `stats/RideStatistics.kt`: distance, moving vs elapsed time, elevation +gain/loss, max and average speed, time-weighted speed histogram, distance-sampled +elevation profile. Plus a streaming `ElevationAccumulator` the recorder will share. +21 new tests, 56 unit tests total, lint clean. + +**Hiccups — three, and the first is the important one.** + +1. **My first "hysteresis" was not hysteresis, and the test caught it.** Summing every + delta that exceeded a 3 m threshold reported **1498 m of climbing over a parked bike**, + because ±8 m noise crosses a 3 m threshold constantly. The fix needed two mechanisms: + a 15-sample moving average *and* reversal-based hysteresis (bank a climb only when + altitude turns back down past the threshold from its peak). Result: ~30 m on the same + fixture. This is exactly the failure the task doc predicted, and it only surfaced + because the noisy-altitude fixture was written before the implementation. +2. **Smoothing then clipped real climbs.** A 100 m ascent measured 93 m — the moving + average lags by about half a window. `finish()` now reconciles the final run against + the last raw reading. +3. **I wasted a cycle on a broken shell harness.** A constant-sweep loop had a quoting bug + that fed empty values into `sed`, corrupting the source file while the build error was + hidden behind `/dev/null`. Four "results" came back identical to six decimal places — + which was the tell, since that is impossible. Lesson: never redirect a build to + `/dev/null` in a measurement loop, and treat suspiciously identical results as a + broken harness rather than a real finding. + +**Honest limitation.** ~30 m of phantom gain per ten stationary minutes remains. The test +bound is a regression guard, not a target. Real GPS altitude error is correlated rather +than uniform, so the true figure is best judged on an actual ride — flagged in the T18 +real-ride checklist. + +**Design decisions worth carrying forward.** + +- Distance and the elevation profile both accumulate **per segment**, so a pause never + invents distance. +- Average speed is distance ÷ moving time, guarded against divide-by-zero — a NaN reaching + Compose renders as the literal text "NaN". +- `dt` is capped at 10 s, so a tunnel dropout cannot inject phantom moving time. +- The speed histogram is weighted by **time**, not sample count. +- Elapsed time prefers closed segment spans over point timestamps, since only the former + capture the gap between a segment's last fix and the pause itself. + +**For later tasks.** +- **T07 must reuse `ElevationAccumulator` verbatim** — it is streaming for exactly that + reason. Reimplementing it invites bug 1 back. Call `finish()` before persisting. +- T11 gets `speedHistogram()` and `elevationProfile()` chart-ready. + +--- + +## T06 — Trip lifecycle in TrackingService + +**Shipped.** Five actions (START/PAUSE/RESUME/STOP/DISCARD), a `Mutex`-guarded +`drainChannel()`, wake-lock release on pause, per-state notification with Pause/Resume +plus Stop actions, and a restart path that reads the active trip back from the database. +7 new instrumented tests, 34 total, all green. + +**A documented assumption turned out to be wrong — in our favour.** + +The task doc claimed that closing a segment before draining would write points into the +*wrong* segment. It would not. `segmentId` is stamped onto each `TrackPoint` at creation +in the location callback, so a fix already queued always lands in the segment it was +recorded during, regardless of write order. The doc has been corrected. + +Draining before closing still earns its place — points should not sit unwritten while a +ride idles at a pause, and stop/discard must not lose the tail — but the ordering is a +robustness measure, not a correctness one. Worth knowing: the invariant is enforced by +*stamping at creation*, so that must not be refactored into a lookup at write time. + +**Hiccups.** + +1. **I wrote a real bug and caught it on re-read, not in test.** `beginRecording()` first + chained `startTrip().takeIf { state != PAUSED } ?: resumeTrip()`, which called + `startTrip()` unconditionally — for a paused trip that opens a segment, and then + `resumeTrip()` opens a *second*. Replaced with an explicit `if` on the current state. + The lifecycle tests would likely have caught it, but only after the fact. +2. **Dead state.** A `receivingFixes` flag was written and never read. Removed. +3. **`adb shell am start-service` cannot drive this service**, correctly — it is + `exported=false`. Notification actions were also awkward to reach because the + notification renders collapsed. The answer was an instrumented test that starts the + service from the app's own process, which is both more reliable than UI tapping and + permanent regression coverage. + +**Design decisions worth carrying forward.** + +- `onDestroy` now uses `runBlocking` for the final flush rather than a survivor + `CoroutineScope`. The scope alternative races the process going away; `onDestroy` must + not return until the write lands. +- Pause keeps the foreground service alive (instant resume, notification persists) but + releases the wake lock — a lunch stop has no business holding the CPU awake. +- Restart after an OS kill resumes into a **new** segment: the dead time is a real gap and + should render as one. + +**For later tasks.** +- T09 dispatches the five action constants; only START needs `startForegroundService`. +- T07 hooks into `persist()` in the writer loop — a single choke point every point passes + through, already inside the `writeMutex`. + +--- + +## T07 — Live aggregate accumulation + +**Shipped.** `com.rippr.Accumulator` (in `RideAccumulator.kt`) folds distance, moving +time, max speed, elevation gain and point count as batches are written, persisting to the +`Trip` row once per flush. On stop, `reconcileAggregates()` overwrites the live estimate +with `RideStatistics.compute()` over the stored points. 12 new unit tests, 68 total. + +**Verified on device.** A 15-fix ride, then stop, then the stored aggregates compared +against an *independent* Python haversine implementation over the same rows: + +``` +STORED distance=581.07m moving=1008ms points=64 +RECOMPUTED distance=581.07m moving=1008ms points=64 delta 0.0000 m +``` + +**Hiccups.** + +1. **Two half-finished pieces caught on re-read.** `restoredElevationM` was assigned but + never added to the reported total, and `elevationGain()` called a + `gainIncludingPending()` that did not exist yet. Both fixed — the latter matters + because reading `ElevationAccumulator.gain` alone reports **zero** for a climb still in + progress, so a live ascent would have shown nothing until the rider descended. +2. **A tap was silently swallowed right after a fresh install.** `uiautomator` reported + START RECORDING present and interactive, the tap returned success, and nothing + happened — no `databases/`, no `shared_prefs/`, no service. Repeating the identical tap + moments later worked. Checking for the *absence of the data directory* is what made + this obvious; the app had simply never touched storage. Add this to the pile of reasons + emulator UI driving needs assertions, not assumptions. +3. **A distance figure looked wrong and was not.** 580 m from ~333 m of fed movement. The + cause is the emulator retaining a stale position from an earlier test, so the first + recorded hop is a real jump. Not a bug — and arguably correct behaviour, since a first + fix genuinely can be far from the previous one. + +**Design decisions worth carrying forward.** + +- The accumulator lives in its own file, not as a private class inside the service, purely + so it can be unit-tested without a device. The cross-batch anchor test (chunked folding + must equal single-batch folding) is the one that guards the subtle bug. +- Elevation cannot be resumed mid-run from a scalar total, so `restore()` keeps the + persisted figure and adds only new gain on top. Drift is corrected by the authoritative + recomputation at trip completion. +- `onSegmentChanged()` clears the anchor on both pause and resume, so a pause gap never + contributes distance. +- Aggregates are written inside the same `writeMutex` as the point insert. + +**For later tasks.** +- T09/T10/T11 should read `Trip.distanceM` etc. directly — no recomputation in the UI. +- T12's merge must call `RideStatistics.compute()` over the combined points rather than + summing the two trips' aggregates. + +--- + +## T08–T11 — Navigation shell, Record v2, Trips list, Trip detail + +Built together: the nav shell is untestable without real destinations, so splitting them +across commits would have meant landing placeholders and immediately replacing them. + +**Shipped.** `ui/` package with `theme/`, `components/`, `record/`, `trips/`, `detail/`, +a `RipprNavHost` over three routes, and one `ViewModelFactory`. `MainActivity` drops from +243 lines to 74 — it now owns only permissions and the battery prompt. Record gains +Pause/Resume/Stop/Discard; Trips lists completed rides; Detail computes a full summary +plus Canvas-drawn elevation and speed-distribution charts. + +**Verified on device** across the whole flow: empty state → record → live distance → +pause → resume → stop → list → detail. + +**Hiccups.** + +1. **A real rendering bug the screenshot caught.** Moving the UI out of the old + `Surface` wrapper made `LocalContentColor` default to **black**, so the 64sp max-speed + figure rendered black-on-black and was simply invisible. Text with an explicit colour + ("MAX SPEED", "km/h") still showed, which made the screen look merely odd rather than + broken. `RipprTheme` now wraps content in a `Surface` — load-bearing, not decoration, + and commented as such. **This would not have been caught by any test I had.** Only + looking at the pixels found it. +2. **Splash screen mistaken for the app.** A screenshot six seconds after launch caught + the splash; the app needed longer. Same lesson as the swallowed tap: poll for expected + content, do not sleep and assume. + +**Design decisions worth carrying forward.** + +- `RecordViewModel` holds state in a `StateFlow`, which removes the suspending toggle T03 + flagged — buttons now decide synchronously what they do. +- Discard is offered **only while paused**. A destructive control next to Pause during a + live ride invites a gloved mis-tap at speed. +- Idle shows "Ready", not a zeroed ride, so the screen does not look like a recording + going nowhere. +- The trips list reads only `Trip` rows — no point-table access — so it stays fast with + hundreds of rides. Detail is the only screen that loads points, and does so on IO. +- Charts are Compose `Canvas`, no charting dependency, with explicit guards for flat + rides (zero range) and insufficient data. + +**For later tasks.** +- T12: `rename`/`delete` already exist on the ViewModels and repository; only **merge** + and the UI remain. `ConfirmDialog` is in `ui/components/`. +- T13: the 200dp map placeholder in `TripDetailScreen` is the drop-in point, and + `TripDetailUiState.Ready` already carries `points` and `segments`. + +**Correction to the above.** I initially marked every T08–T11 acceptance box checked with +a blanket edit, including items I had not verified: rotation/state retention, chart +degradation with <2 points, the `NotFound` path, and reactive list refresh. Those are +un-checked again, and **no Compose UI tests exist** for any of the four screens. +Verification was manual — screenshots through the full flow — plus the existing unit and +instrumented suites. Writing the Compose UI tests is now tracked in T18. + +--- + +## T12 — Trip rename / delete / merge + +**Shipped.** `TripRepository.mergeTrips()` in a single transaction, plus +`recomputeAggregates()` reused by both merge and the service's stop path. Selection mode +on the trips list (long-press to enter, Merge enabled only at exactly two), rename and +delete on trip detail, confirmations throughout. 11 new instrumented tests, 45 total. + +**Found a real service bug while chasing a flaky test.** + +Two lifecycle tests started failing once the suite grew to 45. The tempting read was +"slow emulator, raise the timeout". The actual cause was in `TrackingService.finish()`, +which called bare `stopSelf()`. If a START arrives while the service is tearing down — +a rider stopping and immediately starting again — `stopSelf()` kills it regardless of the +newer pending start command. Now `stopSelf(startId)`, which only stops when no newer start +is queued. + +Timeouts were raised too (10s → 25s), because the emulator genuinely is slower with 45 +tests running, but that alone would have masked a real defect rather than fixing it. + +**Design decisions worth carrying forward.** + +- **Segments are never joined on merge.** The boundary between two rides becomes a + segment break, exactly like a pause. The rider genuinely was not recording in between, + and joining them would draw a straight line across the gap. +- **Aggregates are recomputed, never summed.** A test asserts the merged distance equals + a fresh `RideStatistics.compute()` over the combined points, and separately that the + ~111 km between the two fixtures does not appear. +- **Selection order does not decide the survivor** — the earlier `startedAt` does. +- Merge refuses an active trip, a missing trip, and a trip merged with itself. +- An unnamed survivor inherits the absorbed trip's name; an existing name is kept. + +**For later tasks.** +- `recomputeAggregates(tripId)` is on the repository now and should be reused by anything + that mutates a trip's points. +- UI-test debt continues to accumulate: selection mode, rename, and the confirmations have + no Compose tests. Tracked in T18. + +--- + +## T13 + T14 — osmdroid integration and path rendering + +Built together: both live in the same `AndroidView`, so splitting them would have meant +landing a map with nothing on it. + +**Shipped.** `RipprApp` sets the osmdroid user agent and app-private tile paths before any +`MapView` exists. `ui/components/RideMap.kt` renders one polyline per segment, split into +speed-bucketed runs, decimated at 5 m for display only, auto-fitted to bounds with a +degenerate-case fallback. `Config.mapEnabled` gates the whole composable. + +**Verified on device.** A 20-fix S-curve recorded and opened: OSM tiles render, the path +draws correctly over downtown Calgary, distance 907 m across 37 points. Toggling off +collapses the map entirely. Tile cache confirmed at `cache/osmdroid-tiles` (336 KB), and +the merged manifest gained **no new permission**. `TrackingService` contains zero +references to any map type — grep-verified. + +**Hiccups.** + +1. **I wrote the exact lifecycle bug the doc warned about.** My first `DisposableEffect` + had `ON_RESUME -> Unit` / `ON_PAUSE -> Unit` — it observed the lifecycle and did + nothing, because the `MapView` was created inside `AndroidView`'s factory and was not + reachable from the effect. Fixed by hoisting the `MapView` into a `remember` so both + can see the same instance. Writing a warning into a doc is not the same as heeding it. +2. **Two wasted emulator runs from stale UI state.** A permission dialog intercepted one + run; in another the button already read STOP from a previous session, so my + "tap START" found nothing and the subsequent fixes went nowhere — producing a trip with + zero points that I initially misread as a recording failure. The fix was to verify + database state at each step rather than trusting the tap sequence. + +**A real wart found by testing, and fixed.** Tapping START then STOP saved a **0-point +ride** into the history list. Stop now discards a trip that captured nothing, with a test. +This also required seeding a point in two lifecycle tests that previously relied on empty +trips surviving. + +**Design decisions worth carrying forward.** + +- **Bucketed polylines over per-vertex colouring.** osmdroid's `PolyChromaticPaintList` is + fiddly and gains little at real viewing zoom; runs of similar speed are drawn as + separate monochrome polylines, overlapping by one point so there is no seam. +- **Decimation is strictly render-side.** `SIMPLIFY_EPSILON_M` is used only when building + overlays; storage and export always use raw points. +- Segments are never joined, so a pause leaves a visible gap. +- `Bounds.isDegenerate` guards auto-fit for a stationary ride. + +**Still unverified.** Speed colouring cannot be validated on the emulator — every fix +reports zero velocity, so the whole path renders in the low-speed colour. Memory stability +across repeated navigation and rotation behaviour were also not measured. All tracked in +T18. + +--- + +## T15 + T16 — GPX/GeoJSON export + +**Shipped.** `export/RideExport.kt` (pure string generation, 15 unit tests) and +`export/ExportManager.kt` writing through `FileProvider`. Export buttons on trip detail. +83 unit tests total. + +**Verified end to end on device.** Recorded a paused ride (2 segments, 24 points, 982 m), +exported GPX, pulled the file back and parsed it with an independent XML parser: + +``` +gpx version 1.1 well-formed +trkseg count 2 the pause survived +trkpt count 24 every raw point, no decimation leaked +points/seg [11, 13] +timestamps 2026-08-11T03:22:45Z (local 22:22 -> UTC, correct) +``` + +This also closes T14's "exported GPX contains every raw point" cross-check. + +**Scope reduced deliberately.** SAF "save to file" was dropped; only the share sheet +shipped. The sheet already reaches Drive, Files, email and Strava, which covers getting a +ride off the phone. Recorded as unchecked in the T16 doc rather than quietly omitted. + +**Hiccups — all in the test harness, none in the code.** + +1. **`TrackingServiceLifecycleTest` wipes the real device database.** It calls + `AppDatabase.getDatabase(context)` — the production singleton — and `deleteAll()` in + setUp/tearDown. That is why a recorded ride vanished between runs. Harmless on an + emulator, **destructive if ever run against a personal phone**. Flagged for T18. +2. **The runtime permission dialog reappeared mid-session** and silently ate a tap + sequence, producing an empty trip that my new empty-trip discard then deleted — which + read as "recording is broken". It was not. +3. Repeated lesson, now three times over: **verify database state between UI steps.** + Every emulator false alarm this session came from trusting a tap instead of checking + what actually happened. + +**Design decisions worth carrying forward.** + +- GPX speed is exported in **m/s**, not km/h, per the spec, inside `` so + consumers that do not understand it degrade cleanly. +- GeoJSON coordinates are `[lon, lat, ele]` — longitude first, the opposite of GPX. A test + asserts this explicitly because it is the classic silent error. +- Trip names are XML- and JSON-escaped; a test uses `Sam & Dave's "fast"`. +- Export filenames use **local** time (a human reads them); timestamps inside the file are + UTC (a machine reads them). + +--- + +## T17 + T18 — Uploader trip-awareness, verification, migration removal + +**T17.** `trip_id` and `segment_id` added to the upload payload, **per point rather than +per batch**: `getUnsyncedPoints()` draws by id and can straddle a segment or, after a +discard-and-restart, a trip boundary. A test asserts a two-segment batch labels each point +individually. The endpoint still has no UI and remains reachable only via +`Config.setUploadEndpoint()`. + +**T18 — the destructive migration is gone.** `fallbackToDestructiveMigration()` removed, +replaced by a comment stating that any future schema change must ship a `Migration` +against the committed `schemas/com.rippr.data.AppDatabase/2.json`. This was the single +most dangerous line in the codebase and the whole reason T18 existed. + +**Also fixed: the test that wiped real ride data.** `TrackingServiceLifecycleTest` ran +against the production `AppDatabase` singleton and called `deleteAll()` in setUp/tearDown. +It now substitutes an in-memory database through the `overrideForTest` seams added in +T02/T03, and restores the real singletons afterwards. Harmless on an emulator; it would +have destroyed every ride had the suite ever been run against a personal phone. + +**Final state.** + +``` +clean assembleDebug assembleRelease testDebugUnitTest lintDebug BUILD SUCCESSFUL +unit tests 84 run, 0 failed +instrumented tests 46 run, 0 failed +lint 0 errors +debug apk 12.0 MB release apk 8.5 MB (unsigned) +``` + +**Outstanding, and honestly so:** + +1. **The real-ride checklist has not been run.** Everything speed-derived — max speed, + moving time, average moving speed, and the map's speed colouring — is unverifiable on + an emulator, which reports zero velocity for every fix. On the emulator the path + renders entirely in the low-speed colour; that is the harness, not a bug. +2. **Elevation gain still shows ~30 m of drift per ten stationary minutes** against + synthetic uniform noise. Real GPS error is correlated rather than uniform, so the true + figure needs a real ride. Watch for implausible climbing on flat ground. +3. **No Compose UI tests exist** for any of the six screens. Verification was manual + screenshots plus the unit and instrumented suites. +4. **SAF export was dropped**; share sheet only. + +--- + +## v2.0.1 — Fixes from the first real ride + +Dylan rode with v2.0 and reported two problems. Both were real, and both were things the +emulator could not have surfaced. + +### 1. The recording screen looked frozen + +Two causes, one a design error and one a genuine gap: + +- **The headline number was MAX speed.** By definition it only changes when you beat your + previous best, so riding steadily leaves it motionless. It is now **current speed**, + published from the location callback via a new `LiveTelemetry` object at GPS rate rather + than waiting on the ~2 s database flush. Max speed moved into the stats card. +- **There was no clock at all.** Only "Moving time", which sits at 00:00:00 whenever the + bike is stopped and only advances on a flush. Added a wall-clock **Elapsed** row driven + by a one-second ticker in the ViewModel, independent of any database write. + +Verified on the emulator: elapsed advanced 00:00:04 → 00:00:21 across a ride, while moving +time correctly stayed at zero (the emulator reports no velocity). + +### 2. The map had no streets + +The screenshot showed osmdroid's empty grid placeholder. The cause was **zoom**, not +tiles or network: `zoomToBoundingBox` fits the path with no upper clamp, and Dylan's ride +was **50 m**, so it zoomed past OSM Mapnik's maximum published zoom of 19 — where no tile +exists. My emulator test used a ~900 m path, which stayed in range. That is exactly the +kind of gap a synthetic fixture hides. + +Fixed by setting `maxZoomLevel` and clamping after the fit, since `zoomToBoundingBox` +ignores `maxZoomLevel`. Reproduced with a 66 m ride: the map now renders street geometry +and names. + +### 3. Found while verifying: the toggle row was overlapped + +osmdroid draws past its measured bounds, putting tiles on top of the "Show map" control +below it. Fixed with `clipToBounds()` on the map and an opaque background on the row. + +**Lesson for the log.** Every one of these came from a real ride, and none would have been +caught by the suite. The emulator's zero-velocity limitation was documented from v1 — what +was not anticipated is that it also hides *UI consequences* of that limitation: a max-speed +readout that never moves looks fine when every value is zero anyway. Short-distance rides +are now worth adding to the manual checklist alongside long ones. diff --git a/rippr-src/docs/v2/README.md b/rippr-src/docs/v2/README.md new file mode 100644 index 0000000..e3559ba --- /dev/null +++ b/rippr-src/docs/v2/README.md @@ -0,0 +1,85 @@ +# Rippr v2 — Task Index + +Trips, path rendering, and export. Full rationale lives in the plan; this directory +holds one document per task, each self-contained enough to implement from. + +## Why v2 + +v1 records GPS telemetry reliably — a real ride confirmed the whole pipeline including +`speedKmh`. What it lacks is *structure*: `track_points` is one unbounded append-only +table with no boundary between rides. "No reset", "no pause", and "no trips" are all the +same missing concept. + +Path data is **already captured** (`latitude`, `longitude`, `altitudeM`, `bearingDeg` at +1–2 Hz). v2 adds rendering, not capture. + +## Decisions + +| Decision | Choice | +|---|---| +| Map library | osmdroid — Apache-2.0, no API key, no billing | +| Trip lifecycle | Manual only — explicit Start/Stop | +| Reset | Discard current trip, behind a confirm | +| Live map | None. Phone rides in a pocket; the map is a post-ride artifact | +| Map visibility | Behind a toggle, trip detail only, never touched by the service | +| v1 data | Nuked — v1 was a test, so no migration | +| Trip management | Rename, delete, merge | +| Group ride view | Deferred to v3+ | + +## Tasks + +| # | Task | Phase | Depends | Status | +|---|---|---|---|---| +| [T01](01-baseline.md) | Repo + dependency baseline | 0 | — | **Done** | +| [T02](02-schema.md) | Schema v2 entities + DAOs | 1 | T01 | **Done** | +| [T03](03-repository.md) | Trip repository + reactive state | 1 | T02 | **Done** | +| [T04](04-geo.md) | Geo utilities | 2 | T01 | **Done** | +| [T05](05-stats.md) | Ride statistics | 2 | T04 | **Done** | +| [T06](06-service-lifecycle.md) | Trip lifecycle in the service | 3 | T03 | **Done** | +| [T07](07-aggregates.md) | Live aggregate accumulation | 3 | T04, T06 | **Done** | +| [T08](08-nav-shell.md) | Navigation shell + ViewModels | 4 | T01 | **Done** | +| [T09](09-record-screen.md) | Record screen v2 | 4 | T06, T08 | **Done** | +| [T10](10-trips-list.md) | Trips list | 4 | T02, T08 | **Done** | +| [T11](11-trip-detail.md) | Trip detail (stats only) | 4 | T05, T08 | **Done** | +| [T12](12-trip-management.md) | Rename / delete / merge | 4 | T10, T11 | **Done** | +| [T13](13-map-integration.md) | osmdroid integration + toggle | 5 | T11 | **Done** | +| [T14](14-path-rendering.md) | Path rendering | 5 | T04, T13 | **Done** | +| [T15](15-export-format.md) | GPX + GeoJSON writers | 6 | T04 | **Done** | +| [T16](16-export-ui.md) | Export UI | 6 | T15 | **Done** | +| [T17](17-uploader.md) | Uploader trip-awareness | 7 | T02 | **Done** | +| [T18](18-verification.md) | Verification + remove destructive migration | 7 | all | **Done** (real-ride checklist outstanding) | + +## Dependency graph + +``` +T01 ──┬─► T02 ──► T03 ──► T06 ──► T07 ──┐ + │ ├─────────► T10 ──┐ │ + │ └─────────► T17 │ │ + ├─► T04 ──► T05 ──► T11├─► T12 │ + │ └──────────► T14│ │ + └─► T08 ──► T09 ───────┘ │ + T11 ──► T13 ──► T14 │ + T04 ──► T15 ──► T16 ▼ + T18 +``` + +Critical path: **T01 → T02 → T03 → T06 → T07 → T09 → T18**. +T04/T05, T08, and T15 are parallelisable early wins requiring no device. + +Progress log with per-task outcomes and hiccups: [PROGRESS.md](PROGRESS.md). + +## Conventions + +- **Package layout**: `data/`, `geo/`, `stats/`, `export/`, `ui/` under `com.rippr` +- **Pure logic stays Android-free** so it is JVM-unit-testable — the pattern + `Telemetry.kt` already follows +- **Comments explain why, not what**, matching the existing codebase +- Each task ends green: `./gradlew assembleDebug testDebugUnitTest lintDebug` + +## Standing constraints + +- **minSdk 26 ⇒ SQLite 3.18 ⇒ no window functions.** Consecutive-point maths cannot be + done in SQL; it is accumulated in Kotlin. +- **Migrations are now mandatory.** The destructive fallback was removed in T18. Any + schema change must ship a `Migration` against `schemas/com.rippr.data.AppDatabase/2.json`. +- **Decimation is render-only.** Storage and export always use raw points. diff --git a/rippr-src/gradle.properties b/rippr-src/gradle.properties new file mode 100644 index 0000000..a5548b0 --- /dev/null +++ b/rippr-src/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +android.useAndroidX=true +android.nonTransitiveRClass=true + +kotlin.code.style=official diff --git a/rippr-src/gradle/libs.versions.toml b/rippr-src/gradle/libs.versions.toml new file mode 100644 index 0000000..094d5b5 --- /dev/null +++ b/rippr-src/gradle/libs.versions.toml @@ -0,0 +1,60 @@ +[versions] +agp = "8.7.3" +kotlin = "2.0.21" +ksp = "2.0.21-1.0.28" +coreKtx = "1.15.0" +lifecycle = "2.8.7" +activityCompose = "1.9.3" +composeBom = "2024.10.01" +room = "2.6.1" +playServicesLocation = "21.3.0" +coroutines = "1.9.0" +okhttp = "4.12.0" +junit = "4.13.2" +androidxJunit = "1.2.1" +espresso = "3.6.1" +testRules = "1.6.1" +fragment = "1.8.5" +navigation = "2.8.5" +lifecycleViewmodel = "2.8.7" +osmdroid = "6.1.20" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodel" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } + +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } + +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } + +play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } + +junit = { group = "junit", name = "junit", version.ref = "junit" } +json = { group = "org.json", name = "json", version = "20240303" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "testRules" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/rippr-src/gradle/wrapper/gradle-wrapper.jar b/rippr-src/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/rippr-src/gradle/wrapper/gradle-wrapper.jar differ diff --git a/rippr-src/gradle/wrapper/gradle-wrapper.properties b/rippr-src/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e3343bc --- /dev/null +++ b/rippr-src/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/rippr-src/gradlew b/rippr-src/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/rippr-src/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/rippr-src/gradlew.bat b/rippr-src/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/rippr-src/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/rippr-src/settings.gradle.kts b/rippr-src/settings.gradle.kts new file mode 100644 index 0000000..06ea2fb --- /dev/null +++ b/rippr-src/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Rippr" +include(":app")