From 280fd7f9887a7fc0d81b3460afd3f7f7fe4a57ed Mon Sep 17 00:00:00 2001 From: uhryniuk Date: Tue, 11 Aug 2026 08:30:25 -0500 Subject: [PATCH] Add Rippr source snapshot and full-history bundle Two copies for two jobs. rippr-src/ is a browsable git archive export of the tracked tree at 2f76983 - no build outputs, no local.properties, no nested .git - which is convenient to read in gitea but carries no history and will drift. rippr-full-history.bundle is the real backup: all 18 commits, verified as "records a complete history" and test-cloned before committing. This matters because ~/dojo/rippr has no git remote and otherwise exists only on one machine. rippr-src/SNAPSHOT.md explains the difference and how to restore. Co-Authored-By: Claude Opus 5 --- rippr-full-history.bundle | Bin 0 -> 238972 bytes rippr-src/.gitignore | 13 + rippr-src/SNAPSHOT.md | 44 ++ rippr-src/app/build.gradle.kts | 112 ++++ rippr-src/app/proguard-rules.pro | 0 .../schemas/com.rippr.data.AppDatabase/2.json | 285 +++++++++ .../com/rippr/TrackingServiceLifecycleTest.kt | 243 ++++++++ .../java/com/rippr/data/MergeTest.kt | 197 ++++++ .../java/com/rippr/data/SchemaTest.kt | 225 +++++++ .../java/com/rippr/data/TripRepositoryTest.kt | 244 ++++++++ rippr-src/app/src/main/AndroidManifest.xml | 56 ++ .../app/src/main/java/com/rippr/Config.kt | 48 ++ .../src/main/java/com/rippr/LiveTelemetry.kt | 30 + .../src/main/java/com/rippr/MainActivity.kt | 75 +++ .../main/java/com/rippr/RideAccumulator.kt | 114 ++++ .../app/src/main/java/com/rippr/RipprApp.kt | 25 + .../app/src/main/java/com/rippr/Telemetry.kt | 66 ++ .../main/java/com/rippr/TelemetryUploader.kt | 99 +++ .../main/java/com/rippr/TrackingService.kt | 526 ++++++++++++++++ .../src/main/java/com/rippr/UploadStatus.kt | 21 + .../main/java/com/rippr/data/AppDatabase.kt | 60 ++ .../src/main/java/com/rippr/data/RideStats.kt | 26 + .../src/main/java/com/rippr/data/Segment.kt | 37 ++ .../main/java/com/rippr/data/SegmentDao.kt | 29 + .../main/java/com/rippr/data/TrackPoint.kt | 47 ++ .../main/java/com/rippr/data/TrackPointDao.kt | 81 +++ .../app/src/main/java/com/rippr/data/Trip.kt | 48 ++ .../src/main/java/com/rippr/data/TripDao.kt | 80 +++ .../java/com/rippr/data/TripRepository.kt | 192 ++++++ .../java/com/rippr/export/ExportManager.kt | 63 ++ .../main/java/com/rippr/export/RideExport.kt | 151 +++++ .../app/src/main/java/com/rippr/geo/Geo.kt | 165 +++++ .../java/com/rippr/stats/RideStatistics.kt | 302 +++++++++ .../app/src/main/java/com/rippr/ui/Format.kt | 40 ++ .../main/java/com/rippr/ui/RipprNavHost.kt | 60 ++ .../main/java/com/rippr/ui/RipprViewModels.kt | 40 ++ .../java/com/rippr/ui/components/RideMap.kt | 220 +++++++ .../java/com/rippr/ui/components/Stats.kt | 67 ++ .../com/rippr/ui/detail/TripDetailScreen.kt | 401 ++++++++++++ .../rippr/ui/detail/TripDetailViewModel.kt | 103 ++++ .../java/com/rippr/ui/record/RecordScreen.kt | 192 ++++++ .../com/rippr/ui/record/RecordViewModel.kt | 91 +++ .../src/main/java/com/rippr/ui/theme/Theme.kt | 54 ++ .../java/com/rippr/ui/trips/TripsScreen.kt | 192 ++++++ .../java/com/rippr/ui/trips/TripsViewModel.kt | 61 ++ .../res/drawable/ic_launcher_foreground.xml | 35 ++ .../res/drawable/ic_launcher_monochrome.xml | 21 + .../src/main/res/drawable/ic_stat_rippr.xml | 18 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + rippr-src/app/src/main/res/values/colors.xml | 4 + rippr-src/app/src/main/res/values/strings.xml | 4 + rippr-src/app/src/main/res/values/themes.xml | 8 + .../app/src/main/res/xml/backup_rules.xml | 2 + rippr-src/app/src/main/res/xml/file_paths.xml | 6 + .../test/java/com/rippr/AccumulatorTest.kt | 181 ++++++ .../src/test/java/com/rippr/TelemetryTest.kt | 98 +++ .../java/com/rippr/TelemetryUploaderTest.kt | 150 +++++ .../java/com/rippr/export/RideExportTest.kt | 211 +++++++ .../src/test/java/com/rippr/geo/GeoTest.kt | 210 +++++++ .../com/rippr/stats/RideStatisticsTest.kt | 265 ++++++++ rippr-src/build.gradle.kts | 6 + rippr-src/design/build_preview.py | 262 ++++++++ rippr-src/design/gen_logos.py | 187 ++++++ rippr-src/design/logo-review.html | 245 ++++++++ rippr-src/design/logos/rippr-apex-onbg.svg | 1 + rippr-src/design/logos/rippr-apex.svg | 1 + rippr-src/design/logos/rippr-heading-onbg.svg | 1 + rippr-src/design/logos/rippr-heading.svg | 1 + rippr-src/design/logos/rippr-route-onbg.svg | 1 + rippr-src/design/logos/rippr-route.svg | 1 + rippr-src/design/material/LICENSE.md | 7 + rippr-src/design/material/explore.svg | 1 + rippr-src/design/material/moving.svg | 1 + rippr-src/design/material/navigation.svg | 1 + rippr-src/design/material/route.svg | 1 + rippr-src/design/material/speed.svg | 1 + .../design/material/sports_motorsports.svg | 1 + rippr-src/design/material/two_wheeler.svg | 1 + rippr-src/design/to_vector_drawable.py | 173 ++++++ rippr-src/docs/v2/01-baseline.md | 76 +++ rippr-src/docs/v2/02-schema.md | 128 ++++ rippr-src/docs/v2/03-repository.md | 112 ++++ rippr-src/docs/v2/04-geo.md | 92 +++ rippr-src/docs/v2/05-stats.md | 126 ++++ rippr-src/docs/v2/06-service-lifecycle.md | 146 +++++ rippr-src/docs/v2/07-aggregates.md | 113 ++++ rippr-src/docs/v2/08-nav-shell.md | 98 +++ rippr-src/docs/v2/09-record-screen.md | 114 ++++ rippr-src/docs/v2/10-trips-list.md | 89 +++ rippr-src/docs/v2/11-trip-detail.md | 86 +++ rippr-src/docs/v2/12-trip-management.md | 109 ++++ rippr-src/docs/v2/13-map-integration.md | 119 ++++ rippr-src/docs/v2/14-path-rendering.md | 108 ++++ rippr-src/docs/v2/15-export-format.md | 118 ++++ rippr-src/docs/v2/16-export-ui.md | 109 ++++ rippr-src/docs/v2/17-uploader.md | 91 +++ rippr-src/docs/v2/18-verification.md | 118 ++++ rippr-src/docs/v2/PROGRESS.md | 571 ++++++++++++++++++ rippr-src/docs/v2/README.md | 85 +++ rippr-src/gradle.properties | 9 + rippr-src/gradle/libs.versions.toml | 60 ++ rippr-src/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47505 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + rippr-src/gradlew | 248 ++++++++ rippr-src/gradlew.bat | 82 +++ rippr-src/settings.gradle.kts | 24 + 106 files changed, 10377 insertions(+) create mode 100644 rippr-full-history.bundle create mode 100644 rippr-src/.gitignore create mode 100644 rippr-src/SNAPSHOT.md create mode 100644 rippr-src/app/build.gradle.kts create mode 100644 rippr-src/app/proguard-rules.pro create mode 100644 rippr-src/app/schemas/com.rippr.data.AppDatabase/2.json create mode 100644 rippr-src/app/src/androidTest/java/com/rippr/TrackingServiceLifecycleTest.kt create mode 100644 rippr-src/app/src/androidTest/java/com/rippr/data/MergeTest.kt create mode 100644 rippr-src/app/src/androidTest/java/com/rippr/data/SchemaTest.kt create mode 100644 rippr-src/app/src/androidTest/java/com/rippr/data/TripRepositoryTest.kt create mode 100644 rippr-src/app/src/main/AndroidManifest.xml create mode 100644 rippr-src/app/src/main/java/com/rippr/Config.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/LiveTelemetry.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/MainActivity.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/RideAccumulator.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/RipprApp.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/Telemetry.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/TelemetryUploader.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/TrackingService.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/UploadStatus.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/data/AppDatabase.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/data/RideStats.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/data/Segment.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/data/SegmentDao.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/data/TrackPoint.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/data/TrackPointDao.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/data/Trip.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/data/TripDao.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/data/TripRepository.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/export/ExportManager.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/export/RideExport.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/geo/Geo.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/Format.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/RipprNavHost.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/RipprViewModels.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/components/RideMap.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/components/Stats.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/detail/TripDetailScreen.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/detail/TripDetailViewModel.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/record/RecordScreen.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/record/RecordViewModel.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/theme/Theme.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/trips/TripsScreen.kt create mode 100644 rippr-src/app/src/main/java/com/rippr/ui/trips/TripsViewModel.kt create mode 100644 rippr-src/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 rippr-src/app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 rippr-src/app/src/main/res/drawable/ic_stat_rippr.xml create mode 100644 rippr-src/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 rippr-src/app/src/main/res/values/colors.xml create mode 100644 rippr-src/app/src/main/res/values/strings.xml create mode 100644 rippr-src/app/src/main/res/values/themes.xml create mode 100644 rippr-src/app/src/main/res/xml/backup_rules.xml create mode 100644 rippr-src/app/src/main/res/xml/file_paths.xml create mode 100644 rippr-src/app/src/test/java/com/rippr/AccumulatorTest.kt create mode 100644 rippr-src/app/src/test/java/com/rippr/TelemetryTest.kt create mode 100644 rippr-src/app/src/test/java/com/rippr/TelemetryUploaderTest.kt create mode 100644 rippr-src/app/src/test/java/com/rippr/export/RideExportTest.kt create mode 100644 rippr-src/app/src/test/java/com/rippr/geo/GeoTest.kt create mode 100644 rippr-src/app/src/test/java/com/rippr/stats/RideStatisticsTest.kt create mode 100644 rippr-src/build.gradle.kts create mode 100644 rippr-src/design/build_preview.py create mode 100644 rippr-src/design/gen_logos.py create mode 100644 rippr-src/design/logo-review.html create mode 100644 rippr-src/design/logos/rippr-apex-onbg.svg create mode 100644 rippr-src/design/logos/rippr-apex.svg create mode 100644 rippr-src/design/logos/rippr-heading-onbg.svg create mode 100644 rippr-src/design/logos/rippr-heading.svg create mode 100644 rippr-src/design/logos/rippr-route-onbg.svg create mode 100644 rippr-src/design/logos/rippr-route.svg create mode 100644 rippr-src/design/material/LICENSE.md create mode 100644 rippr-src/design/material/explore.svg create mode 100644 rippr-src/design/material/moving.svg create mode 100644 rippr-src/design/material/navigation.svg create mode 100644 rippr-src/design/material/route.svg create mode 100644 rippr-src/design/material/speed.svg create mode 100644 rippr-src/design/material/sports_motorsports.svg create mode 100644 rippr-src/design/material/two_wheeler.svg create mode 100644 rippr-src/design/to_vector_drawable.py create mode 100644 rippr-src/docs/v2/01-baseline.md create mode 100644 rippr-src/docs/v2/02-schema.md create mode 100644 rippr-src/docs/v2/03-repository.md create mode 100644 rippr-src/docs/v2/04-geo.md create mode 100644 rippr-src/docs/v2/05-stats.md create mode 100644 rippr-src/docs/v2/06-service-lifecycle.md create mode 100644 rippr-src/docs/v2/07-aggregates.md create mode 100644 rippr-src/docs/v2/08-nav-shell.md create mode 100644 rippr-src/docs/v2/09-record-screen.md create mode 100644 rippr-src/docs/v2/10-trips-list.md create mode 100644 rippr-src/docs/v2/11-trip-detail.md create mode 100644 rippr-src/docs/v2/12-trip-management.md create mode 100644 rippr-src/docs/v2/13-map-integration.md create mode 100644 rippr-src/docs/v2/14-path-rendering.md create mode 100644 rippr-src/docs/v2/15-export-format.md create mode 100644 rippr-src/docs/v2/16-export-ui.md create mode 100644 rippr-src/docs/v2/17-uploader.md create mode 100644 rippr-src/docs/v2/18-verification.md create mode 100644 rippr-src/docs/v2/PROGRESS.md create mode 100644 rippr-src/docs/v2/README.md create mode 100644 rippr-src/gradle.properties create mode 100644 rippr-src/gradle/libs.versions.toml create mode 100644 rippr-src/gradle/wrapper/gradle-wrapper.jar create mode 100644 rippr-src/gradle/wrapper/gradle-wrapper.properties create mode 100755 rippr-src/gradlew create mode 100644 rippr-src/gradlew.bat create mode 100644 rippr-src/settings.gradle.kts diff --git a/rippr-full-history.bundle b/rippr-full-history.bundle new file mode 100644 index 0000000000000000000000000000000000000000..5f9f6c94ebbcaad639ac6e4261defb34d6ab5248 GIT binary patch literal 238972 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 jjp2Bd-XAw+;UbHaqz& literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 GIT binary patch literal 47505 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 '} + 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")