Add Rippr source snapshot and full-history bundle
Two copies for two jobs. rippr-src/ is a browsable git archive export of the tracked tree at 2f76983 - no build outputs, no local.properties, no nested .git - which is convenient to read in gitea but carries no history and will drift. rippr-full-history.bundle is the real backup: all 18 commits, verified as "records a complete history" and test-cloned before committing. This matters because ~/dojo/rippr has no git remote and otherwise exists only on one machine. rippr-src/SNAPSHOT.md explains the difference and how to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
package com.rippr
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.rippr.data.AppDatabase
|
||||
import com.rippr.data.TripRepository
|
||||
import com.rippr.data.TripState
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Drives the real foreground service through its five actions and asserts the trip and
|
||||
* segment structure that results.
|
||||
*
|
||||
* The service is `exported=false`, so `adb shell am start-service` is correctly refused —
|
||||
* this is the only way to exercise the lifecycle without tapping notification actions.
|
||||
* No GPS is injected, so no points are recorded; what is under test here is the segment
|
||||
* bookkeeping, which is what T06 changed.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TrackingServiceLifecycleTest {
|
||||
|
||||
@get:Rule
|
||||
val permissions: GrantPermissionRule = GrantPermissionRule.grant(
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
android.Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
)
|
||||
|
||||
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
private lateinit var db: AppDatabase
|
||||
private lateinit var repo: TripRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// Substitute an in-memory database for the singleton the service resolves.
|
||||
//
|
||||
// This test previously ran against the *real* app database and wiped it in
|
||||
// setUp/tearDown. Harmless on a throwaway emulator, but running the suite
|
||||
// against a personal phone would have destroyed every recorded ride.
|
||||
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
AppDatabase.overrideForTest(db)
|
||||
repo = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
|
||||
TripRepository.overrideForTest(repo)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
send(TrackingService.ACTION_STOP)
|
||||
awaitOrNull(timeoutMs = 15_000) { repo.activeTrip() == null }
|
||||
// Let the service finish tearing down before the next test starts one, so a
|
||||
// stale instance cannot service the next START.
|
||||
Thread.sleep(500)
|
||||
db.close()
|
||||
// Restore the real singletons so no other test inherits the in-memory database.
|
||||
AppDatabase.overrideForTest(null)
|
||||
TripRepository.overrideForTest(null)
|
||||
}
|
||||
|
||||
private fun send(action: String) {
|
||||
val intent = Intent(context, TrackingService::class.java).setAction(action)
|
||||
if (action == TrackingService.ACTION_START) {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** The service works asynchronously; poll rather than guessing at a sleep. */
|
||||
private fun await(timeoutMs: Long = 25_000, condition: suspend () -> Boolean) {
|
||||
assertTrue(
|
||||
"condition not met within ${timeoutMs}ms",
|
||||
awaitOrNull(timeoutMs, condition),
|
||||
)
|
||||
}
|
||||
|
||||
private fun awaitOrNull(timeoutMs: Long, condition: suspend () -> Boolean): Boolean =
|
||||
runBlocking {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (condition()) return@runBlocking true
|
||||
Thread.sleep(200)
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
private suspend fun openSegments(tripId: Long) =
|
||||
db.segmentDao().forTrip(tripId).count { it.isOpen }
|
||||
|
||||
@Test
|
||||
fun startOpensATripAndASegment() {
|
||||
send(TrackingService.ACTION_START)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
|
||||
runBlocking {
|
||||
val trip = repo.activeTrip()!!
|
||||
assertNull("an active trip has no end time", trip.endedAt)
|
||||
assertEquals(1, db.segmentDao().countForTrip(trip.id))
|
||||
assertEquals(1, openSegments(trip.id))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pauseClosesTheSegmentButKeepsTheTripActive() {
|
||||
send(TrackingService.ACTION_START)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
|
||||
send(TrackingService.ACTION_PAUSE)
|
||||
await { repo.activeTrip()?.state == TripState.PAUSED }
|
||||
|
||||
runBlocking {
|
||||
val trip = repo.activeTrip()!!
|
||||
assertNull("pause must not end the trip", trip.endedAt)
|
||||
assertEquals("no segment may stay open while paused", 0, openSegments(trip.id))
|
||||
assertEquals(1, db.segmentDao().countForTrip(trip.id))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeOpensASecondSegmentSoThePauseLeavesAGap() {
|
||||
send(TrackingService.ACTION_START)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
send(TrackingService.ACTION_PAUSE)
|
||||
await { repo.activeTrip()?.state == TripState.PAUSED }
|
||||
|
||||
send(TrackingService.ACTION_RESUME)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
|
||||
runBlocking {
|
||||
val trip = repo.activeTrip()!!
|
||||
assertEquals("resume must not reuse the closed segment", 2, db.segmentDao().countForTrip(trip.id))
|
||||
assertEquals(1, openSegments(trip.id))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedPauseDoesNotAccumulateSegments() {
|
||||
send(TrackingService.ACTION_START)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
|
||||
repeat(3) {
|
||||
send(TrackingService.ACTION_PAUSE)
|
||||
await { repo.activeTrip()?.state == TripState.PAUSED }
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
assertEquals(1, db.segmentDao().countForTrip(repo.activeTrip()!!.id))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopClosesTheTripAndEverySegment() {
|
||||
send(TrackingService.ACTION_START)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
val tripId = runBlocking { repo.activeTrip()!!.id }
|
||||
runBlocking { seedPoint(tripId) }
|
||||
|
||||
send(TrackingService.ACTION_STOP)
|
||||
await { repo.activeTrip() == null }
|
||||
|
||||
runBlocking {
|
||||
val trip = db.tripDao().getById(tripId)!!
|
||||
assertEquals(TripState.COMPLETED, trip.state)
|
||||
assertNotNull(trip.endedAt)
|
||||
assertEquals(0, openSegments(tripId))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stoppingARideThatCapturedNothingDiscardsIt() {
|
||||
send(TrackingService.ACTION_START)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
val tripId = runBlocking { repo.activeTrip()!!.id }
|
||||
|
||||
// No GPS is injected here, so the trip has no points.
|
||||
send(TrackingService.ACTION_STOP)
|
||||
await { repo.activeTrip() == null }
|
||||
|
||||
runBlocking {
|
||||
assertNull("an empty ride is clutter, not history", db.tripDao().getById(tripId))
|
||||
}
|
||||
}
|
||||
|
||||
/** Gives a trip one point so stop treats it as a real ride rather than empty. */
|
||||
private suspend fun seedPoint(tripId: Long) {
|
||||
val segmentId = db.segmentDao().openSegment(tripId)?.id ?: return
|
||||
db.trackPointDao().insertPoint(
|
||||
com.rippr.data.TrackPoint(
|
||||
tripId = tripId, segmentId = segmentId, timestamp = 1_000,
|
||||
latitude = 51.0, longitude = -114.0, speedKmh = 40f, altitudeM = 1000.0,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discardDeletesTheTripEntirely() {
|
||||
send(TrackingService.ACTION_START)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
val tripId = runBlocking { repo.activeTrip()!!.id }
|
||||
|
||||
send(TrackingService.ACTION_DISCARD)
|
||||
await { repo.activeTrip() == null }
|
||||
|
||||
runBlocking {
|
||||
assertNull("discard must remove the row, not close it", db.tripDao().getById(tripId))
|
||||
assertEquals(0, db.trackPointDao().countForTrip(tripId))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFullPauseResumeCycleProducesExactlyTwoSegments() {
|
||||
send(TrackingService.ACTION_START)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
val tripId = runBlocking { repo.activeTrip()!!.id }
|
||||
runBlocking { seedPoint(tripId) }
|
||||
|
||||
send(TrackingService.ACTION_PAUSE)
|
||||
await { repo.activeTrip()?.state == TripState.PAUSED }
|
||||
send(TrackingService.ACTION_RESUME)
|
||||
await { repo.activeTrip()?.state == TripState.RECORDING }
|
||||
send(TrackingService.ACTION_STOP)
|
||||
await { repo.activeTrip() == null }
|
||||
|
||||
runBlocking {
|
||||
assertEquals(2, db.segmentDao().countForTrip(tripId))
|
||||
assertEquals(0, openSegments(tripId))
|
||||
assertEquals(TripState.COMPLETED, db.tripDao().getById(tripId)!!.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
197
rippr-src/app/src/androidTest/java/com/rippr/data/MergeTest.kt
Normal file
197
rippr-src/app/src/androidTest/java/com/rippr/data/MergeTest.kt
Normal file
@@ -0,0 +1,197 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.rippr.stats.RideStatistics
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MergeTest {
|
||||
|
||||
private lateinit var db: AppDatabase
|
||||
private lateinit var repo: TripRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java,
|
||||
).build()
|
||||
repo = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
/** A completed trip with one segment and [points] fixes walking north. */
|
||||
private suspend fun completedTrip(
|
||||
startedAt: Long,
|
||||
points: Int,
|
||||
startLat: Double,
|
||||
name: String? = null,
|
||||
): Long {
|
||||
val tripId = db.tripDao().insert(
|
||||
Trip(startedAt = startedAt, endedAt = startedAt + points * 1_000L,
|
||||
name = name, state = TripState.COMPLETED)
|
||||
)
|
||||
val segmentId = db.segmentDao().insert(
|
||||
Segment(tripId = tripId, startedAt = startedAt, endedAt = startedAt + points * 1_000L)
|
||||
)
|
||||
db.trackPointDao().insertPoints(
|
||||
(0 until points).map {
|
||||
TrackPoint(
|
||||
tripId = tripId,
|
||||
segmentId = segmentId,
|
||||
timestamp = startedAt + it * 1_000L,
|
||||
latitude = startLat + it * 0.0001,
|
||||
longitude = -114.0,
|
||||
speedKmh = 40f,
|
||||
altitudeM = 1000.0,
|
||||
)
|
||||
}
|
||||
)
|
||||
repo.recomputeAggregates(tripId)
|
||||
return tripId
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeReparentsEverythingAndDeletesTheAbsorbedRow() = runTest {
|
||||
val early = completedTrip(startedAt = 1_000, points = 10, startLat = 51.0)
|
||||
val late = completedTrip(startedAt = 900_000, points = 15, startLat = 52.0)
|
||||
|
||||
val survivor = repo.mergeTrips(early, late)
|
||||
|
||||
assertEquals("the earlier trip must survive", early, survivor)
|
||||
assertNull("absorbed row must be gone", db.tripDao().getById(late))
|
||||
assertEquals(2, db.segmentDao().countForTrip(early))
|
||||
assertEquals("no point may be lost", 25, db.trackPointDao().countForTrip(early))
|
||||
assertEquals(25, db.trackPointDao().getAllPoints().size)
|
||||
assertEquals(1, db.tripDao().count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectionOrderDoesNotDecideTheSurvivor() = runTest {
|
||||
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
|
||||
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0)
|
||||
|
||||
// Deliberately passed later-first.
|
||||
assertEquals(early, repo.mergeTrips(late, early))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergedAggregatesAreRecomputedNotSummed() = runTest {
|
||||
val early = completedTrip(startedAt = 1_000, points = 10, startLat = 51.0)
|
||||
val late = completedTrip(startedAt = 900_000, points = 15, startLat = 52.0)
|
||||
val distanceBefore = db.tripDao().getById(early)!!.distanceM +
|
||||
db.tripDao().getById(late)!!.distanceM
|
||||
|
||||
repo.mergeTrips(early, late)
|
||||
|
||||
val merged = db.tripDao().getById(early)!!
|
||||
val expected = RideStatistics.compute(
|
||||
db.trackPointDao().forTrip(early),
|
||||
db.segmentDao().forTrip(early),
|
||||
)
|
||||
assertEquals(expected.distanceM, merged.distanceM, 0.01)
|
||||
assertEquals(25, merged.pointCount)
|
||||
// The ~111 km between the two rides must not appear anywhere.
|
||||
assertEquals(
|
||||
"summing would have added the inter-ride gap",
|
||||
distanceBefore,
|
||||
merged.distanceM,
|
||||
1.0,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theJoinRemainsASegmentBoundary() = runTest {
|
||||
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
|
||||
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0)
|
||||
|
||||
repo.mergeTrips(early, late)
|
||||
|
||||
val segments = db.segmentDao().forTrip(early)
|
||||
assertEquals("segments must never be joined", 2, segments.size)
|
||||
val points = db.trackPointDao().forTrip(early)
|
||||
assertEquals(2, points.map { it.segmentId }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun endedAtBecomesTheLaterOfTheTwo() = runTest {
|
||||
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
|
||||
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0)
|
||||
val lateEnd = db.tripDao().getById(late)!!.endedAt!!
|
||||
|
||||
repo.mergeTrips(early, late)
|
||||
|
||||
assertEquals(lateEnd, db.tripDao().getById(early)!!.endedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnnamedSurvivorInheritsTheOtherName() = runTest {
|
||||
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0, name = null)
|
||||
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0, name = "Kananaskis")
|
||||
|
||||
repo.mergeTrips(early, late)
|
||||
|
||||
assertEquals("Kananaskis", db.tripDao().getById(early)!!.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExistingSurvivorNameIsKept() = runTest {
|
||||
val early = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0, name = "Morning loop")
|
||||
val late = completedTrip(startedAt = 900_000, points = 5, startLat = 52.0, name = "Evening")
|
||||
|
||||
repo.mergeTrips(early, late)
|
||||
|
||||
assertEquals("Morning loop", db.tripDao().getById(early)!!.name)
|
||||
}
|
||||
|
||||
// --- Rejections -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun mergingATripWithItselfIsRejected() = runTest {
|
||||
val trip = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
|
||||
assertNull(repo.mergeTrips(trip, trip))
|
||||
assertEquals(5, db.trackPointDao().countForTrip(trip))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergingAnActiveTripIsRejected() = runTest {
|
||||
val completed = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
|
||||
val active = repo.startTrip(900_000)
|
||||
|
||||
assertNull(repo.mergeTrips(completed, active.tripId))
|
||||
assertNotNull("the active trip must survive untouched", repo.activeTrip())
|
||||
assertEquals(2, db.tripDao().count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergingAMissingTripIsRejected() = runTest {
|
||||
val trip = completedTrip(startedAt = 1_000, points = 5, startLat = 51.0)
|
||||
assertNull(repo.mergeTrips(trip, 9_999))
|
||||
assertEquals(1, db.tripDao().count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeIsAtomicAndLeavesNoOrphans() = runTest {
|
||||
val early = completedTrip(startedAt = 1_000, points = 8, startLat = 51.0)
|
||||
val late = completedTrip(startedAt = 900_000, points = 8, startLat = 52.0)
|
||||
|
||||
repo.mergeTrips(early, late)
|
||||
|
||||
val orphanSegments = db.segmentDao().forTrip(late)
|
||||
assertTrue("segments left behind on the deleted trip", orphanSegments.isEmpty())
|
||||
assertEquals(0, db.trackPointDao().countForTrip(late))
|
||||
assertEquals(16, db.trackPointDao().getAllPoints().size)
|
||||
}
|
||||
}
|
||||
225
rippr-src/app/src/androidTest/java/com/rippr/data/SchemaTest.kt
Normal file
225
rippr-src/app/src/androidTest/java/com/rippr/data/SchemaTest.kt
Normal file
@@ -0,0 +1,225 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SchemaTest {
|
||||
|
||||
private lateinit var db: AppDatabase
|
||||
private lateinit var trips: TripDao
|
||||
private lateinit var segments: SegmentDao
|
||||
private lateinit var points: TrackPointDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java,
|
||||
).build()
|
||||
trips = db.tripDao()
|
||||
segments = db.segmentDao()
|
||||
points = db.trackPointDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private suspend fun seedTrip(startedAt: Long = 1_000): Pair<Long, Long> {
|
||||
val tripId = trips.insert(Trip(startedAt = startedAt))
|
||||
val segmentId = segments.insert(Segment(tripId = tripId, startedAt = startedAt))
|
||||
return tripId to segmentId
|
||||
}
|
||||
|
||||
private fun point(tripId: Long, segmentId: Long, ts: Long, speed: Float = 50f) = TrackPoint(
|
||||
tripId = tripId,
|
||||
segmentId = segmentId,
|
||||
timestamp = ts,
|
||||
latitude = 51.0447,
|
||||
longitude = -114.0719,
|
||||
speedKmh = speed,
|
||||
altitudeM = 1045.0,
|
||||
)
|
||||
|
||||
// --- Cascade -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deletingATripCascadesToSegmentsAndPoints() = runTest {
|
||||
val (tripId, segmentId) = seedTrip()
|
||||
points.insertPoints(List(5) { point(tripId, segmentId, 1_000L + it) })
|
||||
assertEquals(5, points.countForTrip(tripId))
|
||||
|
||||
trips.deleteById(tripId)
|
||||
|
||||
assertEquals(0, segments.countForTrip(tripId))
|
||||
assertEquals(0, points.countForTrip(tripId))
|
||||
assertTrue(points.getAllPoints().isEmpty())
|
||||
}
|
||||
|
||||
// --- Active trip -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun observeActiveEmitsNullOnAnEmptyDatabase() = runTest {
|
||||
assertNull(trips.observeActive().first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeActiveTracksTheOpenTripAndClearsWhenClosed() = runTest {
|
||||
val (tripId, _) = seedTrip()
|
||||
assertNotNull(trips.observeActive().first())
|
||||
|
||||
trips.close(tripId, endedAt = 9_000)
|
||||
|
||||
assertNull("a closed trip must not read as active", trips.observeActive().first())
|
||||
assertEquals(TripState.COMPLETED, trips.getById(tripId)?.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedTripsAreListedNewestFirstAndExcludeTheActiveOne() = runTest {
|
||||
val older = trips.insert(Trip(startedAt = 1_000))
|
||||
val newer = trips.insert(Trip(startedAt = 5_000))
|
||||
trips.close(older, endedAt = 2_000)
|
||||
trips.close(newer, endedAt = 6_000)
|
||||
trips.insert(Trip(startedAt = 9_000)) // still active
|
||||
|
||||
val listed = trips.observeCompleted().first()
|
||||
|
||||
assertEquals(listOf(newer, older), listed.map { it.id })
|
||||
}
|
||||
|
||||
// --- Segments ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun openSegmentReturnsOnlyTheUnclosedOne() = runTest {
|
||||
val (tripId, first) = seedTrip()
|
||||
segments.close(first, endedAt = 2_000)
|
||||
assertNull(segments.openSegment(tripId))
|
||||
|
||||
val second = segments.insert(Segment(tripId = tripId, startedAt = 3_000))
|
||||
assertEquals(second, segments.openSegment(tripId)?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pointsAreOrderedBySegmentThenId() = runTest {
|
||||
val (tripId, firstSegment) = seedTrip()
|
||||
val secondSegment = segments.insert(Segment(tripId = tripId, startedAt = 5_000))
|
||||
|
||||
// Interleave the inserts so ordering cannot pass by luck of insertion order.
|
||||
points.insertPoint(point(tripId, firstSegment, 1_000))
|
||||
points.insertPoint(point(tripId, secondSegment, 5_000))
|
||||
points.insertPoint(point(tripId, firstSegment, 2_000))
|
||||
points.insertPoint(point(tripId, secondSegment, 6_000))
|
||||
|
||||
val ordered = points.forTrip(tripId)
|
||||
|
||||
assertEquals(
|
||||
listOf(firstSegment, firstSegment, secondSegment, secondSegment),
|
||||
ordered.map { it.segmentId },
|
||||
)
|
||||
assertEquals(listOf(1_000L, 2_000L, 5_000L, 6_000L), ordered.map { it.timestamp })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastInSegmentReturnsTheMostRecentlyWrittenPoint() = runTest {
|
||||
val (tripId, segmentId) = seedTrip()
|
||||
points.insertPoints(List(3) { point(tripId, segmentId, 1_000L + it) })
|
||||
|
||||
assertEquals(1_002L, points.lastInSegment(segmentId)?.timestamp)
|
||||
assertNull(points.lastInSegment(999))
|
||||
}
|
||||
|
||||
// --- Aggregates --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun statsForATripWithNoPointsAreZeroNotNull() = runTest {
|
||||
val (tripId, _) = seedTrip()
|
||||
|
||||
// COALESCE guards: without them Room throws on RideStats' non-null fields.
|
||||
val stats = points.observeTripStats(tripId).first()
|
||||
|
||||
assertEquals(0, stats.pointCount)
|
||||
assertEquals(0f, stats.maxSpeedKmh, 0.001f)
|
||||
assertEquals(0L, stats.durationMillis)
|
||||
assertEquals(0, stats.pendingUpload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statsAreScopedToOneTrip() = runTest {
|
||||
val (tripA, segA) = seedTrip(startedAt = 1_000)
|
||||
val (tripB, segB) = seedTrip(startedAt = 5_000)
|
||||
points.insertPoints(listOf(point(tripA, segA, 1_000, 40f), point(tripA, segA, 3_000, 120f)))
|
||||
points.insertPoints(listOf(point(tripB, segB, 5_000, 200f)))
|
||||
|
||||
val a = points.observeTripStats(tripA).first()
|
||||
|
||||
assertEquals(2, a.pointCount)
|
||||
assertEquals("trip B must not leak into trip A", 120f, a.maxSpeedKmh, 0.001f)
|
||||
assertEquals(2_000L, a.durationMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aggregateColumnsRoundTripOnTheTripRow() = runTest {
|
||||
val (tripId, _) = seedTrip()
|
||||
|
||||
trips.updateAggregates(
|
||||
id = tripId,
|
||||
distanceM = 47_231.5,
|
||||
movingMillis = 4_320_000,
|
||||
maxSpeedKmh = 118.4f,
|
||||
elevationGainM = 612.0,
|
||||
pointCount = 8_640,
|
||||
)
|
||||
|
||||
val trip = trips.getById(tripId)!!
|
||||
assertEquals(47_231.5, trip.distanceM, 0.001)
|
||||
assertEquals(4_320_000L, trip.movingMillis)
|
||||
assertEquals(118.4f, trip.maxSpeedKmh, 0.001f)
|
||||
assertEquals(612.0, trip.elevationGainM, 0.001)
|
||||
assertEquals(8_640, trip.pointCount)
|
||||
}
|
||||
|
||||
// --- Upload backlog ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun syncedFlagStillDrivesTheUploadBacklog() = runTest {
|
||||
val (tripId, segmentId) = seedTrip()
|
||||
points.insertPoints(List(4) { point(tripId, segmentId, 1_000L + it) })
|
||||
|
||||
val pending = points.getUnsyncedPoints(100)
|
||||
assertEquals(4, pending.size)
|
||||
|
||||
points.markSynced(pending.take(2).map { it.id })
|
||||
|
||||
assertEquals(2, points.countUnsynced())
|
||||
assertEquals(2, points.observePendingUpload(tripId).first())
|
||||
}
|
||||
|
||||
// --- Merge support -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun reparentMovesSegmentsAndPointsToTheSurvivingTrip() = runTest {
|
||||
val (survivor, survivorSeg) = seedTrip(startedAt = 1_000)
|
||||
val (absorbed, absorbedSeg) = seedTrip(startedAt = 5_000)
|
||||
points.insertPoints(listOf(point(survivor, survivorSeg, 1_000)))
|
||||
points.insertPoints(List(3) { point(absorbed, absorbedSeg, 5_000L + it) })
|
||||
|
||||
segments.reparent(oldTripId = absorbed, newTripId = survivor)
|
||||
points.reparent(oldTripId = absorbed, newTripId = survivor)
|
||||
trips.deleteById(absorbed)
|
||||
|
||||
assertEquals(2, segments.countForTrip(survivor))
|
||||
assertEquals("no point may be lost to the CASCADE", 4, points.countForTrip(survivor))
|
||||
assertEquals(4, points.getAllPoints().size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TripRepositoryTest {
|
||||
|
||||
private lateinit var db: AppDatabase
|
||||
private lateinit var repo: TripRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java,
|
||||
).build()
|
||||
repo = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private suspend fun addPoint(handle: TripHandle, ts: Long) =
|
||||
db.trackPointDao().insertPoint(
|
||||
TrackPoint(
|
||||
tripId = handle.tripId,
|
||||
segmentId = handle.segmentId,
|
||||
timestamp = ts,
|
||||
latitude = 51.0,
|
||||
longitude = -114.0,
|
||||
speedKmh = 50f,
|
||||
altitudeM = 1000.0,
|
||||
)
|
||||
)
|
||||
|
||||
// --- Happy path --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun fullLifecycleProducesOneTripAndTwoSegments() = runTest {
|
||||
val first = repo.startTrip(1_000)
|
||||
addPoint(first, 1_100)
|
||||
|
||||
repo.pauseTrip(2_000)
|
||||
val second = repo.resumeTrip(3_000)!!
|
||||
addPoint(second, 3_100)
|
||||
repo.completeTrip(4_000)
|
||||
|
||||
assertNotEquals("resume must open a new segment", first.segmentId, second.segmentId)
|
||||
assertEquals(first.tripId, second.tripId)
|
||||
|
||||
val segments = repo.segmentsForTrip(first.tripId)
|
||||
assertEquals(2, segments.size)
|
||||
assertTrue("every segment must be closed", segments.all { !it.isOpen })
|
||||
|
||||
val trip = db.tripDao().getById(first.tripId)!!
|
||||
assertEquals(TripState.COMPLETED, trip.state)
|
||||
assertEquals(4_000L, trip.endedAt)
|
||||
assertNull(repo.activeTrip())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pauseClosesTheSegmentButLeavesTheTripOpen() = runTest {
|
||||
val handle = repo.startTrip(1_000)
|
||||
repo.pauseTrip(2_000)
|
||||
|
||||
val trip = repo.activeTrip()
|
||||
assertNotNull("a paused trip is still active", trip)
|
||||
assertEquals(TripState.PAUSED, trip!!.state)
|
||||
assertNull("endedAt belongs to completeTrip, not pause", trip.endedAt)
|
||||
assertNull(db.segmentDao().openSegment(handle.tripId))
|
||||
}
|
||||
|
||||
// --- Idempotency: the OS can restart the service at any moment ----------
|
||||
|
||||
@Test
|
||||
fun startTripAdoptsAnAlreadyActiveTripInsteadOfCreatingASecond() = runTest {
|
||||
val first = repo.startTrip(1_000)
|
||||
val second = repo.startTrip(2_000)
|
||||
|
||||
assertEquals("must adopt, not duplicate", first.tripId, second.tripId)
|
||||
assertEquals("must reuse the open segment", first.segmentId, second.segmentId)
|
||||
assertEquals(1, db.tripDao().count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeWhileAlreadyRecordingIsANoOp() = runTest {
|
||||
val first = repo.startTrip(1_000)
|
||||
val again = repo.resumeTrip(2_000)!!
|
||||
|
||||
assertEquals(first.segmentId, again.segmentId)
|
||||
assertEquals("no duplicate segment", 1, db.segmentDao().countForTrip(first.tripId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doublePauseIsANoOp() = runTest {
|
||||
val handle = repo.startTrip(1_000)
|
||||
assertTrue(repo.pauseTrip(2_000))
|
||||
assertTrue(repo.pauseTrip(3_000))
|
||||
assertEquals(1, db.segmentDao().countForTrip(handle.tripId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transitionsWithNoActiveTripAreSafeNoOps() = runTest {
|
||||
assertFalse(repo.pauseTrip(1_000))
|
||||
assertNull(repo.resumeTrip(1_000))
|
||||
assertNull(repo.completeTrip(1_000))
|
||||
assertFalse(repo.discardTrip())
|
||||
assertEquals(0, db.tripDao().count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startAfterCompleteBeginsAFreshTrip() = runTest {
|
||||
val first = repo.startTrip(1_000)
|
||||
repo.completeTrip(2_000)
|
||||
val second = repo.startTrip(3_000)
|
||||
|
||||
assertNotEquals(first.tripId, second.tripId)
|
||||
assertEquals(2, db.tripDao().count())
|
||||
}
|
||||
|
||||
// --- Discard -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun discardRemovesTheTripAndAllItsPoints() = runTest {
|
||||
val handle = repo.startTrip(1_000)
|
||||
repeat(5) { addPoint(handle, 1_000L + it) }
|
||||
|
||||
assertTrue(repo.discardTrip())
|
||||
|
||||
assertNull(repo.activeTrip())
|
||||
assertEquals(0, db.tripDao().count())
|
||||
assertTrue(db.trackPointDao().getAllPoints().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discardLeavesEarlierCompletedTripsAlone() = runTest {
|
||||
val keep = repo.startTrip(1_000)
|
||||
addPoint(keep, 1_100)
|
||||
repo.completeTrip(2_000)
|
||||
|
||||
val throwaway = repo.startTrip(3_000)
|
||||
addPoint(throwaway, 3_100)
|
||||
repo.discardTrip()
|
||||
|
||||
assertEquals(1, db.tripDao().count())
|
||||
assertEquals(1, db.trackPointDao().countForTrip(keep.tripId))
|
||||
}
|
||||
|
||||
// --- Reactive state ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun activeTripFlowTracksTheLifecycle() = runTest {
|
||||
assertNull(repo.observeActiveTrip().first())
|
||||
|
||||
repo.startTrip(1_000)
|
||||
assertNotNull(repo.observeActiveTrip().first())
|
||||
|
||||
repo.pauseTrip(2_000)
|
||||
assertEquals(
|
||||
"a paused ride must still read as active",
|
||||
TripState.PAUSED,
|
||||
repo.observeActiveTrip().first()?.state,
|
||||
)
|
||||
|
||||
repo.completeTrip(3_000)
|
||||
assertNull(repo.observeActiveTrip().first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedTripsFlowExcludesTheActiveTrip() = runTest {
|
||||
repo.startTrip(1_000)
|
||||
repo.completeTrip(2_000)
|
||||
repo.startTrip(3_000)
|
||||
|
||||
assertEquals(1, repo.observeCompletedTrips().first().size)
|
||||
}
|
||||
|
||||
// --- Rename ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun renameStoresNullRatherThanAnEmptyString() = runTest {
|
||||
val handle = repo.startTrip(1_000)
|
||||
|
||||
repo.renameTrip(handle.tripId, "Kananaskis loop")
|
||||
assertEquals("Kananaskis loop", db.tripDao().getById(handle.tripId)?.name)
|
||||
|
||||
repo.renameTrip(handle.tripId, " ")
|
||||
assertNull(
|
||||
"blank must collapse to null or the date-label branch diverges",
|
||||
db.tripDao().getById(handle.tripId)?.name,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameTrimsSurroundingWhitespace() = runTest {
|
||||
val handle = repo.startTrip(1_000)
|
||||
repo.renameTrip(handle.tripId, " Bow Valley ")
|
||||
assertEquals("Bow Valley", db.tripDao().getById(handle.tripId)?.name)
|
||||
}
|
||||
|
||||
// --- Process-death simulation ------------------------------------------
|
||||
|
||||
@Test
|
||||
fun aFreshRepositoryOverTheSameDatabaseSeesTheActiveTrip() = runTest {
|
||||
val handle = repo.startTrip(1_000)
|
||||
addPoint(handle, 1_100)
|
||||
|
||||
// Stands in for the service being killed and restarted: new object graph, same
|
||||
// database. An in-memory flag would have come back false here.
|
||||
val restarted = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
|
||||
|
||||
val adopted = restarted.startTrip(5_000)
|
||||
assertEquals(handle.tripId, adopted.tripId)
|
||||
assertEquals("the open segment is resumed, not replaced", handle.segmentId, adopted.segmentId)
|
||||
assertEquals(1, db.tripDao().count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restartAfterPauseResumesIntoANewSegment() = runTest {
|
||||
val handle = repo.startTrip(1_000)
|
||||
repo.pauseTrip(2_000)
|
||||
|
||||
val restarted = TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
|
||||
val resumed = restarted.resumeTrip(5_000)!!
|
||||
|
||||
assertEquals(handle.tripId, resumed.tripId)
|
||||
assertNotEquals(handle.segmentId, resumed.segmentId)
|
||||
assertEquals(TripState.RECORDING, restarted.activeTrip()?.state)
|
||||
}
|
||||
}
|
||||
56
rippr-src/app/src/main/AndroidManifest.xml
Normal file
56
rippr-src/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="true" />
|
||||
|
||||
<application
|
||||
android:name=".RipprApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Rippr">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.Rippr">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Required since Android 7: a raw file:// URI in an Intent throws
|
||||
FileUriExposedException at share time, not at build time. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".TrackingService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false"
|
||||
android:foregroundServiceType="location" />
|
||||
</application>
|
||||
</manifest>
|
||||
48
rippr-src/app/src/main/java/com/rippr/Config.kt
Normal file
48
rippr-src/app/src/main/java/com/rippr/Config.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.rippr
|
||||
|
||||
import android.content.Context
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Runtime configuration. The upload endpoint is intentionally empty by default —
|
||||
* recording must work with no server at all, and uploading is opt-in.
|
||||
*/
|
||||
object Config {
|
||||
|
||||
private const val PREFS = "rippr_prefs"
|
||||
private const val KEY_ENDPOINT = "upload_endpoint"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val KEY_MAP_ENABLED = "map_enabled"
|
||||
|
||||
fun uploadEndpoint(context: Context): String =
|
||||
prefs(context).getString(KEY_ENDPOINT, "").orEmpty()
|
||||
|
||||
fun setUploadEndpoint(context: Context, url: String) {
|
||||
prefs(context).edit().putString(KEY_ENDPOINT, url.trim()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether trip detail renders a map at all.
|
||||
*
|
||||
* When off the map composable is never created, so no tile is ever requested — a real
|
||||
* short-circuit, not a hidden view. The map only ever exists inside the detail
|
||||
* screen's Compose lifecycle; the recording service never touches it.
|
||||
*/
|
||||
fun mapEnabled(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_MAP_ENABLED, true)
|
||||
|
||||
fun setMapEnabled(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_MAP_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
/** Stable per-install id so the server can distinguish riders in a group. */
|
||||
fun deviceId(context: Context): String {
|
||||
val p = prefs(context)
|
||||
return p.getString(KEY_DEVICE_ID, null) ?: UUID.randomUUID().toString().also {
|
||||
p.edit().putString(KEY_DEVICE_ID, it).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
30
rippr-src/app/src/main/java/com/rippr/LiveTelemetry.kt
Normal file
30
rippr-src/app/src/main/java/com/rippr/LiveTelemetry.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.rippr
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* The most recent fix, published straight from the location callback.
|
||||
*
|
||||
* The Trip row is only written every ~2 s and carries *max* speed, not current, so the
|
||||
* recording screen cannot show a live speedo from it. This is deliberately ephemeral —
|
||||
* losing it on restart is correct, and nothing durable depends on it.
|
||||
*/
|
||||
object LiveTelemetry {
|
||||
private val _speedKmh = MutableStateFlow(0f)
|
||||
val speedKmh: StateFlow<Float> = _speedKmh.asStateFlow()
|
||||
|
||||
private val _accuracyM = MutableStateFlow(0f)
|
||||
val accuracyM: StateFlow<Float> = _accuracyM.asStateFlow()
|
||||
|
||||
fun update(speedKmh: Float, accuracyM: Float) {
|
||||
_speedKmh.value = speedKmh
|
||||
_accuracyM.value = accuracyM
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_speedKmh.value = 0f
|
||||
_accuracyM.value = 0f
|
||||
}
|
||||
}
|
||||
75
rippr-src/app/src/main/java/com/rippr/MainActivity.kt
Normal file
75
rippr-src/app/src/main/java/com/rippr/MainActivity.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
package com.rippr
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.rippr.ui.RipprNavHost
|
||||
import com.rippr.ui.theme.RipprTheme
|
||||
|
||||
/**
|
||||
* Owns only what has to live on an Activity: runtime permissions and the
|
||||
* battery-optimisation prompt. Screen state belongs to the ViewModels.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { granted ->
|
||||
if (granted[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||
granted[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||
) {
|
||||
// Granting the permission should begin the ride the user already asked for.
|
||||
startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
RipprTheme {
|
||||
RipprNavHost(
|
||||
onRequestPermissions = ::requestPermissions,
|
||||
hasLocationPermission = ::hasLocationPermission,
|
||||
isBatteryExempt = ::isIgnoringBatteryOptimizations,
|
||||
onRequestBatteryExemption = ::requestBatteryExemption,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasLocationPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun requestPermissions() {
|
||||
val permissions = mutableListOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permissions += Manifest.permission.POST_NOTIFICATIONS
|
||||
}
|
||||
permissionLauncher.launch(permissions.toTypedArray())
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
ContextCompat.startForegroundService(
|
||||
this,
|
||||
Intent(this, TrackingService::class.java).setAction(TrackingService.ACTION_START),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isIgnoringBatteryOptimizations(): Boolean =
|
||||
getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName)
|
||||
|
||||
private fun requestBatteryExemption() {
|
||||
runCatching { startActivity(TrackingService.batteryOptimizationIntent(packageName)) }
|
||||
}
|
||||
}
|
||||
114
rippr-src/app/src/main/java/com/rippr/RideAccumulator.kt
Normal file
114
rippr-src/app/src/main/java/com/rippr/RideAccumulator.kt
Normal file
@@ -0,0 +1,114 @@
|
||||
package com.rippr
|
||||
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.geo.Geo
|
||||
import com.rippr.stats.ElevationAccumulator
|
||||
import com.rippr.stats.RideStatistics
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Running totals for the ride in progress.
|
||||
*
|
||||
* Exists because SQLite 3.18 (minSdk 26) has no window functions, so distance and
|
||||
* elevation — both of which need consecutive-row differences — cannot be computed in SQL.
|
||||
* They are folded in here as points are written and persisted onto the `Trip` row once
|
||||
* per flush, so the recording screen can show live numbers without rescanning the point
|
||||
* table.
|
||||
*
|
||||
* The values this produces are an *estimate*. They are overwritten with
|
||||
* [RideStatistics.compute] when the trip completes, which is also why this class reuses
|
||||
* `ElevationAccumulator` rather than reimplementing hysteresis: a naive version measured
|
||||
* 1498 m of phantom climbing on a parked bike.
|
||||
*
|
||||
* Not thread-safe — the writer loop owns it, under the service's write mutex.
|
||||
*/
|
||||
class Accumulator {
|
||||
|
||||
/**
|
||||
* The last point of the *previous* batch.
|
||||
*
|
||||
* Without this, distance restarts at every flush boundary and silently under-reports
|
||||
* by roughly one inter-point hop per batch — a few percent, which nobody notices
|
||||
* until they compare against an odometer.
|
||||
*/
|
||||
private var lastPoint: TrackPoint? = null
|
||||
private var elevation = ElevationAccumulator()
|
||||
private var restoredElevationM: Double = 0.0
|
||||
|
||||
var distanceM: Double = 0.0
|
||||
private set
|
||||
var movingMillis: Long = 0
|
||||
private set
|
||||
var maxSpeedKmh: Float = 0f
|
||||
private set
|
||||
var pointCount: Int = 0
|
||||
private set
|
||||
|
||||
fun fold(points: List<TrackPoint>) {
|
||||
for (point in points) {
|
||||
pointCount++
|
||||
maxSpeedKmh = max(maxSpeedKmh, point.speedKmh)
|
||||
elevation.add(point.altitudeM)
|
||||
|
||||
val prev = lastPoint
|
||||
// Distance and moving time only accrue *within* a segment. Points either side
|
||||
// of a pause can be kilometres apart and that gap was never ridden.
|
||||
if (prev != null && prev.segmentId == point.segmentId) {
|
||||
distanceM += Geo.haversineMeters(
|
||||
prev.latitude, prev.longitude, point.latitude, point.longitude,
|
||||
)
|
||||
val dt = point.timestamp - prev.timestamp
|
||||
if (dt in 1..RideStatistics.MAX_SAMPLE_GAP_MILLIS &&
|
||||
point.speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH
|
||||
) {
|
||||
movingMillis += dt
|
||||
}
|
||||
}
|
||||
lastPoint = point
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elevation gain including the run still in progress.
|
||||
*
|
||||
* Reads a snapshot rather than mutating, so it is safe to call on every flush while
|
||||
* recording continues.
|
||||
*/
|
||||
fun elevationGain(): Double = restoredElevationM + elevation.gainIncludingPending()
|
||||
|
||||
/** Seeds from a trip already in progress after the service is restarted. */
|
||||
fun restore(
|
||||
distanceM: Double,
|
||||
movingMillis: Long,
|
||||
maxSpeedKmh: Float,
|
||||
elevationGainM: Double,
|
||||
pointCount: Int,
|
||||
lastPoint: TrackPoint?,
|
||||
) {
|
||||
this.distanceM = distanceM
|
||||
this.movingMillis = movingMillis
|
||||
this.maxSpeedKmh = maxSpeedKmh
|
||||
this.pointCount = pointCount
|
||||
this.lastPoint = lastPoint
|
||||
// Elevation cannot be resumed mid-run from a scalar total, so the accumulator
|
||||
// restarts and only gain from here on is added to the persisted figure. The
|
||||
// authoritative recomputation at trip completion corrects any drift.
|
||||
this.elevation = ElevationAccumulator()
|
||||
this.restoredElevationM = elevationGainM
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
lastPoint = null
|
||||
elevation = ElevationAccumulator()
|
||||
distanceM = 0.0
|
||||
movingMillis = 0
|
||||
maxSpeedKmh = 0f
|
||||
pointCount = 0
|
||||
restoredElevationM = 0.0
|
||||
}
|
||||
|
||||
/** Clears the cross-segment anchor so a resumed ride does not span the pause. */
|
||||
fun onSegmentChanged() {
|
||||
lastPoint = null
|
||||
}
|
||||
}
|
||||
25
rippr-src/app/src/main/java/com/rippr/RipprApp.kt
Normal file
25
rippr-src/app/src/main/java/com/rippr/RipprApp.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.rippr
|
||||
|
||||
import android.app.Application
|
||||
import com.rippr.export.ExportManager
|
||||
import org.osmdroid.config.Configuration
|
||||
|
||||
class RipprApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Must happen before any MapView is constructed. osmdroid's default user agent
|
||||
// is rejected by OSM's tile servers with a 403, and the failure looks like an
|
||||
// empty map rather than an error.
|
||||
Configuration.getInstance().apply {
|
||||
userAgentValue = BuildConfig.APPLICATION_ID
|
||||
// App-private storage. Older osmdroid guidance points at external storage,
|
||||
// which would drag in a storage permission for no reason.
|
||||
osmdroidBasePath = filesDir.resolve("osmdroid").apply { mkdirs() }
|
||||
osmdroidTileCache = cacheDir.resolve("osmdroid-tiles").apply { mkdirs() }
|
||||
}
|
||||
|
||||
// Stale exports from previous sessions are dead weight.
|
||||
ExportManager.clearCache(this)
|
||||
}
|
||||
}
|
||||
66
rippr-src/app/src/main/java/com/rippr/Telemetry.kt
Normal file
66
rippr-src/app/src/main/java/com/rippr/Telemetry.kt
Normal file
@@ -0,0 +1,66 @@
|
||||
package com.rippr
|
||||
|
||||
import com.rippr.data.TrackPoint
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Pure functions and process-wide recording state. Kept free of Android framework
|
||||
* types so the conversion and serialization logic is covered by JVM unit tests.
|
||||
*/
|
||||
object Telemetry {
|
||||
|
||||
const val MS_TO_KMH = 3.6f
|
||||
|
||||
fun msToKmh(metersPerSecond: Float): Float = metersPerSecond * MS_TO_KMH
|
||||
|
||||
/**
|
||||
* A parked bike still emits jittering fixes. Anything under this is reported as
|
||||
* zero so "max speed" is not set by GPS noise while the phone sits in a pocket.
|
||||
*/
|
||||
const val SPEED_NOISE_FLOOR_KMH = 1.5f
|
||||
|
||||
fun sanitizeSpeedKmh(raw: Float): Float =
|
||||
if (!raw.isFinite() || raw < SPEED_NOISE_FLOOR_KMH) 0f else raw
|
||||
|
||||
/** Drop fixes too imprecise to be worth storing. 0 means "accuracy unknown". */
|
||||
fun isUsableFix(accuracyMeters: Float, maxAccuracyMeters: Float = 50f): Boolean =
|
||||
accuracyMeters <= 0f || accuracyMeters <= maxAccuracyMeters
|
||||
|
||||
fun formatDuration(millis: Long): String {
|
||||
if (millis <= 0) return "00:00:00"
|
||||
val totalSeconds = millis / 1000
|
||||
return "%02d:%02d:%02d".format(
|
||||
totalSeconds / 3600,
|
||||
(totalSeconds % 3600) / 60,
|
||||
totalSeconds % 60
|
||||
)
|
||||
}
|
||||
|
||||
fun encodeBatch(deviceId: String, points: List<TrackPoint>): String {
|
||||
val array = JSONArray()
|
||||
points.forEach { p ->
|
||||
array.put(
|
||||
JSONObject().apply {
|
||||
put("id", p.id)
|
||||
// Per point, not per batch: getUnsyncedPoints() draws by id and can
|
||||
// straddle a segment or, after a discard-and-restart, a trip
|
||||
// boundary. Hoisting these to batch level would silently mislabel.
|
||||
put("trip_id", p.tripId)
|
||||
put("segment_id", p.segmentId)
|
||||
put("ts", p.timestamp)
|
||||
put("lat", p.latitude)
|
||||
put("lon", p.longitude)
|
||||
put("speed_kmh", p.speedKmh.toDouble())
|
||||
put("alt_m", p.altitudeM)
|
||||
put("acc_m", p.accuracyM.toDouble())
|
||||
put("bearing", p.bearingDeg.toDouble())
|
||||
}
|
||||
)
|
||||
}
|
||||
return JSONObject().apply {
|
||||
put("device_id", deviceId)
|
||||
put("points", array)
|
||||
}.toString()
|
||||
}
|
||||
}
|
||||
99
rippr-src/app/src/main/java/com/rippr/TelemetryUploader.kt
Normal file
99
rippr-src/app/src/main/java/com/rippr/TelemetryUploader.kt
Normal file
@@ -0,0 +1,99 @@
|
||||
package com.rippr
|
||||
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.data.TrackPointDao
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Streams recorded points to a REST endpoint when the network allows it.
|
||||
*
|
||||
* Upload is strictly secondary to recording: every failure path here is swallowed
|
||||
* and retried later, and nothing in this class can stop the location pipeline.
|
||||
* Points stay in the database with `synced = 0` until the server acknowledges them,
|
||||
* so a dead endpoint costs nothing but a growing backlog.
|
||||
*/
|
||||
class TelemetryUploader(
|
||||
private val dao: TrackPointDao,
|
||||
private val endpoint: String,
|
||||
private val deviceId: String,
|
||||
private val client: OkHttpClient = defaultClient()
|
||||
) {
|
||||
|
||||
suspend fun uploadPending(): Result = withContext(Dispatchers.IO) {
|
||||
if (endpoint.isBlank()) return@withContext Result.Disabled
|
||||
|
||||
var uploaded = 0
|
||||
repeat(MAX_BATCHES_PER_RUN) {
|
||||
val batch = dao.getUnsyncedPoints(BATCH_SIZE)
|
||||
if (batch.isEmpty()) return@withContext Result.Success(uploaded)
|
||||
|
||||
val ok = postBatch(batch)
|
||||
if (!ok) {
|
||||
return@withContext if (uploaded > 0) Result.Partial(uploaded) else Result.Failed
|
||||
}
|
||||
dao.markSynced(batch.map { it.id })
|
||||
uploaded += batch.size
|
||||
|
||||
// Server was fine but there may be more; brief pause so a long backlog
|
||||
// does not saturate a weak mobile link.
|
||||
if (batch.size == BATCH_SIZE) delay(250)
|
||||
}
|
||||
Result.Partial(uploaded)
|
||||
}
|
||||
|
||||
private fun postBatch(batch: List<TrackPoint>): Boolean {
|
||||
val body = Telemetry.encodeBatch(deviceId, batch).toRequestBody(JSON)
|
||||
val request = Request.Builder()
|
||||
.url(endpoint)
|
||||
.post(body)
|
||||
.header("Content-Type", "application/json")
|
||||
.build()
|
||||
|
||||
return try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
UploadStatus.setError(null)
|
||||
true
|
||||
} else {
|
||||
UploadStatus.setError("HTTP ${response.code}")
|
||||
Log.w(TAG, "Upload rejected: HTTP ${response.code}")
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
UploadStatus.setError(e.message ?: "network error")
|
||||
Log.w(TAG, "Upload failed, will retry", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface Result {
|
||||
data object Disabled : Result
|
||||
data object Failed : Result
|
||||
data class Partial(val uploaded: Int) : Result
|
||||
data class Success(val uploaded: Int) : Result
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TelemetryUploader"
|
||||
const val BATCH_SIZE = 200
|
||||
private const val MAX_BATCHES_PER_RUN = 10
|
||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
526
rippr-src/app/src/main/java/com/rippr/TrackingService.kt
Normal file
526
rippr-src/app/src/main/java/com/rippr/TrackingService.kt
Normal file
@@ -0,0 +1,526 @@
|
||||
package com.rippr
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.android.gms.location.FusedLocationProviderClient
|
||||
import com.google.android.gms.location.LocationCallback
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationResult
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.rippr.data.AppDatabase
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.data.TripRepository
|
||||
import com.rippr.data.TripState
|
||||
import com.rippr.stats.ElevationAccumulator
|
||||
import com.rippr.stats.RideStatistics
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Foreground service that owns the entire recording pipeline.
|
||||
*
|
||||
* Resilience notes:
|
||||
* - Location callbacks push into an unlimited [Channel] and a single writer coroutine
|
||||
* drains it in batches. A slow disk therefore stalls the writer, never the
|
||||
* location callback, and no fix is dropped under back pressure.
|
||||
* - Every point is stamped with its trip and segment id at creation, so a fix still in
|
||||
* flight when a pause happens is written to the segment it actually belongs to.
|
||||
* - START_STICKY plus a self-contained restart path means the service resumes recording
|
||||
* after a process kill without needing the original Intent — the active trip is read
|
||||
* back from the database.
|
||||
* - Upload runs on its own loop; network problems cannot interrupt recording.
|
||||
*/
|
||||
class TrackingService : Service() {
|
||||
|
||||
private lateinit var fusedLocationClient: FusedLocationProviderClient
|
||||
private lateinit var locationCallback: LocationCallback
|
||||
private lateinit var db: AppDatabase
|
||||
private lateinit var uploader: TelemetryUploader
|
||||
private lateinit var trips: TripRepository
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val pointChannel = Channel<TrackPoint>(Channel.UNLIMITED)
|
||||
|
||||
/** Serialises the writer loop against explicit drains at pause/stop/discard. */
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
/**
|
||||
* The most recent start command. Passed to `stopSelf(startId)` so a teardown cannot
|
||||
* kill the service out from under a START that arrived while it was stopping — which
|
||||
* happens whenever a rider stops and immediately starts again.
|
||||
*/
|
||||
@Volatile private var latestStartId = 0
|
||||
|
||||
// Written by the lifecycle path, read by the location callback on another thread.
|
||||
@Volatile private var currentTripId: Long = 0
|
||||
@Volatile private var currentSegmentId: Long = 0
|
||||
|
||||
/**
|
||||
* Running totals for the active trip.
|
||||
*
|
||||
* SQLite 3.18 on minSdk 26 has no window functions, so "distance from the previous
|
||||
* point" cannot be expressed in SQL at all. It is accumulated here instead, in the
|
||||
* writer loop that already touches every point, and persisted once per flush.
|
||||
*
|
||||
* Only ever touched from inside [writeMutex].
|
||||
*/
|
||||
private var accumulator = Accumulator()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
db = AppDatabase.getDatabase(this)
|
||||
trips = TripRepository.get(this)
|
||||
uploader = TelemetryUploader(
|
||||
dao = db.trackPointDao(),
|
||||
endpoint = Config.uploadEndpoint(this),
|
||||
deviceId = Config.deviceId(this)
|
||||
)
|
||||
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
|
||||
createNotificationChannel()
|
||||
|
||||
locationCallback = object : LocationCallback() {
|
||||
override fun onLocationResult(result: LocationResult) {
|
||||
val loc = result.lastLocation ?: return
|
||||
if (!Telemetry.isUsableFix(loc.accuracy)) return
|
||||
|
||||
// Stamped at creation, which is what makes a pause safe: a fix already in
|
||||
// flight lands in the segment it was recorded during, not the next one.
|
||||
val tripId = currentTripId
|
||||
val segmentId = currentSegmentId
|
||||
if (tripId == 0L || segmentId == 0L) return
|
||||
|
||||
val point = TrackPoint(
|
||||
tripId = tripId,
|
||||
segmentId = segmentId,
|
||||
timestamp = loc.time,
|
||||
latitude = loc.latitude,
|
||||
longitude = loc.longitude,
|
||||
speedKmh = Telemetry.sanitizeSpeedKmh(Telemetry.msToKmh(loc.speed)),
|
||||
altitudeM = loc.altitude,
|
||||
accuracyM = loc.accuracy,
|
||||
bearingDeg = loc.bearing
|
||||
)
|
||||
// Published at GPS rate so the screen has a live speedo; the Trip row
|
||||
// only flushes every ~2 s and carries max speed, not current.
|
||||
LiveTelemetry.update(point.speedKmh, loc.accuracy)
|
||||
|
||||
// Never suspends: the channel is unlimited, so the GPS thread is
|
||||
// decoupled from database latency.
|
||||
pointChannel.trySend(point)
|
||||
}
|
||||
}
|
||||
|
||||
startWriterLoop()
|
||||
startUploadLoop()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Promote to foreground before anything else. Android allows roughly five seconds
|
||||
// from startForegroundService() before it kills the process.
|
||||
startForegroundCompat()
|
||||
latestStartId = startId
|
||||
|
||||
when (intent?.action) {
|
||||
null -> serviceScope.launch { restoreAfterProcessDeath() }
|
||||
ACTION_START -> serviceScope.launch { beginRecording() }
|
||||
ACTION_PAUSE -> serviceScope.launch { pause() }
|
||||
ACTION_RESUME -> serviceScope.launch { beginRecording() }
|
||||
ACTION_STOP -> serviceScope.launch { finish(discard = false, startId = startId) }
|
||||
ACTION_DISCARD -> serviceScope.launch { finish(discard = true, startId = startId) }
|
||||
else -> Log.w(TAG, "Unknown action ${intent.action}")
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
// --- Lifecycle ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Starts a ride or resumes a paused one. The repository adopts an already-active trip
|
||||
* rather than creating a second, so this is safe to call from either state.
|
||||
*/
|
||||
private suspend fun beginRecording() {
|
||||
val now = System.currentTimeMillis()
|
||||
val active = trips.activeTrip()
|
||||
val handle = if (active?.state == TripState.PAUSED) {
|
||||
trips.resumeTrip(now)
|
||||
} else {
|
||||
trips.startTrip(now)
|
||||
} ?: return
|
||||
|
||||
writeMutex.withLock {
|
||||
if (handle.tripId != currentTripId) {
|
||||
accumulator.reset()
|
||||
seedAccumulator(handle.tripId)
|
||||
}
|
||||
// A new segment must not measure distance back to the pre-pause position.
|
||||
accumulator.onSegmentChanged()
|
||||
}
|
||||
|
||||
currentTripId = handle.tripId
|
||||
currentSegmentId = handle.segmentId
|
||||
acquireWakeLock()
|
||||
startLocationUpdates()
|
||||
updateNotification(TripState.RECORDING)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops consuming GPS but keeps the service and its notification alive, so resuming
|
||||
* is instant. The wake lock is released — a paused ride at a lunch stop has no reason
|
||||
* to hold the CPU awake.
|
||||
*/
|
||||
private suspend fun pause() {
|
||||
// Stop the source first, then drain, then close. Points already queued carry the
|
||||
// old segment id, so they remain correct whichever order they are written in;
|
||||
// draining here is about not leaving them unwritten while the ride sits idle.
|
||||
stopLocationUpdates()
|
||||
LiveTelemetry.clear()
|
||||
drainChannel()
|
||||
trips.pauseTrip(System.currentTimeMillis())
|
||||
|
||||
currentSegmentId = 0
|
||||
writeMutex.withLock { accumulator.onSegmentChanged() }
|
||||
releaseWakeLock()
|
||||
updateNotification(TripState.PAUSED)
|
||||
}
|
||||
|
||||
private suspend fun finish(discard: Boolean, startId: Int = latestStartId) {
|
||||
stopLocationUpdates()
|
||||
LiveTelemetry.clear()
|
||||
if (discard) {
|
||||
// Throw away anything still queued; it belongs to a trip about to be deleted.
|
||||
while (pointChannel.tryReceive().getOrNull() != null) { /* drop */ }
|
||||
trips.discardTrip()
|
||||
} else {
|
||||
drainChannel()
|
||||
val finishedTripId = currentTripId
|
||||
// A ride that captured nothing - START then STOP, or no GPS fix ever - is
|
||||
// noise in the history list, so it is dropped rather than saved.
|
||||
if (finishedTripId != 0L && db.trackPointDao().countForTrip(finishedTripId) == 0) {
|
||||
Log.i(TAG, "Discarding empty trip $finishedTripId")
|
||||
trips.discardTrip()
|
||||
} else {
|
||||
trips.completeTrip(System.currentTimeMillis())
|
||||
if (finishedTripId != 0L) reconcileAggregates(finishedTripId)
|
||||
}
|
||||
}
|
||||
|
||||
writeMutex.withLock { accumulator.reset() }
|
||||
currentTripId = 0
|
||||
currentSegmentId = 0
|
||||
releaseWakeLock()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
// Only stops if no newer start command has arrived in the meantime.
|
||||
stopSelf(startId)
|
||||
}
|
||||
|
||||
/**
|
||||
* START_STICKY redelivers a null intent after an OS-initiated kill. The active trip
|
||||
* in the database says what to do — an in-memory flag could not have survived.
|
||||
*/
|
||||
private suspend fun restoreAfterProcessDeath() {
|
||||
val trip = trips.activeTrip()
|
||||
when (trip?.state) {
|
||||
TripState.RECORDING -> {
|
||||
Log.i(TAG, "Restoring trip ${trip.id} after process death")
|
||||
// Resume into a *new* segment: the time the process was dead is a real
|
||||
// gap in the recording and should render as one.
|
||||
trips.resumeTrip(System.currentTimeMillis())?.let { handle ->
|
||||
writeMutex.withLock {
|
||||
accumulator.reset()
|
||||
seedAccumulator(handle.tripId)
|
||||
accumulator.onSegmentChanged()
|
||||
}
|
||||
currentTripId = handle.tripId
|
||||
currentSegmentId = handle.segmentId
|
||||
acquireWakeLock()
|
||||
startLocationUpdates()
|
||||
updateNotification(TripState.RECORDING)
|
||||
}
|
||||
}
|
||||
TripState.PAUSED -> {
|
||||
currentTripId = trip.id
|
||||
updateNotification(TripState.PAUSED)
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "No active trip to restore; stopping")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf(latestStartId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores running totals from the persisted row so a restarted service continues
|
||||
* accumulating rather than counting the ride from zero.
|
||||
*/
|
||||
private suspend fun seedAccumulator(tripId: Long) {
|
||||
val trip = db.tripDao().getById(tripId) ?: return
|
||||
accumulator.restore(
|
||||
distanceM = trip.distanceM,
|
||||
movingMillis = trip.movingMillis,
|
||||
maxSpeedKmh = trip.maxSpeedKmh,
|
||||
elevationGainM = trip.elevationGainM,
|
||||
pointCount = trip.pointCount,
|
||||
// Deliberately null: the anchor is only valid within a segment, and a restart
|
||||
// always opens a new one.
|
||||
lastPoint = null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the live estimate with the authoritative computation over stored points.
|
||||
*
|
||||
* The incremental totals can drift — a process kill loses the in-memory accumulator
|
||||
* mid-segment — so a finished ride is always recomputed from what was actually
|
||||
* written, using the same code the detail screen uses.
|
||||
*/
|
||||
private suspend fun reconcileAggregates(tripId: Long) {
|
||||
try {
|
||||
val points = db.trackPointDao().forTrip(tripId)
|
||||
val segments = db.segmentDao().forTrip(tripId)
|
||||
val summary = RideStatistics.compute(points, segments)
|
||||
db.tripDao().updateAggregates(
|
||||
id = tripId,
|
||||
distanceM = summary.distanceM,
|
||||
movingMillis = summary.movingMillis,
|
||||
maxSpeedKmh = summary.maxSpeedKmh,
|
||||
elevationGainM = summary.elevationGainM,
|
||||
pointCount = summary.pointCount,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reconcile aggregates for trip $tripId", e)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Writing ------------------------------------------------------------
|
||||
|
||||
/** Single writer, batching drains so a 2 Hz stream is not 2 transactions/second. */
|
||||
private fun startWriterLoop() = serviceScope.launch {
|
||||
while (isActive) {
|
||||
// Block until at least one point exists, then take whatever else is queued.
|
||||
val first = pointChannel.receive()
|
||||
writeMutex.withLock {
|
||||
val buffer = ArrayList<TrackPoint>(FLUSH_SIZE)
|
||||
buffer.add(first)
|
||||
while (buffer.size < FLUSH_SIZE) {
|
||||
buffer.add(pointChannel.tryReceive().getOrNull() ?: break)
|
||||
}
|
||||
persist(buffer)
|
||||
}
|
||||
// Coalesce bursts without letting points sit unwritten for long.
|
||||
delay(FLUSH_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes everything currently queued and returns once it has landed. */
|
||||
private suspend fun drainChannel() = writeMutex.withLock {
|
||||
val remaining = generateSequence { pointChannel.tryReceive().getOrNull() }.toList()
|
||||
if (remaining.isNotEmpty()) persist(remaining)
|
||||
}
|
||||
|
||||
private suspend fun persist(points: List<TrackPoint>) {
|
||||
if (points.isEmpty()) return
|
||||
val tripId = points.first().tripId
|
||||
try {
|
||||
db.trackPointDao().insertPoints(points)
|
||||
accumulator.fold(points)
|
||||
db.tripDao().updateAggregates(
|
||||
id = tripId,
|
||||
distanceM = accumulator.distanceM,
|
||||
movingMillis = accumulator.movingMillis,
|
||||
maxSpeedKmh = accumulator.maxSpeedKmh,
|
||||
elevationGainM = accumulator.elevationGain(),
|
||||
pointCount = accumulator.pointCount,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Losing the app to a DB error mid-ride is worse than losing points.
|
||||
Log.e(TAG, "Batch insert failed, dropping ${points.size} points", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUploadLoop() = serviceScope.launch {
|
||||
while (isActive) {
|
||||
delay(UPLOAD_INTERVAL_MS)
|
||||
try {
|
||||
uploader.uploadPending()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Upload cycle failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Location -----------------------------------------------------------
|
||||
|
||||
private fun startLocationUpdates() {
|
||||
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
|
||||
.setMinUpdateIntervalMillis(500)
|
||||
.setWaitForAccurateLocation(false)
|
||||
.setMinUpdateDistanceMeters(0f)
|
||||
.build()
|
||||
|
||||
try {
|
||||
fusedLocationClient.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Location permission missing; stopping service", e)
|
||||
serviceScope.launch { finish(discard = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopLocationUpdates() {
|
||||
fusedLocationClient.removeLocationUpdates(locationCallback)
|
||||
}
|
||||
|
||||
// --- Wake lock ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A partial wake lock keeps the CPU alive so the writer coroutine still runs with
|
||||
* the screen off. The foreground service alone does not guarantee this on all OEMs.
|
||||
*/
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
val pm = getSystemService(PowerManager::class.java)
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "rippr:recording").apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(MAX_RIDE_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
// --- Notification -------------------------------------------------------
|
||||
|
||||
private fun startForegroundCompat() {
|
||||
val notification = buildNotification(TripState.RECORDING)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotification(state: TripState) {
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, buildNotification(state))
|
||||
}
|
||||
|
||||
private fun action(requestCode: Int, name: String) = PendingIntent.getService(
|
||||
this,
|
||||
requestCode,
|
||||
Intent(this, TrackingService::class.java).setAction(name),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun buildNotification(state: TripState): Notification {
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val paused = state == TripState.PAUSED
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(if (paused) "Rippr Paused" else "Rippr Active")
|
||||
.setContentText(
|
||||
if (paused) "Ride paused — not recording" else "Recording ride telemetry…"
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_stat_rippr)
|
||||
.setContentIntent(openIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
|
||||
if (paused) {
|
||||
builder.addAction(android.R.drawable.ic_media_play, "Resume", action(2, ACTION_RESUME))
|
||||
} else {
|
||||
builder.addAction(android.R.drawable.ic_media_pause, "Pause", action(3, ACTION_PAUSE))
|
||||
}
|
||||
builder.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", action(1, ACTION_STOP))
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Rippr Recording",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Persistent notification shown while a ride is being recorded"
|
||||
setShowBadge(false)
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
// --- Teardown -----------------------------------------------------------
|
||||
|
||||
override fun onDestroy() {
|
||||
stopLocationUpdates()
|
||||
releaseWakeLock()
|
||||
|
||||
// Anything still queued has to land before the scope dies. runBlocking is
|
||||
// deliberate: onDestroy must not return until the write completes, and the
|
||||
// survivor-scope alternative races the process going away.
|
||||
val remaining = generateSequence { pointChannel.tryReceive().getOrNull() }.toList()
|
||||
if (remaining.isNotEmpty()) {
|
||||
runBlocking {
|
||||
runCatching { db.trackPointDao().insertPoints(remaining) }
|
||||
.onFailure { Log.e(TAG, "Final flush of ${remaining.size} points failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TrackingService"
|
||||
const val CHANNEL_ID = "rippr_service_channel"
|
||||
|
||||
const val ACTION_START = "com.rippr.action.START"
|
||||
const val ACTION_PAUSE = "com.rippr.action.PAUSE"
|
||||
const val ACTION_RESUME = "com.rippr.action.RESUME"
|
||||
const val ACTION_STOP = "com.rippr.action.STOP"
|
||||
const val ACTION_DISCARD = "com.rippr.action.DISCARD"
|
||||
|
||||
private const val NOTIFICATION_ID = 101
|
||||
private const val FLUSH_SIZE = 25
|
||||
private const val FLUSH_INTERVAL_MS = 2_000L
|
||||
private const val UPLOAD_INTERVAL_MS = 30_000L
|
||||
private const val MAX_RIDE_MILLIS = 12L * 60 * 60 * 1000
|
||||
|
||||
fun batteryOptimizationIntent(packageName: String): Intent =
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||
.setData(android.net.Uri.parse("package:$packageName"))
|
||||
}
|
||||
}
|
||||
21
rippr-src/app/src/main/java/com/rippr/UploadStatus.kt
Normal file
21
rippr-src/app/src/main/java/com/rippr/UploadStatus.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.rippr
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Last-seen upload failure, surfaced on the recording screen.
|
||||
*
|
||||
* Unlike recording state — which now lives in the database because it must survive
|
||||
* process death — this is genuinely ephemeral. A stale error from a previous process
|
||||
* would be misleading, so losing it on restart is the correct behaviour.
|
||||
*/
|
||||
object UploadStatus {
|
||||
private val _lastError = MutableStateFlow<String?>(null)
|
||||
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
||||
|
||||
fun setError(message: String?) {
|
||||
_lastError.value = message
|
||||
}
|
||||
}
|
||||
60
rippr-src/app/src/main/java/com/rippr/data/AppDatabase.kt
Normal file
60
rippr-src/app/src/main/java/com/rippr/data/AppDatabase.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.rippr.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
|
||||
class Converters {
|
||||
@TypeConverter
|
||||
fun toTripState(value: String): TripState = TripState.valueOf(value)
|
||||
|
||||
@TypeConverter
|
||||
fun fromTripState(state: TripState): String = state.name
|
||||
}
|
||||
|
||||
@Database(
|
||||
entities = [Trip::class, Segment::class, TrackPoint::class],
|
||||
version = 2,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun tripDao(): TripDao
|
||||
abstract fun segmentDao(): SegmentDao
|
||||
abstract fun trackPointDao(): TrackPointDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): AppDatabase =
|
||||
INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"rippr_db"
|
||||
)
|
||||
// A ride is unrecoverable if a write is lost to a crash mid-flush,
|
||||
// but full sync on every insert at 2 Hz burns battery. WAL with
|
||||
// NORMAL sync is the standard compromise and survives app crashes;
|
||||
// only an OS-level crash can lose the last few points.
|
||||
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING)
|
||||
// No destructive fallback. Rides recorded from v2 onward are real
|
||||
// data, so any future schema change MUST ship a Migration against the
|
||||
// committed schemas/com.rippr.data.AppDatabase/2.json baseline.
|
||||
// Reinstating fallbackToDestructiveMigration() here would silently
|
||||
// delete every stored ride on the next version bump.
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
|
||||
/** Test seam — lets instrumented tests substitute an in-memory database. */
|
||||
fun overrideForTest(db: AppDatabase?) {
|
||||
INSTANCE = db
|
||||
}
|
||||
}
|
||||
}
|
||||
26
rippr-src/app/src/main/java/com/rippr/data/RideStats.kt
Normal file
26
rippr-src/app/src/main/java/com/rippr/data/RideStats.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
|
||||
/**
|
||||
* Cheap SQL-computed stats for the live recording screen.
|
||||
*
|
||||
* Deliberately limited to what plain aggregate functions can express. Distance and
|
||||
* elevation gain are absent because they need consecutive-row differences, and SQLite
|
||||
* 3.18 (minSdk 26) has no window functions — those are accumulated in Kotlin and stored
|
||||
* on the [Trip] row instead.
|
||||
*/
|
||||
data class RideStats(
|
||||
@ColumnInfo(name = "pointCount") val pointCount: Int,
|
||||
@ColumnInfo(name = "maxSpeedKmh") val maxSpeedKmh: Float,
|
||||
@ColumnInfo(name = "avgSpeedKmh") val avgSpeedKmh: Float,
|
||||
@ColumnInfo(name = "firstTimestamp") val firstTimestamp: Long,
|
||||
@ColumnInfo(name = "lastTimestamp") val lastTimestamp: Long,
|
||||
@ColumnInfo(name = "pendingUpload") val pendingUpload: Int,
|
||||
) {
|
||||
val durationMillis: Long get() = if (pointCount == 0) 0L else lastTimestamp - firstTimestamp
|
||||
|
||||
companion object {
|
||||
val EMPTY = RideStats(0, 0f, 0f, 0L, 0L, 0)
|
||||
}
|
||||
}
|
||||
37
rippr-src/app/src/main/java/com/rippr/data/Segment.kt
Normal file
37
rippr-src/app/src/main/java/com/rippr/data/Segment.kt
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* One pause-free stretch of recording within a [Trip].
|
||||
*
|
||||
* This layer is what makes pause correct rather than cosmetic. Without it, a rider who
|
||||
* pauses at a gas station and resumes across town gets a polyline drawn straight through
|
||||
* terrain they never travelled, and a distance total that includes it. Points are grouped
|
||||
* by segment for rendering, distance accumulation, and GPX `<trkseg>` output, so every
|
||||
* consumer naturally leaves a gap where the rider stopped.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "segments",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Trip::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["tripId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("tripId")],
|
||||
)
|
||||
data class Segment(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val tripId: Long,
|
||||
val startedAt: Long,
|
||||
/** Null while this segment is still being recorded into. */
|
||||
val endedAt: Long? = null,
|
||||
) {
|
||||
val isOpen: Boolean get() = endedAt == null
|
||||
}
|
||||
29
rippr-src/app/src/main/java/com/rippr/data/SegmentDao.kt
Normal file
29
rippr-src/app/src/main/java/com/rippr/data/SegmentDao.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface SegmentDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insert(segment: Segment): Long
|
||||
|
||||
@Query("SELECT * FROM segments WHERE tripId = :tripId ORDER BY id ASC")
|
||||
suspend fun forTrip(tripId: Long): List<Segment>
|
||||
|
||||
/** The segment currently being recorded into, if any. */
|
||||
@Query("SELECT * FROM segments WHERE tripId = :tripId AND endedAt IS NULL ORDER BY id DESC LIMIT 1")
|
||||
suspend fun openSegment(tripId: Long): Segment?
|
||||
|
||||
@Query("UPDATE segments SET endedAt = :endedAt WHERE id = :id")
|
||||
suspend fun close(id: Long, endedAt: Long)
|
||||
|
||||
/** Used by merge: re-parents a trip's segments onto the surviving trip. */
|
||||
@Query("UPDATE segments SET tripId = :newTripId WHERE tripId = :oldTripId")
|
||||
suspend fun reparent(oldTripId: Long, newTripId: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM segments WHERE tripId = :tripId")
|
||||
suspend fun countForTrip(tripId: Long): Int
|
||||
}
|
||||
47
rippr-src/app/src/main/java/com/rippr/data/TrackPoint.kt
Normal file
47
rippr-src/app/src/main/java/com/rippr/data/TrackPoint.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* A single GPS fix.
|
||||
*
|
||||
* Ordering is by [id] rather than [timestamp] everywhere it matters: `timestamp` comes
|
||||
* from `Location.time`, which is GPS-derived and can jump, whereas the autoincrement id
|
||||
* is genuinely monotonic in write order.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "track_points",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Trip::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["tripId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
ForeignKey(
|
||||
entity = Segment::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["segmentId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("tripId"), Index("segmentId"), Index("synced")],
|
||||
)
|
||||
data class TrackPoint(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val tripId: Long,
|
||||
val segmentId: Long,
|
||||
val timestamp: Long,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val speedKmh: Float,
|
||||
val altitudeM: Double,
|
||||
val accuracyM: Float = 0f,
|
||||
val bearingDeg: Float = 0f,
|
||||
/** Set once the point has been accepted by the remote endpoint. */
|
||||
@ColumnInfo(defaultValue = "0") val synced: Boolean = false,
|
||||
)
|
||||
81
rippr-src/app/src/main/java/com/rippr/data/TrackPointDao.kt
Normal file
81
rippr-src/app/src/main/java/com/rippr/data/TrackPointDao.kt
Normal file
@@ -0,0 +1,81 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface TrackPointDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insertPoint(point: TrackPoint): Long
|
||||
|
||||
/** Batched insert — the recorder buffers points and flushes them in groups. */
|
||||
@Insert
|
||||
suspend fun insertPoints(points: List<TrackPoint>): List<Long>
|
||||
|
||||
/**
|
||||
* Ordered by segment then id so consumers walk the ride in recording order with
|
||||
* pause boundaries intact. Not ordered by timestamp: that value is GPS-derived and
|
||||
* can jump, whereas id is monotonic in write order.
|
||||
*/
|
||||
@Query("SELECT * FROM track_points WHERE tripId = :tripId ORDER BY segmentId ASC, id ASC")
|
||||
suspend fun forTrip(tripId: Long): List<TrackPoint>
|
||||
|
||||
@Query("SELECT * FROM track_points WHERE segmentId = :segmentId ORDER BY id ASC")
|
||||
suspend fun forSegment(segmentId: Long): List<TrackPoint>
|
||||
|
||||
/** The anchor a restarted recorder needs to continue accumulating distance. */
|
||||
@Query("SELECT * FROM track_points WHERE segmentId = :segmentId ORDER BY id DESC LIMIT 1")
|
||||
suspend fun lastInSegment(segmentId: Long): TrackPoint?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_points WHERE tripId = :tripId")
|
||||
suspend fun countForTrip(tripId: Long): Int
|
||||
|
||||
/**
|
||||
* Live stats for one trip. COALESCE keeps the row non-null for a trip with no points
|
||||
* yet, which would otherwise make Room throw on RideStats' non-null fields.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT COUNT(*) AS pointCount,
|
||||
COALESCE(MAX(speedKmh), 0.0) AS maxSpeedKmh,
|
||||
COALESCE(AVG(speedKmh), 0.0) AS avgSpeedKmh,
|
||||
COALESCE(MIN(timestamp), 0) AS firstTimestamp,
|
||||
COALESCE(MAX(timestamp), 0) AS lastTimestamp,
|
||||
COALESCE(SUM(CASE WHEN synced = 0 THEN 1 ELSE 0 END), 0) AS pendingUpload
|
||||
FROM track_points
|
||||
WHERE tripId = :tripId
|
||||
"""
|
||||
)
|
||||
fun observeTripStats(tripId: Long): Flow<RideStats>
|
||||
|
||||
// --- Upload backlog -----------------------------------------------------
|
||||
// Batches are drawn by id and can straddle a segment or trip boundary, which is why
|
||||
// the payload carries trip/segment identity per point rather than per batch.
|
||||
|
||||
@Query("SELECT * FROM track_points WHERE synced = 0 ORDER BY id ASC LIMIT :limit")
|
||||
suspend fun getUnsyncedPoints(limit: Int): List<TrackPoint>
|
||||
|
||||
@Query("UPDATE track_points SET synced = 1 WHERE id IN (:ids)")
|
||||
suspend fun markSynced(ids: List<Long>)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_points WHERE synced = 0")
|
||||
suspend fun countUnsynced(): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_points WHERE tripId = :tripId AND synced = 0")
|
||||
fun observePendingUpload(tripId: Long): Flow<Int>
|
||||
|
||||
// --- Maintenance --------------------------------------------------------
|
||||
|
||||
/** Used by merge: points carry tripId directly, so they re-parent alongside segments. */
|
||||
@Query("UPDATE track_points SET tripId = :newTripId WHERE tripId = :oldTripId")
|
||||
suspend fun reparent(oldTripId: Long, newTripId: Long)
|
||||
|
||||
@Query("SELECT * FROM track_points ORDER BY id ASC")
|
||||
suspend fun getAllPoints(): List<TrackPoint>
|
||||
|
||||
@Query("DELETE FROM track_points")
|
||||
suspend fun clearAll()
|
||||
}
|
||||
48
rippr-src/app/src/main/java/com/rippr/data/Trip.kt
Normal file
48
rippr-src/app/src/main/java/com/rippr/data/Trip.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Where a ride is in its lifecycle.
|
||||
*
|
||||
* [Trip.endedAt] alone distinguishes active from finished, but cannot tell RECORDING
|
||||
* from PAUSED — and the service needs that distinction to decide what to do when the OS
|
||||
* restarts it mid-ride. Hence both.
|
||||
*/
|
||||
enum class TripState {
|
||||
RECORDING,
|
||||
PAUSED,
|
||||
COMPLETED,
|
||||
}
|
||||
|
||||
/**
|
||||
* One ride, from pressing Start to pressing Stop.
|
||||
*
|
||||
* The aggregate columns are denormalised on purpose. They are accumulated as points
|
||||
* arrive (see the recorder's writer loop) and recomputed authoritatively when the trip
|
||||
* completes, so the trips list can render hundreds of rides without touching the point
|
||||
* table. SQLite 3.18 on minSdk 26 has no window functions, so consecutive-point maths
|
||||
* like distance cannot be expressed in SQL anyway.
|
||||
*/
|
||||
@Entity(tableName = "trips")
|
||||
data class Trip(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val startedAt: Long,
|
||||
/** Null while the ride is still active. */
|
||||
val endedAt: Long? = null,
|
||||
/** Null means the UI derives a label from [startedAt]. Never store an empty string. */
|
||||
val name: String? = null,
|
||||
val state: TripState = TripState.RECORDING,
|
||||
@ColumnInfo(defaultValue = "0") val distanceM: Double = 0.0,
|
||||
@ColumnInfo(defaultValue = "0") val movingMillis: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val maxSpeedKmh: Float = 0f,
|
||||
@ColumnInfo(defaultValue = "0") val elevationGainM: Double = 0.0,
|
||||
@ColumnInfo(defaultValue = "0") val pointCount: Int = 0,
|
||||
) {
|
||||
val isActive: Boolean get() = endedAt == null
|
||||
|
||||
val elapsedMillis: Long
|
||||
get() = if (endedAt == null) 0L else endedAt - startedAt
|
||||
}
|
||||
80
rippr-src/app/src/main/java/com/rippr/data/TripDao.kt
Normal file
80
rippr-src/app/src/main/java/com/rippr/data/TripDao.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
package com.rippr.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface TripDao {
|
||||
|
||||
@Insert
|
||||
suspend fun insert(trip: Trip): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(trip: Trip)
|
||||
|
||||
/**
|
||||
* The in-progress ride, or null. This is the source of truth for "are we recording" —
|
||||
* it survives process death, which an in-memory flag cannot.
|
||||
*/
|
||||
@Query("SELECT * FROM trips WHERE endedAt IS NULL ORDER BY id DESC LIMIT 1")
|
||||
fun observeActive(): Flow<Trip?>
|
||||
|
||||
@Query("SELECT * FROM trips WHERE endedAt IS NULL ORDER BY id DESC LIMIT 1")
|
||||
suspend fun getActive(): Trip?
|
||||
|
||||
@Query("SELECT * FROM trips WHERE endedAt IS NOT NULL ORDER BY startedAt DESC")
|
||||
fun observeCompleted(): Flow<List<Trip>>
|
||||
|
||||
@Query("SELECT * FROM trips WHERE id = :id")
|
||||
fun observeById(id: Long): Flow<Trip?>
|
||||
|
||||
@Query("SELECT * FROM trips WHERE id = :id")
|
||||
suspend fun getById(id: Long): Trip?
|
||||
|
||||
@Query("UPDATE trips SET name = :name WHERE id = :id")
|
||||
suspend fun rename(id: Long, name: String?)
|
||||
|
||||
@Query("UPDATE trips SET state = :state WHERE id = :id")
|
||||
suspend fun setState(id: Long, state: TripState)
|
||||
|
||||
@Query("UPDATE trips SET endedAt = :endedAt, state = :state WHERE id = :id")
|
||||
suspend fun close(id: Long, endedAt: Long, state: TripState = TripState.COMPLETED)
|
||||
|
||||
/**
|
||||
* Persists the running totals. Called once per writer flush (~every 2s), so it stays
|
||||
* a narrow targeted update rather than a full row rewrite.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
UPDATE trips
|
||||
SET distanceM = :distanceM,
|
||||
movingMillis = :movingMillis,
|
||||
maxSpeedKmh = :maxSpeedKmh,
|
||||
elevationGainM = :elevationGainM,
|
||||
pointCount = :pointCount
|
||||
WHERE id = :id
|
||||
"""
|
||||
)
|
||||
suspend fun updateAggregates(
|
||||
id: Long,
|
||||
distanceM: Double,
|
||||
movingMillis: Long,
|
||||
maxSpeedKmh: Float,
|
||||
elevationGainM: Double,
|
||||
pointCount: Int,
|
||||
)
|
||||
|
||||
/** Segments and points go with it via CASCADE. */
|
||||
@Query("DELETE FROM trips WHERE id = :id")
|
||||
suspend fun deleteById(id: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM trips")
|
||||
suspend fun count(): Int
|
||||
|
||||
/** Test/maintenance helper. Segments and points follow via CASCADE. */
|
||||
@Query("DELETE FROM trips")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
192
rippr-src/app/src/main/java/com/rippr/data/TripRepository.kt
Normal file
192
rippr-src/app/src/main/java/com/rippr/data/TripRepository.kt
Normal file
@@ -0,0 +1,192 @@
|
||||
package com.rippr.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.room.withTransaction
|
||||
import com.rippr.stats.RideStatistics
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Owns every trip lifecycle transition.
|
||||
*
|
||||
* Recording state lives in the database rather than in memory. A row in `trips` with
|
||||
* `endedAt IS NULL` *is* the fact of an in-progress ride, so it survives process death —
|
||||
* which the previous in-memory flag could not. START_STICKY restarts the service with a
|
||||
* null intent, and an in-memory flag would come back false while a ride was genuinely
|
||||
* underway.
|
||||
*
|
||||
* Every transition is idempotent and safe to call from an unexpected state. The OS can
|
||||
* restart the service at any moment, so calling resume on an already-recording trip must
|
||||
* be a no-op rather than opening a duplicate segment.
|
||||
*/
|
||||
class TripRepository(
|
||||
private val db: AppDatabase,
|
||||
private val tripDao: TripDao,
|
||||
private val segmentDao: SegmentDao,
|
||||
private val pointDao: TrackPointDao,
|
||||
) {
|
||||
|
||||
fun observeActiveTrip(): Flow<Trip?> = tripDao.observeActive()
|
||||
|
||||
fun observeTrip(id: Long): Flow<Trip?> = tripDao.observeById(id)
|
||||
|
||||
fun observeCompletedTrips(): Flow<List<Trip>> = tripDao.observeCompleted()
|
||||
|
||||
suspend fun activeTrip(): Trip? = tripDao.getActive()
|
||||
|
||||
suspend fun tripById(id: Long): Trip? = tripDao.getById(id)
|
||||
|
||||
/** The segment currently being written into, if a ride is active and not paused. */
|
||||
suspend fun openSegment(): Segment? =
|
||||
tripDao.getActive()?.let { segmentDao.openSegment(it.id) }
|
||||
|
||||
/**
|
||||
* Starts a ride, or adopts one already in progress.
|
||||
*
|
||||
* Adoption rather than rejection is deliberate: after a process kill the trip row
|
||||
* still exists, and the restarted service needs to continue it, not start a second.
|
||||
*/
|
||||
suspend fun startTrip(now: Long): TripHandle = db.withTransaction {
|
||||
val existing = tripDao.getActive()
|
||||
if (existing != null) {
|
||||
Log.i(TAG, "startTrip: adopting active trip ${existing.id}")
|
||||
return@withTransaction adoptOrOpenSegment(existing, now)
|
||||
}
|
||||
val tripId = tripDao.insert(Trip(startedAt = now, state = TripState.RECORDING))
|
||||
val segmentId = segmentDao.insert(Segment(tripId = tripId, startedAt = now))
|
||||
TripHandle(tripId, segmentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the open segment and marks the trip paused. The trip itself stays open —
|
||||
* only [completeTrip] sets `endedAt`.
|
||||
*/
|
||||
suspend fun pauseTrip(now: Long): Boolean = db.withTransaction {
|
||||
val trip = tripDao.getActive() ?: return@withTransaction false
|
||||
if (trip.state == TripState.PAUSED) return@withTransaction true
|
||||
|
||||
segmentDao.openSegment(trip.id)?.let { segmentDao.close(it.id, now) }
|
||||
tripDao.setState(trip.id, TripState.PAUSED)
|
||||
true
|
||||
}
|
||||
|
||||
/** Opens a fresh segment so the pause leaves a real gap in the recorded path. */
|
||||
suspend fun resumeTrip(now: Long): TripHandle? = db.withTransaction {
|
||||
val trip = tripDao.getActive() ?: return@withTransaction null
|
||||
val handle = adoptOrOpenSegment(trip, now)
|
||||
tripDao.setState(trip.id, TripState.RECORDING)
|
||||
handle
|
||||
}
|
||||
|
||||
suspend fun completeTrip(now: Long): Long? = db.withTransaction {
|
||||
val trip = tripDao.getActive() ?: return@withTransaction null
|
||||
segmentDao.openSegment(trip.id)?.let { segmentDao.close(it.id, now) }
|
||||
tripDao.close(trip.id, endedAt = now, state = TripState.COMPLETED)
|
||||
trip.id
|
||||
}
|
||||
|
||||
/** Deletes the active trip outright. Segments and points follow via CASCADE. */
|
||||
suspend fun discardTrip(): Boolean = db.withTransaction {
|
||||
val trip = tripDao.getActive() ?: return@withTransaction false
|
||||
tripDao.deleteById(trip.id)
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun renameTrip(id: Long, name: String?) {
|
||||
// Empty input must collapse to null, or the UI's "derive a label from the date"
|
||||
// branch and a stored "" would diverge.
|
||||
tripDao.rename(id, name?.trim()?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
suspend fun deleteTrip(id: Long) = tripDao.deleteById(id)
|
||||
|
||||
/**
|
||||
* Combines two completed rides into one.
|
||||
*
|
||||
* The earlier trip survives, the later one's segments and points are re-parented onto
|
||||
* it, and the later row is deleted. Segments are **never joined** — the boundary
|
||||
* between the two rides becomes a segment boundary exactly like a pause, which is
|
||||
* correct: the rider genuinely was not recording in between, and joining them would
|
||||
* draw a straight line across the gap.
|
||||
*
|
||||
* Aggregates are recomputed from scratch rather than summed, because distance is not
|
||||
* additive across that gap.
|
||||
*
|
||||
* Returns the surviving trip id, or null if the merge was rejected.
|
||||
*/
|
||||
suspend fun mergeTrips(a: Long, b: Long): Long? = db.withTransaction {
|
||||
if (a == b) return@withTransaction null
|
||||
val first = tripDao.getById(a) ?: return@withTransaction null
|
||||
val second = tripDao.getById(b) ?: return@withTransaction null
|
||||
if (first.isActive || second.isActive) {
|
||||
Log.w(TAG, "Refusing to merge an active trip")
|
||||
return@withTransaction null
|
||||
}
|
||||
|
||||
// Selection order is not ride order.
|
||||
val (survivor, absorbed) =
|
||||
if (first.startedAt <= second.startedAt) first to second else second to first
|
||||
|
||||
segmentDao.reparent(oldTripId = absorbed.id, newTripId = survivor.id)
|
||||
pointDao.reparent(oldTripId = absorbed.id, newTripId = survivor.id)
|
||||
tripDao.deleteById(absorbed.id)
|
||||
|
||||
val endedAt = maxOf(survivor.endedAt ?: 0L, absorbed.endedAt ?: 0L)
|
||||
tripDao.close(survivor.id, endedAt = endedAt, state = TripState.COMPLETED)
|
||||
if (survivor.name == null && absorbed.name != null) {
|
||||
tripDao.rename(survivor.id, absorbed.name)
|
||||
}
|
||||
|
||||
recomputeAggregates(survivor.id)
|
||||
survivor.id
|
||||
}
|
||||
|
||||
/** Recomputes a trip's stored totals from the points it actually owns. */
|
||||
suspend fun recomputeAggregates(tripId: Long) {
|
||||
val summary = RideStatistics.compute(
|
||||
pointDao.forTrip(tripId),
|
||||
segmentDao.forTrip(tripId),
|
||||
)
|
||||
tripDao.updateAggregates(
|
||||
id = tripId,
|
||||
distanceM = summary.distanceM,
|
||||
movingMillis = summary.movingMillis,
|
||||
maxSpeedKmh = summary.maxSpeedKmh,
|
||||
elevationGainM = summary.elevationGainM,
|
||||
pointCount = summary.pointCount,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pointsForTrip(id: Long): List<TrackPoint> = pointDao.forTrip(id)
|
||||
|
||||
suspend fun segmentsForTrip(id: Long): List<Segment> = segmentDao.forTrip(id)
|
||||
|
||||
private suspend fun adoptOrOpenSegment(trip: Trip, now: Long): TripHandle {
|
||||
val segment = segmentDao.openSegment(trip.id)
|
||||
?: Segment(tripId = trip.id, startedAt = now)
|
||||
.let { it.copy(id = segmentDao.insert(it)) }
|
||||
return TripHandle(trip.id, segment.id)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TripRepository"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: TripRepository? = null
|
||||
|
||||
fun get(context: Context): TripRepository =
|
||||
INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: AppDatabase.getDatabase(context).let { db ->
|
||||
TripRepository(db, db.tripDao(), db.segmentDao(), db.trackPointDao())
|
||||
}.also { INSTANCE = it }
|
||||
}
|
||||
|
||||
/** Test seam, mirroring [AppDatabase.overrideForTest]. */
|
||||
fun overrideForTest(repo: TripRepository?) {
|
||||
INSTANCE = repo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The ids the recorder stamps onto each fix. */
|
||||
data class TripHandle(val tripId: Long, val segmentId: Long)
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.rippr.export
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import com.rippr.data.Segment
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.data.Trip
|
||||
import java.io.File
|
||||
|
||||
enum class ExportFormat(val extension: String, val mimeType: String) {
|
||||
GPX("gpx", "application/gpx+xml"),
|
||||
GEOJSON("geojson", "application/geo+json"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a ride into a shareable file.
|
||||
*
|
||||
* Files are written to `cacheDir/exports` and handed out through [FileProvider]. A raw
|
||||
* `file://` URI in an Intent throws `FileUriExposedException` on Android 7+, and the
|
||||
* receiving app needs `FLAG_GRANT_READ_URI_PERMISSION` or it sees a SecurityException
|
||||
* that looks like a bug in *that* app.
|
||||
*/
|
||||
object ExportManager {
|
||||
|
||||
private const val DIR = "exports"
|
||||
|
||||
fun write(
|
||||
context: Context,
|
||||
trip: Trip,
|
||||
segments: List<Segment>,
|
||||
points: List<TrackPoint>,
|
||||
name: String,
|
||||
fileStamp: String,
|
||||
format: ExportFormat,
|
||||
): Uri {
|
||||
val content = when (format) {
|
||||
ExportFormat.GPX -> RideExport.gpx(trip, segments, points, name)
|
||||
ExportFormat.GEOJSON -> RideExport.geoJson(trip, segments, points, name)
|
||||
}
|
||||
|
||||
val dir = File(context.cacheDir, DIR).apply { mkdirs() }
|
||||
// Sortable, unambiguous, no spaces. Unlike the timestamps *inside* the file,
|
||||
// which are UTC, this is local time because it is read by a human.
|
||||
val file = File(dir, "rippr-$fileStamp.${format.extension}")
|
||||
file.writeText(content)
|
||||
|
||||
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
}
|
||||
|
||||
fun shareIntent(uri: Uri, format: ExportFormat): Intent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = format.mimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
/** Exports accumulate otherwise; they are handoffs, not storage. */
|
||||
fun clearCache(context: Context) {
|
||||
runCatching { File(context.cacheDir, DIR).deleteRecursively() }
|
||||
}
|
||||
}
|
||||
151
rippr-src/app/src/main/java/com/rippr/export/RideExport.kt
Normal file
151
rippr-src/app/src/main/java/com/rippr/export/RideExport.kt
Normal file
@@ -0,0 +1,151 @@
|
||||
package com.rippr.export
|
||||
|
||||
import com.rippr.data.Segment
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.data.Trip
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Serialises a ride to formats other tools understand.
|
||||
*
|
||||
* Pure string generation with no Android dependency, so it is fully unit-testable.
|
||||
*
|
||||
* **Never decimate here.** Simplification exists only in the map render path; an export
|
||||
* must carry every raw point. The tests assert exact counts to catch any leak.
|
||||
*/
|
||||
object RideExport {
|
||||
|
||||
/** ISO 8601 in UTC. `Location.time` is already UTC epoch millis, so no zone maths. */
|
||||
private val iso: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC)
|
||||
|
||||
private fun time(epochMillis: Long): String = iso.format(Instant.ofEpochMilli(epochMillis))
|
||||
|
||||
private fun coord(value: Double): String = String.format(Locale.ROOT, "%.7f", value)
|
||||
|
||||
private fun num(value: Double): String = String.format(Locale.ROOT, "%.1f", value)
|
||||
|
||||
/**
|
||||
* XML-escapes text destined for an attribute or element body.
|
||||
*
|
||||
* A trip named "Sam & Dave's" would otherwise produce malformed XML, and the failure
|
||||
* is invisible until an import rejects the file.
|
||||
*/
|
||||
internal fun escapeXml(text: String): String = buildString(text.length) {
|
||||
for (c in text) {
|
||||
when (c) {
|
||||
'&' -> append("&")
|
||||
'<' -> append("<")
|
||||
'>' -> append(">")
|
||||
'"' -> append(""")
|
||||
'\'' -> append("'")
|
||||
else -> append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GPX 1.1.
|
||||
*
|
||||
* One `<trkseg>` per [Segment] — this is exactly how GPX represents a recording gap,
|
||||
* so pauses survive the round-trip into Strava, Garmin, or Google Earth instead of
|
||||
* becoming a straight line across town.
|
||||
*
|
||||
* Speed goes in `<extensions>`; it is not part of core GPX 1.1, and consumers that do
|
||||
* not understand the extension ignore it, which is the correct degradation.
|
||||
*/
|
||||
fun gpx(trip: Trip, segments: List<Segment>, points: List<TrackPoint>, name: String): String {
|
||||
val bySegment = points.groupBy { it.segmentId }
|
||||
// Segment order, then any points whose segment is missing, so nothing is dropped.
|
||||
val ordered = segments.map { it.id }.filter { bySegment.containsKey(it) } +
|
||||
bySegment.keys.filter { id -> segments.none { it.id == id } }
|
||||
|
||||
return buildString {
|
||||
append("""<?xml version="1.0" encoding="UTF-8"?>""").append('\n')
|
||||
append(
|
||||
"""<gpx version="1.1" creator="Rippr" xmlns="http://www.topografix.com/GPX/1/1">"""
|
||||
).append('\n')
|
||||
append(" <metadata>\n")
|
||||
append(" <name>").append(escapeXml(name)).append("</name>\n")
|
||||
append(" <time>").append(time(trip.startedAt)).append("</time>\n")
|
||||
append(" </metadata>\n")
|
||||
append(" <trk>\n")
|
||||
append(" <name>").append(escapeXml(name)).append("</name>\n")
|
||||
|
||||
for (segmentId in ordered) {
|
||||
append(" <trkseg>\n")
|
||||
for (p in bySegment.getValue(segmentId)) {
|
||||
append(" <trkpt lat=\"").append(coord(p.latitude))
|
||||
.append("\" lon=\"").append(coord(p.longitude)).append("\">\n")
|
||||
append(" <ele>").append(num(p.altitudeM)).append("</ele>\n")
|
||||
append(" <time>").append(time(p.timestamp)).append("</time>\n")
|
||||
append(" <extensions>\n")
|
||||
append(" <speed>")
|
||||
.append(num(p.speedKmh / 3.6)) // GPX speed is metres per second
|
||||
.append("</speed>\n")
|
||||
append(" </extensions>\n")
|
||||
append(" </trkpt>\n")
|
||||
}
|
||||
append(" </trkseg>\n")
|
||||
}
|
||||
|
||||
append(" </trk>\n")
|
||||
append("</gpx>\n")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON `FeatureCollection`, one `LineString` per segment.
|
||||
*
|
||||
* Coordinates are `[lon, lat, ele]` — **longitude first**, the opposite order to GPX
|
||||
* and the most common mistake in GeoJSON output.
|
||||
*/
|
||||
fun geoJson(trip: Trip, segments: List<Segment>, points: List<TrackPoint>, name: String): String {
|
||||
val bySegment = points.groupBy { it.segmentId }
|
||||
val ordered = segments.map { it.id }.filter { bySegment.containsKey(it) } +
|
||||
bySegment.keys.filter { id -> segments.none { it.id == id } }
|
||||
|
||||
val features = ordered.joinToString(",\n") { segmentId ->
|
||||
val coords = bySegment.getValue(segmentId).joinToString(", ") { p ->
|
||||
"[${coord(p.longitude)}, ${coord(p.latitude)}, ${num(p.altitudeM)}]"
|
||||
}
|
||||
""" {
|
||||
"type": "Feature",
|
||||
"properties": { "segment_id": $segmentId },
|
||||
"geometry": { "type": "LineString", "coordinates": [$coords] }
|
||||
}"""
|
||||
}
|
||||
|
||||
return """{
|
||||
"type": "FeatureCollection",
|
||||
"properties": {
|
||||
"name": ${jsonString(name)},
|
||||
"started_at": ${trip.startedAt},
|
||||
"distance_m": ${num(trip.distanceM)},
|
||||
"max_speed_kmh": ${num(trip.maxSpeedKmh.toDouble())}
|
||||
},
|
||||
"features": [
|
||||
$features
|
||||
]
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private fun jsonString(text: String): String = buildString {
|
||||
append('"')
|
||||
for (c in text) {
|
||||
when (c) {
|
||||
'"' -> append("\\\"")
|
||||
'\\' -> append("\\\\")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
else -> if (c < ' ') append("\\u%04x".format(c.code)) else append(c)
|
||||
}
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
}
|
||||
165
rippr-src/app/src/main/java/com/rippr/geo/Geo.kt
Normal file
165
rippr-src/app/src/main/java/com/rippr/geo/Geo.kt
Normal file
@@ -0,0 +1,165 @@
|
||||
package com.rippr.geo
|
||||
|
||||
import com.rippr.data.TrackPoint
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
data class LatLon(val lat: Double, val lon: Double)
|
||||
|
||||
data class Bounds(
|
||||
val minLat: Double,
|
||||
val minLon: Double,
|
||||
val maxLat: Double,
|
||||
val maxLon: Double,
|
||||
) {
|
||||
val centerLat: Double get() = (minLat + maxLat) / 2
|
||||
val centerLon: Double get() = (minLon + maxLon) / 2
|
||||
|
||||
/** True when every point sits at effectively one spot — a parked "ride". */
|
||||
val isDegenerate: Boolean
|
||||
get() = abs(maxLat - minLat) < 1e-9 && abs(maxLon - minLon) < 1e-9
|
||||
}
|
||||
|
||||
fun TrackPoint.toLatLon() = LatLon(latitude, longitude)
|
||||
|
||||
/**
|
||||
* Geographic maths with no Android dependencies, so it is testable on the JVM.
|
||||
*
|
||||
* Haversine rather than Vincenty throughout: assuming a sphere costs about 0.5% — a few
|
||||
* metres per kilometre — which is well below GPS noise. Vincenty's iterative solution
|
||||
* would be false precision at real cost.
|
||||
*/
|
||||
object Geo {
|
||||
|
||||
/** IUGG mean Earth radius. */
|
||||
const val EARTH_RADIUS_M = 6_371_008.8
|
||||
|
||||
private const val DEG_TO_RAD = Math.PI / 180.0
|
||||
|
||||
fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||
val dLat = (lat2 - lat1) * DEG_TO_RAD
|
||||
val dLon = (lon2 - lon1) * DEG_TO_RAD
|
||||
val sinLat = sin(dLat / 2)
|
||||
val sinLon = sin(dLon / 2)
|
||||
val a = sinLat * sinLat +
|
||||
cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sinLon * sinLon
|
||||
// asin(sqrt(a)) rather than atan2 — better conditioned for the very short hops
|
||||
// between consecutive GPS fixes.
|
||||
return 2 * EARTH_RADIUS_M * asin(min(1.0, sqrt(a)))
|
||||
}
|
||||
|
||||
fun haversineMeters(a: LatLon, b: LatLon): Double =
|
||||
haversineMeters(a.lat, a.lon, b.lat, b.lon)
|
||||
|
||||
/**
|
||||
* Douglas–Peucker simplification, tolerance in **metres**.
|
||||
*
|
||||
* A degree-based tolerance would behave differently depending where you ride — a
|
||||
* degree of longitude is ~111 km at the equator and ~0 at the poles.
|
||||
*
|
||||
* Iterative with an explicit stack: a 20,000-point ride can blow the call stack in
|
||||
* the pathological case where recursion never balances.
|
||||
*/
|
||||
fun simplify(points: List<LatLon>, epsilonMeters: Double): List<LatLon> {
|
||||
if (points.size <= 2 || epsilonMeters <= 0.0) return points
|
||||
|
||||
val keep = BooleanArray(points.size)
|
||||
keep[0] = true
|
||||
keep[points.lastIndex] = true
|
||||
|
||||
val stack = ArrayDeque<Pair<Int, Int>>()
|
||||
stack.addLast(0 to points.lastIndex)
|
||||
|
||||
while (stack.isNotEmpty()) {
|
||||
val (first, last) = stack.removeLast()
|
||||
if (last <= first + 1) continue
|
||||
|
||||
var maxDist = 0.0
|
||||
var index = first
|
||||
for (i in (first + 1) until last) {
|
||||
val d = perpendicularDistanceMeters(points[i], points[first], points[last])
|
||||
if (d > maxDist) {
|
||||
maxDist = d
|
||||
index = i
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDist > epsilonMeters) {
|
||||
keep[index] = true
|
||||
stack.addLast(first to index)
|
||||
stack.addLast(index to last)
|
||||
}
|
||||
}
|
||||
|
||||
return points.filterIndexed { i, _ -> keep[i] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance from [p] to the segment [a]–[b], via a local equirectangular projection.
|
||||
*
|
||||
* Valid over the short spans between consecutive fixes and far cheaper than a
|
||||
* geodesic solution.
|
||||
*/
|
||||
fun perpendicularDistanceMeters(p: LatLon, a: LatLon, b: LatLon): Double {
|
||||
val latRef = (a.lat + b.lat) / 2 * DEG_TO_RAD
|
||||
val mPerDegLat = EARTH_RADIUS_M * DEG_TO_RAD
|
||||
val mPerDegLon = mPerDegLat * cos(latRef)
|
||||
|
||||
val ax = a.lon * mPerDegLon
|
||||
val ay = a.lat * mPerDegLat
|
||||
val bx = b.lon * mPerDegLon
|
||||
val by = b.lat * mPerDegLat
|
||||
val px = p.lon * mPerDegLon
|
||||
val py = p.lat * mPerDegLat
|
||||
|
||||
val dx = bx - ax
|
||||
val dy = by - ay
|
||||
val lengthSq = dx * dx + dy * dy
|
||||
if (lengthSq == 0.0) {
|
||||
// Degenerate segment: fall back to point distance.
|
||||
val ex = px - ax
|
||||
val ey = py - ay
|
||||
return sqrt(ex * ex + ey * ey)
|
||||
}
|
||||
|
||||
// Clamped projection, so a point beyond either end measures to the endpoint
|
||||
// rather than to the infinite line.
|
||||
val t = (((px - ax) * dx + (py - ay) * dy) / lengthSq).coerceIn(0.0, 1.0)
|
||||
val cx = ax + t * dx
|
||||
val cy = ay + t * dy
|
||||
val ex = px - cx
|
||||
val ey = py - cy
|
||||
return sqrt(ex * ex + ey * ey)
|
||||
}
|
||||
|
||||
/** Null for an empty list — callers must handle "no path" rather than centring on Null Island. */
|
||||
fun bounds(points: List<LatLon>): Bounds? {
|
||||
if (points.isEmpty()) return null
|
||||
var minLat = points[0].lat
|
||||
var maxLat = points[0].lat
|
||||
var minLon = points[0].lon
|
||||
var maxLon = points[0].lon
|
||||
for (p in points) {
|
||||
minLat = min(minLat, p.lat)
|
||||
maxLat = max(maxLat, p.lat)
|
||||
minLon = min(minLon, p.lon)
|
||||
maxLon = max(maxLon, p.lon)
|
||||
}
|
||||
return Bounds(minLat, minLon, maxLat, maxLon)
|
||||
}
|
||||
|
||||
/** Total length of a polyline. Callers must not span a pause with this. */
|
||||
fun pathLengthMeters(points: List<LatLon>): Double {
|
||||
if (points.size < 2) return 0.0
|
||||
var total = 0.0
|
||||
for (i in 1 until points.size) {
|
||||
total += haversineMeters(points[i - 1], points[i])
|
||||
}
|
||||
return total
|
||||
}
|
||||
}
|
||||
302
rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt
Normal file
302
rippr-src/app/src/main/java/com/rippr/stats/RideStatistics.kt
Normal file
@@ -0,0 +1,302 @@
|
||||
package com.rippr.stats
|
||||
|
||||
import com.rippr.Telemetry
|
||||
import com.rippr.data.Segment
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.geo.Geo
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class RideSummary(
|
||||
val distanceM: Double = 0.0,
|
||||
/** Wall clock, first fix to last. */
|
||||
val elapsedMillis: Long = 0,
|
||||
/** Time spent above the speed noise floor. */
|
||||
val movingMillis: Long = 0,
|
||||
val maxSpeedKmh: Float = 0f,
|
||||
/** Distance ÷ moving time — not the mean of the speed samples. */
|
||||
val avgMovingSpeedKmh: Float = 0f,
|
||||
val elevationGainM: Double = 0.0,
|
||||
val elevationLossM: Double = 0.0,
|
||||
val pointCount: Int = 0,
|
||||
) {
|
||||
val stoppedMillis: Long get() = max(0L, elapsedMillis - movingMillis)
|
||||
|
||||
companion object {
|
||||
val EMPTY = RideSummary()
|
||||
}
|
||||
}
|
||||
|
||||
data class SpeedBucket(val fromKmh: Int, val toKmh: Int, val millis: Long) {
|
||||
val label: String get() = "$fromKmh–$toKmh"
|
||||
}
|
||||
|
||||
data class ElevationSample(val distanceM: Double, val altitudeM: Double)
|
||||
|
||||
/**
|
||||
* Batch statistics over a stored ride.
|
||||
*
|
||||
* This is the authoritative computation. The recorder accumulates the same values live
|
||||
* as points arrive, but re-runs this on trip completion so a mid-ride process kill
|
||||
* cannot leave permanently skewed totals. Both paths must agree, which is why they share
|
||||
* the constants below rather than duplicating magic numbers.
|
||||
*/
|
||||
object RideStatistics {
|
||||
|
||||
/**
|
||||
* A GPS dropout leaves a large gap between consecutive fixes. Without a cap, a
|
||||
* two-minute tunnel counts as two minutes of moving time at the last known speed.
|
||||
*/
|
||||
const val MAX_SAMPLE_GAP_MILLIS = 10_000L
|
||||
|
||||
/**
|
||||
* Raw GPS altitude wanders by ±5–10 m even sitting still. Summing every positive
|
||||
* delta turns a flat ride into thousands of metres of climbing — the classic bug in
|
||||
* this calculation. A climb only counts once it exceeds this much in one direction.
|
||||
*/
|
||||
const val ELEVATION_HYSTERESIS_M = 3.0
|
||||
|
||||
fun compute(points: List<TrackPoint>, segments: List<Segment> = emptyList()): RideSummary {
|
||||
if (points.isEmpty()) return RideSummary.EMPTY
|
||||
|
||||
var distanceM = 0.0
|
||||
var movingMillis = 0L
|
||||
var maxSpeedKmh = 0f
|
||||
|
||||
val elevation = ElevationAccumulator()
|
||||
|
||||
// Grouping by segment is what keeps a pause from inventing distance: points
|
||||
// either side of a gas-station stop can be kilometres apart.
|
||||
for (segmentPoints in points.groupBy { it.segmentId }.values) {
|
||||
var previous: TrackPoint? = null
|
||||
for (point in segmentPoints) {
|
||||
maxSpeedKmh = max(maxSpeedKmh, point.speedKmh)
|
||||
elevation.add(point.altitudeM)
|
||||
|
||||
previous?.let { prev ->
|
||||
distanceM += Geo.haversineMeters(
|
||||
prev.latitude, prev.longitude, point.latitude, point.longitude,
|
||||
)
|
||||
val dt = point.timestamp - prev.timestamp
|
||||
if (dt in 1..MAX_SAMPLE_GAP_MILLIS &&
|
||||
point.speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH
|
||||
) {
|
||||
movingMillis += dt
|
||||
}
|
||||
}
|
||||
previous = point
|
||||
}
|
||||
}
|
||||
|
||||
elevation.finish()
|
||||
|
||||
val elapsedMillis = elapsedFor(points, segments)
|
||||
|
||||
// Guard the divide: a ride that never moved would otherwise produce NaN, which
|
||||
// Compose happily renders as the literal text "NaN".
|
||||
val avgMovingSpeedKmh = if (movingMillis > 0) {
|
||||
(distanceM / 1000.0 / (movingMillis / 3_600_000.0)).toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
return RideSummary(
|
||||
distanceM = distanceM,
|
||||
elapsedMillis = elapsedMillis,
|
||||
movingMillis = movingMillis,
|
||||
maxSpeedKmh = maxSpeedKmh,
|
||||
avgMovingSpeedKmh = avgMovingSpeedKmh,
|
||||
elevationGainM = elevation.gain,
|
||||
elevationLossM = elevation.loss,
|
||||
pointCount = points.size,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefers segment boundaries over point timestamps: they capture the time between a
|
||||
* segment's last fix and the pause itself, which point timestamps cannot see.
|
||||
*/
|
||||
private fun elapsedFor(points: List<TrackPoint>, segments: List<Segment>): Long {
|
||||
val closed = segments.mapNotNull { s -> s.endedAt?.let { it - s.startedAt } }
|
||||
if (closed.isNotEmpty() && closed.size == segments.size) {
|
||||
return closed.sum()
|
||||
}
|
||||
val timestamps = points.map { it.timestamp }
|
||||
return max(0L, (timestamps.maxOrNull() ?: 0L) - (timestamps.minOrNull() ?: 0L))
|
||||
}
|
||||
|
||||
/**
|
||||
* Time spent in each speed band. Buckets are keyed on the *interval* between fixes,
|
||||
* so the result is a time distribution rather than a sample count — a bike that sits
|
||||
* idle at 2 Hz would otherwise dominate purely by producing more samples.
|
||||
*/
|
||||
fun speedHistogram(points: List<TrackPoint>, bucketKmh: Int = 10): List<SpeedBucket> {
|
||||
if (points.size < 2 || bucketKmh <= 0) return emptyList()
|
||||
|
||||
val millisByBucket = sortedMapOf<Int, Long>()
|
||||
for (segmentPoints in points.groupBy { it.segmentId }.values) {
|
||||
for (i in 1 until segmentPoints.size) {
|
||||
val dt = segmentPoints[i].timestamp - segmentPoints[i - 1].timestamp
|
||||
if (dt !in 1..MAX_SAMPLE_GAP_MILLIS) continue
|
||||
val bucket = (segmentPoints[i].speedKmh / bucketKmh).toInt()
|
||||
millisByBucket[bucket] = (millisByBucket[bucket] ?: 0L) + dt
|
||||
}
|
||||
}
|
||||
|
||||
return millisByBucket.map { (bucket, millis) ->
|
||||
SpeedBucket(bucket * bucketKmh, (bucket + 1) * bucketKmh, millis)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Altitude against distance travelled, downsampled for charting.
|
||||
*
|
||||
* Sampled by distance along the path rather than by index, so a long stop does not
|
||||
* flatten the interesting part of the profile into a few pixels.
|
||||
*/
|
||||
fun elevationProfile(points: List<TrackPoint>, maxSamples: Int = 200): List<ElevationSample> {
|
||||
if (points.isEmpty()) return emptyList()
|
||||
if (points.size == 1) return listOf(ElevationSample(0.0, points[0].altitudeM))
|
||||
|
||||
val full = ArrayList<ElevationSample>(points.size)
|
||||
var cumulative = 0.0
|
||||
var previous: TrackPoint? = null
|
||||
var previousSegment = points.first().segmentId
|
||||
|
||||
for (point in points) {
|
||||
previous?.let { prev ->
|
||||
// Distance only accrues within a segment, matching compute().
|
||||
if (point.segmentId == previousSegment) {
|
||||
cumulative += Geo.haversineMeters(
|
||||
prev.latitude, prev.longitude, point.latitude, point.longitude,
|
||||
)
|
||||
}
|
||||
}
|
||||
full += ElevationSample(cumulative, point.altitudeM)
|
||||
previous = point
|
||||
previousSegment = point.segmentId
|
||||
}
|
||||
|
||||
if (full.size <= maxSamples) return full
|
||||
|
||||
val step = full.size.toDouble() / maxSamples
|
||||
return (0 until maxSamples).map { full[(it * step).roundToInt().coerceAtMost(full.lastIndex)] } +
|
||||
full.last()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elevation gain/loss accumulator that survives GPS altitude noise.
|
||||
*
|
||||
* Two mechanisms, because one is not enough:
|
||||
*
|
||||
* 1. **A moving-average window.** Raw GPS altitude wanders by ±5–10 m while completely
|
||||
* stationary. Averaging over [windowSize] samples cuts the noise by roughly
|
||||
* sqrt(windowSize), bringing it under the threshold below.
|
||||
* 2. **Reversal hysteresis.** A climb is only banked once the altitude turns back down
|
||||
* by more than [thresholdM] from its peak. Simply summing every delta that exceeds a
|
||||
* threshold does *not* work — noise crosses any small threshold constantly, and a
|
||||
* parked bike accumulates well over a kilometre of imaginary climbing. That was
|
||||
* measured, not assumed.
|
||||
*
|
||||
* Streaming rather than batch so the recorder can accumulate live and the batch
|
||||
* computation can reuse the identical code path.
|
||||
*/
|
||||
class ElevationAccumulator(
|
||||
private val windowSize: Int = SMOOTHING_WINDOW,
|
||||
private val thresholdM: Double = RideStatistics.ELEVATION_HYSTERESIS_M,
|
||||
) {
|
||||
private val window = ArrayDeque<Double>(windowSize)
|
||||
private var windowSum = 0.0
|
||||
|
||||
private var lastRaw: Double = 0.0
|
||||
private var lastCommitted: Double? = null
|
||||
private var extreme: Double = 0.0
|
||||
private var direction = 0 // 0 unknown, +1 climbing, -1 descending
|
||||
|
||||
var gain: Double = 0.0
|
||||
private set
|
||||
var loss: Double = 0.0
|
||||
private set
|
||||
|
||||
fun add(altitudeM: Double) {
|
||||
if (!altitudeM.isFinite()) return
|
||||
lastRaw = altitudeM
|
||||
|
||||
window.addLast(altitudeM)
|
||||
windowSum += altitudeM
|
||||
if (window.size > windowSize) windowSum -= window.removeFirst()
|
||||
val smoothed = windowSum / window.size
|
||||
|
||||
val committed = lastCommitted
|
||||
if (committed == null) {
|
||||
lastCommitted = smoothed
|
||||
extreme = smoothed
|
||||
return
|
||||
}
|
||||
|
||||
when (direction) {
|
||||
0 -> when {
|
||||
smoothed > committed + thresholdM -> { direction = 1; extreme = smoothed }
|
||||
smoothed < committed - thresholdM -> { direction = -1; extreme = smoothed }
|
||||
}
|
||||
1 -> if (smoothed > extreme) {
|
||||
extreme = smoothed
|
||||
} else if (smoothed < extreme - thresholdM) {
|
||||
gain += extreme - committed
|
||||
lastCommitted = extreme
|
||||
direction = -1
|
||||
extreme = smoothed
|
||||
}
|
||||
else -> if (smoothed < extreme) {
|
||||
extreme = smoothed
|
||||
} else if (smoothed > extreme + thresholdM) {
|
||||
loss += committed - extreme
|
||||
lastCommitted = extreme
|
||||
direction = 1
|
||||
extreme = smoothed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gain including the run still in progress, without mutating state.
|
||||
*
|
||||
* Safe to poll while recording continues — [finish] would end the run, which is wrong
|
||||
* mid-ride, but reading only [gain] would report zero for a climb that has not yet
|
||||
* turned back down.
|
||||
*/
|
||||
fun gainIncludingPending(): Double {
|
||||
val committed = lastCommitted ?: return gain
|
||||
val tip = if (direction == 1) max(extreme, lastRaw) else extreme
|
||||
return if (direction == 1 && tip > committed) gain + (tip - committed) else gain
|
||||
}
|
||||
|
||||
/**
|
||||
* Banks the run still in progress. Must be called once the last point is added, or a
|
||||
* steady climb to the summit with no descent afterwards reports zero gain.
|
||||
*/
|
||||
fun finish() {
|
||||
val committed = lastCommitted ?: return
|
||||
// The moving average lags the true altitude by about half a window, so the final
|
||||
// smoothed value clips the tail of a climb (a 100 m ascent measured 93 m before
|
||||
// this). Reconcile against the last raw reading to recover it.
|
||||
when (direction) {
|
||||
1 -> extreme = max(extreme, lastRaw)
|
||||
-1 -> extreme = min(extreme, lastRaw)
|
||||
}
|
||||
when {
|
||||
direction == 1 && extreme > committed -> gain += extreme - committed
|
||||
direction == -1 && extreme < committed -> loss += committed - extreme
|
||||
}
|
||||
lastCommitted = extreme
|
||||
direction = 0
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** ~7 s at 2 Hz: long enough to suppress wander, short enough to keep real terrain. */
|
||||
const val SMOOTHING_WINDOW = 15
|
||||
}
|
||||
}
|
||||
40
rippr-src/app/src/main/java/com/rippr/ui/Format.kt
Normal file
40
rippr-src/app/src/main/java/com/rippr/ui/Format.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
package com.rippr.ui
|
||||
|
||||
import com.rippr.data.Trip
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Display formatting shared across screens.
|
||||
*
|
||||
* Timestamps come from `Location.time`, which is UTC epoch millis, so everything here
|
||||
* converts to the device zone. Formatting in UTC would show a 21:00 ride as tomorrow.
|
||||
*/
|
||||
object Format {
|
||||
|
||||
private val dayTime: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("EEE d MMM · HH:mm", Locale.getDefault())
|
||||
|
||||
private val fileStamp: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmm", Locale.getDefault())
|
||||
|
||||
fun dateTime(epochMillis: Long): String =
|
||||
dayTime.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()))
|
||||
|
||||
fun fileTimestamp(epochMillis: Long): String =
|
||||
fileStamp.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()))
|
||||
|
||||
/** A trip's own name, or a date-derived label when it has none. */
|
||||
fun tripLabel(trip: Trip): String = trip.name ?: dateTime(trip.startedAt)
|
||||
|
||||
fun distance(meters: Double): String = when {
|
||||
meters < 1_000 -> "${meters.toInt()} m"
|
||||
else -> String.format(Locale.getDefault(), "%.1f km", meters / 1_000)
|
||||
}
|
||||
|
||||
fun speed(kmh: Float): String = String.format(Locale.getDefault(), "%.1f km/h", kmh)
|
||||
|
||||
fun elevation(meters: Double): String = "${meters.toInt()} m"
|
||||
}
|
||||
60
rippr-src/app/src/main/java/com/rippr/ui/RipprNavHost.kt
Normal file
60
rippr-src/app/src/main/java/com/rippr/ui/RipprNavHost.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package com.rippr.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.rippr.ui.detail.TripDetailScreen
|
||||
import com.rippr.ui.record.RecordScreen
|
||||
import com.rippr.ui.trips.TripsScreen
|
||||
|
||||
object Routes {
|
||||
const val RECORD = "record"
|
||||
const val TRIPS = "trips"
|
||||
const val TRIP_DETAIL = "trip/{tripId}"
|
||||
|
||||
fun tripDetail(tripId: Long) = "trip/$tripId"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RipprNavHost(
|
||||
onRequestPermissions: () -> Unit,
|
||||
hasLocationPermission: () -> Boolean,
|
||||
isBatteryExempt: () -> Boolean,
|
||||
onRequestBatteryExemption: () -> Unit,
|
||||
) {
|
||||
val nav = rememberNavController()
|
||||
|
||||
NavHost(navController = nav, startDestination = Routes.RECORD) {
|
||||
composable(Routes.RECORD) {
|
||||
RecordScreen(
|
||||
onOpenTrips = { nav.navigate(Routes.TRIPS) },
|
||||
onRequestPermissions = onRequestPermissions,
|
||||
hasLocationPermission = hasLocationPermission,
|
||||
isBatteryExempt = isBatteryExempt,
|
||||
onRequestBatteryExemption = onRequestBatteryExemption,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.TRIPS) {
|
||||
TripsScreen(
|
||||
onOpenTrip = { nav.navigate(Routes.tripDetail(it)) },
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.TRIP_DETAIL,
|
||||
// Declared explicitly: Room ids are Long, and without this the argument
|
||||
// silently arrives as a String.
|
||||
arguments = listOf(navArgument("tripId") { type = NavType.LongType }),
|
||||
) { entry ->
|
||||
TripDetailScreen(
|
||||
tripId = entry.arguments?.getLong("tripId") ?: 0L,
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
rippr-src/app/src/main/java/com/rippr/ui/RipprViewModels.kt
Normal file
40
rippr-src/app/src/main/java/com/rippr/ui/RipprViewModels.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
package com.rippr.ui
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import com.rippr.data.TripRepository
|
||||
import com.rippr.ui.detail.TripDetailViewModel
|
||||
import com.rippr.ui.record.RecordViewModel
|
||||
import com.rippr.ui.trips.TripsViewModel
|
||||
|
||||
/**
|
||||
* One factory for every screen.
|
||||
*
|
||||
* The app already uses `@Volatile` double-checked singletons for the database and
|
||||
* repository; a DI framework would be more ceremony than three screens earn.
|
||||
*/
|
||||
class RipprViewModelFactory(private val tripId: Long = 0) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
|
||||
val app = checkNotNull(extras[APPLICATION_KEY]) { "Application missing from CreationExtras" }
|
||||
val repo = TripRepository.get(app)
|
||||
return when {
|
||||
modelClass.isAssignableFrom(RecordViewModel::class.java) ->
|
||||
RecordViewModel(app as Application, repo) as T
|
||||
modelClass.isAssignableFrom(TripsViewModel::class.java) ->
|
||||
TripsViewModel(repo) as T
|
||||
modelClass.isAssignableFrom(TripDetailViewModel::class.java) ->
|
||||
TripDetailViewModel(repo, tripId) as T
|
||||
else -> error("Unknown ViewModel ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
inline fun <reified T : ViewModel> ripprViewModel(tripId: Long = 0): T =
|
||||
viewModel(factory = RipprViewModelFactory(tripId), key = "${T::class.java.name}:$tripId")
|
||||
220
rippr-src/app/src/main/java/com/rippr/ui/components/RideMap.kt
Normal file
220
rippr-src/app/src/main/java/com/rippr/ui/components/RideMap.kt
Normal file
@@ -0,0 +1,220 @@
|
||||
package com.rippr.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.geo.Geo
|
||||
import com.rippr.geo.LatLon
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
|
||||
/**
|
||||
* Rendering tuning.
|
||||
*
|
||||
* A three-hour ride is ~21,600 points and an undecimated polyline janks badly on pan and
|
||||
* zoom. Simplification here is **render-only** — stored and exported data always use raw
|
||||
* points.
|
||||
*/
|
||||
private const val SIMPLIFY_EPSILON_M = 5.0
|
||||
|
||||
/** Colour runs are split at this granularity so speed reads without per-vertex shading. */
|
||||
private const val SPEED_BUCKET_KMH = 10
|
||||
|
||||
/**
|
||||
* OSM Mapnik publishes tiles to zoom 19. Fitting a short ride — a 50 m loop around a
|
||||
* block — asks for a tighter zoom than that, and osmdroid then renders its empty grid
|
||||
* placeholder with no streets at all. Clamping keeps the base layer visible; the path is
|
||||
* vector and stays sharp regardless.
|
||||
*/
|
||||
private const val MAX_TILE_ZOOM = 19.0
|
||||
|
||||
/** Far enough out that street names are legible for a very short ride. */
|
||||
private const val SHORT_RIDE_ZOOM = 17.0
|
||||
|
||||
/**
|
||||
* The recorded path on an OpenStreetMap base layer.
|
||||
*
|
||||
* Only ever composed inside the trip detail screen. The recording service holds no
|
||||
* reference to any of this — the phone rides in a pocket, and a live map would cost
|
||||
* battery for something nobody is looking at.
|
||||
*/
|
||||
@Composable
|
||||
fun RideMap(
|
||||
points: List<TrackPoint>,
|
||||
modifier: Modifier = Modifier,
|
||||
lowColor: ComposeColor,
|
||||
highColor: ComposeColor,
|
||||
startColor: ComposeColor,
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
|
||||
// Held here so the lifecycle observer below can reach the same instance the
|
||||
// AndroidView is showing.
|
||||
val mapView = remember {
|
||||
MapView(context).apply {
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
setMultiTouchControls(true)
|
||||
maxZoomLevel = MAX_TILE_ZOOM
|
||||
// The built-in +/- buttons overlap the path and are redundant with pinch.
|
||||
zoomController.setVisibility(
|
||||
org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { mapView },
|
||||
update = { map ->
|
||||
map.overlays.clear()
|
||||
drawPath(map, points, lowColor, highColor, startColor)
|
||||
fitTo(map, points)
|
||||
map.invalidate()
|
||||
},
|
||||
// Not optional. Without onDetach the tile handles and the downloader thread
|
||||
// outlive the composable, and the leak compounds every time detail is opened.
|
||||
onRelease = { it.onDetach() },
|
||||
)
|
||||
|
||||
// MapView has its own lifecycle that does not follow Compose on its own. Skipping
|
||||
// this leaves the tile downloader running while the app is backgrounded.
|
||||
DisposableEffect(lifecycleOwner, mapView) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> mapView.onResume()
|
||||
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
mapView.onPause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One polyline per segment, further split into runs of similar speed.
|
||||
*
|
||||
* Segments are never joined: a pause means the rider stopped recording, and connecting
|
||||
* across it would draw a line through terrain never travelled. Bucketed runs are used
|
||||
* rather than osmdroid's per-vertex colouring, which is fiddly and gains little at the
|
||||
* zoom levels a ride is actually viewed at.
|
||||
*/
|
||||
private fun drawPath(
|
||||
map: MapView,
|
||||
points: List<TrackPoint>,
|
||||
lowColor: ComposeColor,
|
||||
highColor: ComposeColor,
|
||||
startColor: ComposeColor,
|
||||
) {
|
||||
if (points.isEmpty()) return
|
||||
val maxSpeed = points.maxOf { it.speedKmh }.coerceAtLeast(1f)
|
||||
|
||||
points.groupBy { it.segmentId }.values.forEach { segment ->
|
||||
if (segment.size < 2) return@forEach
|
||||
|
||||
val simplified = Geo.simplify(segment.map { LatLon(it.latitude, it.longitude) }, SIMPLIFY_EPSILON_M)
|
||||
val kept = matchSpeeds(segment, simplified)
|
||||
|
||||
var runStart = 0
|
||||
var runBucket = bucketOf(kept[0].second)
|
||||
for (i in 1..kept.size) {
|
||||
val bucket = if (i < kept.size) bucketOf(kept[i].second) else -1
|
||||
if (bucket != runBucket || i == kept.size) {
|
||||
// Overlap by one point so consecutive runs meet with no visible seam.
|
||||
val slice = kept.subList(runStart, minOf(i + 1, kept.size))
|
||||
if (slice.size >= 2) {
|
||||
map.overlays.add(
|
||||
Polyline().apply {
|
||||
setPoints(slice.map { GeoPoint(it.first.lat, it.first.lon) })
|
||||
outlinePaint.strokeWidth = 10f
|
||||
outlinePaint.color = blend(
|
||||
lowColor, highColor,
|
||||
(runBucket * SPEED_BUCKET_KMH).toFloat() / maxSpeed,
|
||||
).toArgb()
|
||||
}
|
||||
)
|
||||
}
|
||||
runStart = i
|
||||
runBucket = bucket
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start and end markers, drawn as tiny circles rather than pins to stay unobtrusive.
|
||||
val first = points.first()
|
||||
val last = points.last()
|
||||
map.overlays.add(dot(first.latitude, first.longitude, startColor))
|
||||
map.overlays.add(dot(last.latitude, last.longitude, highColor))
|
||||
}
|
||||
|
||||
private fun dot(lat: Double, lon: Double, color: ComposeColor) = Polyline().apply {
|
||||
setPoints(listOf(GeoPoint(lat, lon), GeoPoint(lat + 1e-6, lon + 1e-6)))
|
||||
outlinePaint.strokeWidth = 24f
|
||||
outlinePaint.strokeCap = android.graphics.Paint.Cap.ROUND
|
||||
outlinePaint.color = color.toArgb()
|
||||
}
|
||||
|
||||
/** Pairs each surviving point with the speed of the original fix nearest to it. */
|
||||
private fun matchSpeeds(
|
||||
original: List<TrackPoint>,
|
||||
simplified: List<LatLon>,
|
||||
): List<Pair<LatLon, Float>> {
|
||||
val byCoord = HashMap<Pair<Double, Double>, Float>(original.size)
|
||||
original.forEach { byCoord[it.latitude to it.longitude] = it.speedKmh }
|
||||
return simplified.map { it to (byCoord[it.lat to it.lon] ?: 0f) }
|
||||
}
|
||||
|
||||
private fun bucketOf(speedKmh: Float): Int = (speedKmh / SPEED_BUCKET_KMH).toInt()
|
||||
|
||||
private fun blend(from: ComposeColor, to: ComposeColor, t: Float): ComposeColor {
|
||||
val clamped = t.coerceIn(0f, 1f)
|
||||
return ComposeColor(
|
||||
red = from.red + (to.red - from.red) * clamped,
|
||||
green = from.green + (to.green - from.green) * clamped,
|
||||
blue = from.blue + (to.blue - from.blue) * clamped,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Frames the whole route with padding.
|
||||
*
|
||||
* A stationary "ride" produces a zero-area box, which either throws or lands at maximum
|
||||
* zoom, so that case falls back to centring at a fixed level.
|
||||
*/
|
||||
private fun fitTo(map: MapView, points: List<TrackPoint>) {
|
||||
val bounds = Geo.bounds(points.map { LatLon(it.latitude, it.longitude) }) ?: return
|
||||
if (bounds.isDegenerate) {
|
||||
map.controller.setZoom(SHORT_RIDE_ZOOM)
|
||||
map.controller.setCenter(GeoPoint(bounds.centerLat, bounds.centerLon))
|
||||
return
|
||||
}
|
||||
map.post {
|
||||
map.zoomToBoundingBox(
|
||||
BoundingBox(bounds.maxLat, bounds.maxLon, bounds.minLat, bounds.minLon),
|
||||
false,
|
||||
48,
|
||||
)
|
||||
// zoomToBoundingBox ignores maxZoomLevel, so a short ride still lands past the
|
||||
// last zoom OSM publishes and the base layer renders as an empty grid.
|
||||
if (map.zoomLevelDouble > MAX_TILE_ZOOM) {
|
||||
map.controller.setZoom(SHORT_RIDE_ZOOM)
|
||||
map.controller.setCenter(GeoPoint(bounds.centerLat, bounds.centerLon))
|
||||
}
|
||||
}
|
||||
}
|
||||
67
rippr-src/app/src/main/java/com/rippr/ui/components/Stats.kt
Normal file
67
rippr-src/app/src/main/java/com/rippr/ui/components/Stats.kt
Normal file
@@ -0,0 +1,67 @@
|
||||
package com.rippr.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/** The one number that matters, rendered large. */
|
||||
@Composable
|
||||
fun BigStat(label: String, value: String, unit: String) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
label,
|
||||
fontSize = 13.sp,
|
||||
letterSpacing = 2.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(value, fontSize = 64.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
|
||||
Text(" $unit", fontSize = 18.sp, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = MaterialTheme.colorScheme.outline)
|
||||
Text(value, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
confirmLabel: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(); onDismiss() }) {
|
||||
Text(confirmLabel, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package com.rippr.ui.detail
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rippr.Telemetry
|
||||
import com.rippr.stats.ElevationSample
|
||||
import com.rippr.stats.SpeedBucket
|
||||
import com.rippr.ui.Format
|
||||
import com.rippr.Config
|
||||
import com.rippr.ui.components.ConfirmDialog
|
||||
import com.rippr.export.ExportFormat
|
||||
import com.rippr.export.ExportManager
|
||||
import com.rippr.ui.components.RideMap
|
||||
import com.rippr.ui.components.StatRow
|
||||
import com.rippr.ui.ripprViewModel
|
||||
|
||||
@Composable
|
||||
fun TripDetailScreen(
|
||||
tripId: Long,
|
||||
onBack: () -> Unit,
|
||||
viewModel: TripDetailViewModel = ripprViewModel(tripId),
|
||||
) {
|
||||
val ui by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var renaming by remember { mutableStateOf(false) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
|
||||
(ui as? TripDetailUiState.Ready)?.let { ready ->
|
||||
if (renaming) {
|
||||
RenameDialog(
|
||||
initial = ready.trip.name.orEmpty(),
|
||||
onConfirm = { viewModel.rename(it) },
|
||||
onDismiss = { renaming = false },
|
||||
)
|
||||
}
|
||||
if (confirmDelete) {
|
||||
ConfirmDialog(
|
||||
title = "Delete this ride?",
|
||||
message = "${ready.summary.pointCount} points over " +
|
||||
"${Format.distance(ready.summary.distanceM)} will be permanently deleted.",
|
||||
confirmLabel = "Delete",
|
||||
onConfirm = { viewModel.delete(onBack) },
|
||||
onDismiss = { confirmDelete = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onBack) { Text("‹ Rides") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (ui is TripDetailUiState.Ready) {
|
||||
TextButton(onClick = { renaming = true }) { Text("Rename") }
|
||||
TextButton(onClick = { confirmDelete = true }) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val state = ui) {
|
||||
TripDetailUiState.Loading -> Box(Modifier.fillMaxSize(), Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
TripDetailUiState.NotFound -> Box(Modifier.fillMaxSize(), Alignment.Center) {
|
||||
Text("This ride no longer exists.", color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
|
||||
is TripDetailUiState.Ready -> Content(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(state: TripDetailUiState.Ready) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
Format.tripLabel(state.trip),
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (state.trip.name != null) {
|
||||
Text(
|
||||
Format.dateTime(state.trip.startedAt),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
|
||||
MapSection(state)
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
val s = state.summary
|
||||
StatRow("Distance", Format.distance(s.distanceM))
|
||||
StatRow("Moving time", Telemetry.formatDuration(s.movingMillis))
|
||||
StatRow("Elapsed", Telemetry.formatDuration(s.elapsedMillis))
|
||||
StatRow("Max speed", Format.speed(s.maxSpeedKmh))
|
||||
StatRow("Avg moving speed", Format.speed(s.avgMovingSpeedKmh))
|
||||
StatRow("Elevation gain", Format.elevation(s.elevationGainM))
|
||||
StatRow("Points", s.pointCount.toString())
|
||||
}
|
||||
}
|
||||
|
||||
ExportCard(state)
|
||||
|
||||
ChartCard("ELEVATION") { ElevationChart(state.elevation) }
|
||||
ChartCard("SPEED DISTRIBUTION") { SpeedHistogram(state.histogram) }
|
||||
|
||||
if (state.isMultiSegment) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"${state.segments.size} SEGMENTS",
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 2.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"This ride was paused ${state.segments.size - 1} time(s). " +
|
||||
"The path will show a gap at each pause.",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExportCard(state: TripDetailUiState.Ready) {
|
||||
val context = LocalContext.current
|
||||
val viewModel: TripDetailViewModel = ripprViewModel(state.trip.id)
|
||||
|
||||
fun share(format: ExportFormat) = viewModel.export(context, format) { uri ->
|
||||
context.startActivity(
|
||||
android.content.Intent.createChooser(
|
||||
ExportManager.shareIntent(uri, format),
|
||||
"Share ride",
|
||||
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"EXPORT",
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 2.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { share(ExportFormat.GPX) },
|
||||
enabled = state.points.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("GPX") }
|
||||
Spacer(Modifier.width(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = { share(ExportFormat.GEOJSON) },
|
||||
enabled = state.points.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("GeoJSON") }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"GPX keeps each pause as a separate track segment, so Strava and Garmin " +
|
||||
"will not draw a line across the gap.",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapSection(state: TripDetailUiState.Ready) {
|
||||
val context = LocalContext.current
|
||||
// Read once per composition of this screen; the switch below drives recomposition.
|
||||
var mapEnabled by remember { mutableStateOf(Config.mapEnabled(context)) }
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
if (mapEnabled) {
|
||||
if (state.points.size >= 2) {
|
||||
RideMap(
|
||||
points = state.points,
|
||||
// osmdroid draws past its measured bounds, which otherwise puts
|
||||
// tiles on top of the row below it.
|
||||
modifier = Modifier.fillMaxWidth().height(260.dp).clipToBounds(),
|
||||
lowColor = MaterialTheme.colorScheme.outline,
|
||||
highColor = MaterialTheme.colorScheme.primary,
|
||||
startColor = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxWidth().height(120.dp), Alignment.Center) {
|
||||
Text(
|
||||
"Not enough points to draw a path",
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
// Opaque, so no tile can bleed through underneath the control.
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Show map",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = mapEnabled,
|
||||
onCheckedChange = {
|
||||
// Off means the composable is never created, so no tile is
|
||||
// requested at all.
|
||||
mapEnabled = it
|
||||
Config.setMapEnabled(context, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChartCard(title: String, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
title,
|
||||
fontSize = 12.sp,
|
||||
letterSpacing = 2.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ElevationChart(samples: List<ElevationSample>) {
|
||||
if (samples.size < 2) {
|
||||
NotEnoughData(); return
|
||||
}
|
||||
val minAlt = samples.minOf { it.altitudeM }
|
||||
val maxAlt = samples.maxOf { it.altitudeM }
|
||||
val range = (maxAlt - minAlt).takeIf { it > 0.5 } ?: 1.0 // flat ride: avoid /0
|
||||
val maxDist = samples.last().distanceM.takeIf { it > 0 } ?: 1.0
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
|
||||
Column {
|
||||
Canvas(Modifier.fillMaxWidth().height(120.dp)) {
|
||||
val path = Path()
|
||||
samples.forEachIndexed { i, sample ->
|
||||
val x = (sample.distanceM / maxDist * size.width).toFloat()
|
||||
val y = (size.height - (sample.altitudeM - minAlt) / range * size.height).toFloat()
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
drawPath(path, accent, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 3f))
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween) {
|
||||
AxisLabel("${minAlt.toInt()} m")
|
||||
AxisLabel(Format.distance(maxDist))
|
||||
AxisLabel("${maxAlt.toInt()} m")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SpeedHistogram(buckets: List<SpeedBucket>) {
|
||||
if (buckets.isEmpty()) {
|
||||
NotEnoughData(); return
|
||||
}
|
||||
val maxMillis = buckets.maxOf { it.millis }.coerceAtLeast(1L)
|
||||
val accent = MaterialTheme.colorScheme.primary
|
||||
|
||||
Column {
|
||||
Canvas(Modifier.fillMaxWidth().height(120.dp)) {
|
||||
val slot = size.width / buckets.size
|
||||
val barWidth = slot * 0.7f
|
||||
buckets.forEachIndexed { i, bucket ->
|
||||
val h = (bucket.millis.toFloat() / maxMillis) * size.height
|
||||
drawRect(
|
||||
color = accent,
|
||||
topLeft = Offset(i * slot + (slot - barWidth) / 2, size.height - h),
|
||||
size = androidx.compose.ui.geometry.Size(barWidth, h),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween) {
|
||||
AxisLabel("${buckets.first().fromKmh} km/h")
|
||||
AxisLabel("${buckets.last().toKmh} km/h")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AxisLabel(text: String) {
|
||||
Text(
|
||||
text,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotEnoughData() {
|
||||
Box(Modifier.fillMaxWidth().height(80.dp), Alignment.Center) {
|
||||
Text(
|
||||
"Not enough data",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
initial: String,
|
||||
onConfirm: (String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf(initial) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Name this ride") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
placeholder = { Text("Leave blank to use the date") },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
// Blank collapses to null so the stored value and the date-derived label
|
||||
// cannot diverge; the repository trims and enforces this too.
|
||||
onConfirm(text.trim().takeIf { it.isNotEmpty() })
|
||||
onDismiss()
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.rippr.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rippr.data.Segment
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.data.Trip
|
||||
import com.rippr.data.TripRepository
|
||||
import com.rippr.export.ExportFormat
|
||||
import com.rippr.export.ExportManager
|
||||
import com.rippr.ui.Format
|
||||
import com.rippr.stats.ElevationSample
|
||||
import com.rippr.stats.RideStatistics
|
||||
import com.rippr.stats.RideSummary
|
||||
import com.rippr.stats.SpeedBucket
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
sealed interface TripDetailUiState {
|
||||
data object Loading : TripDetailUiState
|
||||
data object NotFound : TripDetailUiState
|
||||
data class Ready(
|
||||
val trip: Trip,
|
||||
val summary: RideSummary,
|
||||
val segments: List<Segment>,
|
||||
val points: List<TrackPoint>,
|
||||
val elevation: List<ElevationSample>,
|
||||
val histogram: List<SpeedBucket>,
|
||||
) : TripDetailUiState {
|
||||
val isMultiSegment: Boolean get() = segments.size > 1
|
||||
}
|
||||
}
|
||||
|
||||
class TripDetailViewModel(
|
||||
private val repo: TripRepository,
|
||||
private val tripId: Long,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<TripDetailUiState>(TripDetailUiState.Loading)
|
||||
val uiState: StateFlow<TripDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads on IO. A three-hour ride is ~21,600 rows, and reading that on the main thread
|
||||
* would jank the transition into this screen.
|
||||
*/
|
||||
private fun load() = viewModelScope.launch {
|
||||
val state = withContext(Dispatchers.IO) {
|
||||
val trip = repo.tripById(tripId) ?: return@withContext TripDetailUiState.NotFound
|
||||
val points = repo.pointsForTrip(tripId)
|
||||
val segments = repo.segmentsForTrip(tripId)
|
||||
TripDetailUiState.Ready(
|
||||
trip = trip,
|
||||
summary = RideStatistics.compute(points, segments),
|
||||
segments = segments,
|
||||
points = points,
|
||||
elevation = RideStatistics.elevationProfile(points),
|
||||
histogram = RideStatistics.speedHistogram(points),
|
||||
)
|
||||
}
|
||||
_uiState.value = state
|
||||
}
|
||||
|
||||
fun rename(name: String?) = viewModelScope.launch {
|
||||
repo.renameTrip(tripId, name)
|
||||
load()
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the export off the main thread — a long ride is several MB of string
|
||||
* building — then hands the resulting URI back for sharing.
|
||||
*/
|
||||
fun export(context: Context, format: ExportFormat, onReady: (Uri) -> Unit) =
|
||||
viewModelScope.launch {
|
||||
val ready = _uiState.value as? TripDetailUiState.Ready ?: return@launch
|
||||
val uri = withContext(Dispatchers.IO) {
|
||||
ExportManager.write(
|
||||
context = context.applicationContext,
|
||||
trip = ready.trip,
|
||||
segments = ready.segments,
|
||||
points = ready.points,
|
||||
name = Format.tripLabel(ready.trip),
|
||||
fileStamp = Format.fileTimestamp(ready.trip.startedAt),
|
||||
format = format,
|
||||
)
|
||||
}
|
||||
onReady(uri)
|
||||
}
|
||||
|
||||
fun delete(onDeleted: () -> Unit) = viewModelScope.launch {
|
||||
repo.deleteTrip(tripId)
|
||||
onDeleted()
|
||||
}
|
||||
}
|
||||
192
rippr-src/app/src/main/java/com/rippr/ui/record/RecordScreen.kt
Normal file
192
rippr-src/app/src/main/java/com/rippr/ui/record/RecordScreen.kt
Normal file
@@ -0,0 +1,192 @@
|
||||
package com.rippr.ui.record
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rippr.Telemetry
|
||||
import com.rippr.ui.Format
|
||||
import com.rippr.ui.components.BigStat
|
||||
import com.rippr.ui.components.ConfirmDialog
|
||||
import com.rippr.ui.components.StatRow
|
||||
import com.rippr.ui.ripprViewModel
|
||||
|
||||
@Composable
|
||||
fun RecordScreen(
|
||||
onOpenTrips: () -> Unit,
|
||||
onRequestPermissions: () -> Unit,
|
||||
hasLocationPermission: () -> Boolean,
|
||||
isBatteryExempt: () -> Boolean,
|
||||
onRequestBatteryExemption: () -> Unit,
|
||||
viewModel: RecordViewModel = ripprViewModel(),
|
||||
) {
|
||||
val ui by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var confirmDiscard by remember { mutableStateOf(false) }
|
||||
|
||||
if (confirmDiscard) {
|
||||
val points = ui.trip?.pointCount ?: 0
|
||||
val distance = Format.distance(ui.trip?.distanceM ?: 0.0)
|
||||
ConfirmDialog(
|
||||
title = "Discard this ride?",
|
||||
message = "$points points recorded over $distance will be permanently deleted.",
|
||||
confirmLabel = "Discard",
|
||||
onConfirm = viewModel::discard,
|
||||
onDismiss = { confirmDiscard = false },
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"RIPPR",
|
||||
fontSize = 34.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = 6.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
TextButton(onClick = onOpenTrips) { Text("Rides") }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
// Current speed, not max: a max figure only moves when you beat it, which reads
|
||||
// as a frozen screen while riding steadily.
|
||||
BigStat("SPEED", "%.0f".format(ui.currentSpeedKmh), "km/h")
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
if (ui.isIdle) {
|
||||
// A resting state, not a zeroed ride — otherwise the screen looks like
|
||||
// a recording that is going nowhere.
|
||||
StatRow("Status", "Ready")
|
||||
StatRow("Last ride", "See Rides")
|
||||
} else {
|
||||
val trip = ui.trip!!
|
||||
StatRow("Distance", Format.distance(trip.distanceM))
|
||||
StatRow("Elapsed", Telemetry.formatDuration(ui.elapsedMillis))
|
||||
StatRow("Moving time", Telemetry.formatDuration(trip.movingMillis))
|
||||
StatRow("Max speed", Format.speed(trip.maxSpeedKmh))
|
||||
StatRow("Points captured", trip.pointCount.toString())
|
||||
StatRow("Elevation gain", Format.elevation(trip.elevationGainM))
|
||||
if (ui.isPaused) StatRow("Status", "Paused")
|
||||
}
|
||||
ui.uploadError?.let { StatRow("Last upload error", it) }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Controls(
|
||||
ui = ui,
|
||||
onStart = {
|
||||
if (hasLocationPermission()) viewModel.start() else onRequestPermissions()
|
||||
},
|
||||
onPause = viewModel::pause,
|
||||
onResume = viewModel::resume,
|
||||
onStop = viewModel::stop,
|
||||
onDiscard = { confirmDiscard = true },
|
||||
)
|
||||
|
||||
if (!isBatteryExempt()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(onClick = onRequestBatteryExemption) {
|
||||
Text("Disable battery optimization (recommended)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Controls(
|
||||
ui: RecordUiState,
|
||||
onStart: () -> Unit,
|
||||
onPause: () -> Unit,
|
||||
onResume: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
) {
|
||||
when {
|
||||
ui.isIdle -> PrimaryButton("START RECORDING", onStart)
|
||||
|
||||
ui.isRecording -> Column(Modifier.fillMaxWidth()) {
|
||||
PrimaryButton("PAUSE", onPause)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
SecondaryButton("STOP", onStop, Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
// Discard is offered only while paused. Putting a destructive action next to
|
||||
// Pause during a live ride invites a gloved mis-tap at speed.
|
||||
else -> Column(Modifier.fillMaxWidth()) {
|
||||
PrimaryButton("RESUME", onResume)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
SecondaryButton("STOP", onStop, Modifier.weight(1f))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
SecondaryButton("DISCARD", onDiscard, Modifier.weight(1f), destructive = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 72dp tall throughout — these get pressed with gloves on. */
|
||||
@Composable
|
||||
private fun PrimaryButton(label: String, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth().height(72.dp),
|
||||
) {
|
||||
Text(label, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecondaryButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
destructive: Boolean = false,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(56.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = if (destructive) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
),
|
||||
) {
|
||||
Text(label, fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.rippr.ui.record
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rippr.TrackingService
|
||||
import com.rippr.LiveTelemetry
|
||||
import com.rippr.UploadStatus
|
||||
import com.rippr.data.Trip
|
||||
import com.rippr.data.TripRepository
|
||||
import com.rippr.data.TripState
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** Everything the recording screen draws, in one snapshot. */
|
||||
data class RecordUiState(
|
||||
val trip: Trip? = null,
|
||||
val uploadError: String? = null,
|
||||
val currentSpeedKmh: Float = 0f,
|
||||
/** Wall clock since the ride began, ticking every second. */
|
||||
val elapsedMillis: Long = 0,
|
||||
) {
|
||||
val state: TripState? get() = trip?.state
|
||||
val isIdle: Boolean get() = trip == null
|
||||
val isRecording: Boolean get() = trip?.state == TripState.RECORDING
|
||||
val isPaused: Boolean get() = trip?.state == TripState.PAUSED
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RecordViewModel(
|
||||
application: Application,
|
||||
private val trips: TripRepository,
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
/**
|
||||
* Drives the elapsed clock. The Trip row only advances `movingMillis`, and only on a
|
||||
* ~2 s flush while actually moving, so without this the screen looks frozen at a
|
||||
* traffic light — which is exactly how it read on the first real ride.
|
||||
*/
|
||||
private val ticker = flow {
|
||||
while (true) {
|
||||
emit(System.currentTimeMillis())
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State is held here rather than queried on demand, which is what lets the buttons
|
||||
* decide synchronously what they do. T03 briefly had `onToggleClicked` suspend to ask
|
||||
* the database "are we recording" — correct, but a button should never have to wait.
|
||||
*/
|
||||
val uiState: StateFlow<RecordUiState> =
|
||||
combine(
|
||||
trips.observeActiveTrip(),
|
||||
UploadStatus.lastError,
|
||||
LiveTelemetry.speedKmh,
|
||||
ticker,
|
||||
) { trip, error, speed, now ->
|
||||
RecordUiState(
|
||||
trip = trip,
|
||||
uploadError = error,
|
||||
currentSpeedKmh = speed,
|
||||
elapsedMillis = trip?.let { (now - it.startedAt).coerceAtLeast(0L) } ?: 0L,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RecordUiState())
|
||||
|
||||
fun start() = send(TrackingService.ACTION_START, foreground = true)
|
||||
fun pause() = send(TrackingService.ACTION_PAUSE)
|
||||
fun resume() = send(TrackingService.ACTION_RESUME)
|
||||
fun stop() = send(TrackingService.ACTION_STOP)
|
||||
fun discard() = send(TrackingService.ACTION_DISCARD)
|
||||
|
||||
private fun send(action: String, foreground: Boolean = false) {
|
||||
val context = getApplication<Application>()
|
||||
val intent = Intent(context, TrackingService::class.java).setAction(action)
|
||||
// Only START may need to bring the service up; the rest target a running service,
|
||||
// and startForegroundService on an already-foreground service is unnecessary.
|
||||
if (foreground) {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
54
rippr-src/app/src/main/java/com/rippr/ui/theme/Theme.kt
Normal file
54
rippr-src/app/src/main/java/com/rippr/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package com.rippr.ui.theme
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Matches the launcher icon: near-black tarmac with a safety-orange accent.
|
||||
private val Accent = Color(0xFFFF5722)
|
||||
private val Ground = Color(0xFF101418)
|
||||
private val Surface = Color(0xFF181D23)
|
||||
private val Ink = Color(0xFFF2F5F8)
|
||||
private val Outline = Color(0xFF66727E)
|
||||
private val Danger = Color(0xFFE53935)
|
||||
|
||||
private val RipprColors = darkColorScheme(
|
||||
primary = Accent,
|
||||
onPrimary = Color(0xFF100A06),
|
||||
secondary = Outline,
|
||||
background = Ground,
|
||||
onBackground = Ink,
|
||||
surface = Surface,
|
||||
onSurface = Ink,
|
||||
surfaceVariant = Color(0xFF212832),
|
||||
onSurfaceVariant = Ink,
|
||||
outline = Outline,
|
||||
error = Danger,
|
||||
onError = Ink,
|
||||
)
|
||||
|
||||
/**
|
||||
* Dark-only by design. This is read at a glance in daylight with a visor down, and a
|
||||
* light theme would be actively worse for that.
|
||||
*/
|
||||
@Composable
|
||||
fun RipprTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = RipprColors,
|
||||
typography = Typography(),
|
||||
) {
|
||||
// The Surface is load-bearing, not decoration: it sets LocalContentColor. Without
|
||||
// it, any Text that does not name a colour falls back to black — which on this
|
||||
// near-black ground renders as nothing at all.
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
192
rippr-src/app/src/main/java/com/rippr/ui/trips/TripsScreen.kt
Normal file
192
rippr-src/app/src/main/java/com/rippr/ui/trips/TripsScreen.kt
Normal file
@@ -0,0 +1,192 @@
|
||||
package com.rippr.ui.trips
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rippr.Telemetry
|
||||
import com.rippr.data.Trip
|
||||
import com.rippr.ui.Format
|
||||
import com.rippr.ui.components.ConfirmDialog
|
||||
import com.rippr.ui.ripprViewModel
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TripsScreen(
|
||||
onOpenTrip: (Long) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: TripsViewModel = ripprViewModel(),
|
||||
) {
|
||||
val ui by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
var confirmMerge by remember { mutableStateOf(false) }
|
||||
|
||||
if (confirmDelete) {
|
||||
ConfirmDialog(
|
||||
title = "Delete ${ui.selection.size} ride(s)?",
|
||||
message = "Every recorded point for these rides will be permanently deleted.",
|
||||
confirmLabel = "Delete",
|
||||
onConfirm = viewModel::deleteSelected,
|
||||
onDismiss = { confirmDelete = false },
|
||||
)
|
||||
}
|
||||
if (confirmMerge) {
|
||||
val total = ui.trips.filter { it.id in ui.selection }.sumOf { it.distanceM }
|
||||
ConfirmDialog(
|
||||
title = "Merge two rides?",
|
||||
message = "They become one ride of about ${Format.distance(total)}. " +
|
||||
"The join is kept as a segment break, so no straight line is drawn across it.",
|
||||
confirmLabel = "Merge",
|
||||
onConfirm = viewModel::mergeSelected,
|
||||
onDismiss = { confirmMerge = false },
|
||||
)
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (ui.selecting) {
|
||||
TextButton(onClick = viewModel::clearSelection) { Text("Cancel") }
|
||||
Text(
|
||||
"${ui.selection.size} selected",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
} else {
|
||||
TextButton(onClick = onBack) { Text("‹ Record") }
|
||||
Text(
|
||||
"RIDES",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = 4.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (ui.selecting) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { confirmMerge = true },
|
||||
enabled = ui.canMerge,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text(if (ui.canMerge) "MERGE" else "MERGE (pick 2)") }
|
||||
Spacer(Modifier.width(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = { confirmDelete = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("DELETE", color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
!ui.loaded -> Unit
|
||||
ui.isEmpty -> EmptyState()
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(ui.trips, key = { it.id }) { trip ->
|
||||
TripRow(
|
||||
trip = trip,
|
||||
selected = trip.id in ui.selection,
|
||||
selecting = ui.selecting,
|
||||
onClick = {
|
||||
if (ui.selecting) viewModel.toggleSelection(trip.id)
|
||||
else onOpenTrip(trip.id)
|
||||
},
|
||||
onLongClick = { viewModel.toggleSelection(trip.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("No rides yet", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Head back and hit START RECORDING.",
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun TripRow(
|
||||
trip: Trip,
|
||||
selected: Boolean,
|
||||
selecting: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
border = if (selected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
(if (selecting && selected) "✓ " else "") + Format.tripLabel(trip),
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
Format.distance(trip.distanceM),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
Text(
|
||||
"${Telemetry.formatDuration(trip.movingMillis)} moving · max ${Format.speed(trip.maxSpeedKmh)}",
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.rippr.ui.trips
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rippr.data.Trip
|
||||
import com.rippr.data.TripRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class TripsUiState(
|
||||
val trips: List<Trip> = emptyList(),
|
||||
val loaded: Boolean = false,
|
||||
val selection: Set<Long> = emptySet(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = loaded && trips.isEmpty()
|
||||
val selecting: Boolean get() = selection.isNotEmpty()
|
||||
|
||||
/** Merge combines exactly two rides; anything else is ambiguous. */
|
||||
val canMerge: Boolean get() = selection.size == 2
|
||||
}
|
||||
|
||||
class TripsViewModel(private val repo: TripRepository) : ViewModel() {
|
||||
|
||||
private val selection = MutableStateFlow<Set<Long>>(emptySet())
|
||||
|
||||
/**
|
||||
* Every value shown in the list is already persisted on the Trip row, so this screen
|
||||
* never touches the point table. That keeps it fast with hundreds of rides.
|
||||
*/
|
||||
val uiState: StateFlow<TripsUiState> =
|
||||
combine(repo.observeCompletedTrips(), selection) { trips, selected ->
|
||||
// Drop ids that no longer exist, so a deleted trip cannot linger in selection.
|
||||
val live = selected.intersect(trips.map { it.id }.toSet())
|
||||
TripsUiState(trips = trips, loaded = true, selection = live)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TripsUiState())
|
||||
|
||||
fun toggleSelection(tripId: Long) {
|
||||
selection.value = selection.value.let {
|
||||
if (tripId in it) it - tripId else it + tripId
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
selection.value = emptySet()
|
||||
}
|
||||
|
||||
fun deleteSelected() = viewModelScope.launch {
|
||||
selection.value.forEach { repo.deleteTrip(it) }
|
||||
selection.value = emptySet()
|
||||
}
|
||||
|
||||
fun mergeSelected() = viewModelScope.launch {
|
||||
val ids = selection.value.toList()
|
||||
if (ids.size == 2) repo.mergeTrips(ids[0], ids[1])
|
||||
selection.value = emptySet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- Track ring. -->
|
||||
<path
|
||||
android:pathData="M86.0,54.0 A32.0,32.0 0 1 1 22.0,54.0 A32.0,32.0 0 1 1 86.0,54.0"
|
||||
android:strokeColor="#66727E"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeAlpha="0.6" />
|
||||
|
||||
<!-- Material Symbols `route`, Apache-2.0. Scaled from its 960 box onto the icon canvas. -->
|
||||
<group
|
||||
android:translateX="34"
|
||||
android:translateY="74"
|
||||
android:scaleX="0.041667"
|
||||
android:scaleY="0.041667">
|
||||
<path
|
||||
android:pathData="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"
|
||||
android:fillColor="#F2F5F8" />
|
||||
</group>
|
||||
|
||||
<!-- Recording-progress segment: the SVG's dash pattern, cut with trimPath. -->
|
||||
<path
|
||||
android:pathData="M86.0,54.0 A32.0,32.0 0 1 1 22.0,54.0 A32.0,32.0 0 1 1 86.0,54.0"
|
||||
android:strokeColor="#FF5722"
|
||||
android:strokeWidth="3.5"
|
||||
android:strokeLineCap="round"
|
||||
android:trimPathStart="0.3701"
|
||||
android:trimPathEnd="0.4960" />
|
||||
</vector>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M86.0,54.0 A32.0,32.0 0 1 1 22.0,54.0 A32.0,32.0 0 1 1 86.0,54.0"
|
||||
android:strokeColor="#FF000000"
|
||||
android:strokeWidth="3.5" />
|
||||
<group
|
||||
android:translateX="34"
|
||||
android:translateY="74"
|
||||
android:scaleX="0.041667"
|
||||
android:scaleY="0.041667">
|
||||
<path
|
||||
android:pathData="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"
|
||||
android:fillColor="#FF000000" />
|
||||
</group>
|
||||
</vector>
|
||||
18
rippr-src/app/src/main/res/drawable/ic_stat_rippr.xml
Normal file
18
rippr-src/app/src/main/res/drawable/ic_stat_rippr.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<group
|
||||
android:translateX="2"
|
||||
android:translateY="22"
|
||||
android:scaleX="0.020833"
|
||||
android:scaleY="0.020833">
|
||||
<path
|
||||
android:pathData="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"
|
||||
android:fillColor="#FFFFFFFF" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
4
rippr-src/app/src/main/res/values/colors.xml
Normal file
4
rippr-src/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#101418</color>
|
||||
</resources>
|
||||
4
rippr-src/app/src/main/res/values/strings.xml
Normal file
4
rippr-src/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Rippr</string>
|
||||
</resources>
|
||||
8
rippr-src/app/src/main/res/values/themes.xml
Normal file
8
rippr-src/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Rippr" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/black</item>
|
||||
<item name="android:navigationBarColor">@android:color/black</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
2
rippr-src/app/src/main/res/xml/backup_rules.xml
Normal file
2
rippr-src/app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content />
|
||||
6
rippr-src/app/src/main/res/xml/file_paths.xml
Normal file
6
rippr-src/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- Exports are transient handoffs, so they live in cache where the system can
|
||||
reclaim them. RipprApp clears the directory on start. -->
|
||||
<cache-path name="exports" path="exports/" />
|
||||
</paths>
|
||||
181
rippr-src/app/src/test/java/com/rippr/AccumulatorTest.kt
Normal file
181
rippr-src/app/src/test/java/com/rippr/AccumulatorTest.kt
Normal file
@@ -0,0 +1,181 @@
|
||||
package com.rippr
|
||||
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.stats.RideStatistics
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AccumulatorTest {
|
||||
|
||||
private var nextId = 1L
|
||||
|
||||
private fun point(
|
||||
segmentId: Long = 1,
|
||||
ts: Long,
|
||||
lat: Double = 51.0,
|
||||
speed: Float = 40f,
|
||||
alt: Double = 1000.0,
|
||||
) = TrackPoint(
|
||||
id = nextId++,
|
||||
tripId = 1,
|
||||
segmentId = segmentId,
|
||||
timestamp = ts,
|
||||
latitude = lat,
|
||||
longitude = -114.0,
|
||||
speedKmh = speed,
|
||||
altitudeM = alt,
|
||||
)
|
||||
|
||||
private fun run(n: Int, segmentId: Long = 1, startTs: Long = 0, startLat: Double = 51.0) =
|
||||
(0 until n).map {
|
||||
point(segmentId, startTs + it * 1_000L, lat = startLat + it * 0.0001)
|
||||
}
|
||||
|
||||
// --- The cross-batch anchor: the subtle bug this class exists to avoid ----
|
||||
|
||||
@Test
|
||||
fun `distance across many small batches matches one big batch`() {
|
||||
val points = run(100)
|
||||
|
||||
val batched = Accumulator().apply {
|
||||
points.chunked(7).forEach { fold(it) }
|
||||
}
|
||||
val single = Accumulator().apply { fold(points) }
|
||||
|
||||
assertEquals(
|
||||
"losing the anchor between flushes silently under-reports distance",
|
||||
single.distanceM,
|
||||
batched.distanceM,
|
||||
1e-6,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accumulated distance agrees with the batch statistics`() {
|
||||
val points = run(100)
|
||||
|
||||
val acc = Accumulator().apply { points.chunked(25).forEach { fold(it) } }
|
||||
val summary = RideStatistics.compute(points)
|
||||
|
||||
assertEquals(summary.distanceM, acc.distanceM, 0.5)
|
||||
assertEquals(summary.movingMillis, acc.movingMillis)
|
||||
assertEquals(summary.maxSpeedKmh, acc.maxSpeedKmh, 1e-6f)
|
||||
assertEquals(summary.pointCount, acc.pointCount)
|
||||
}
|
||||
|
||||
// --- Segment boundaries ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `distance does not span a segment boundary`() {
|
||||
// Second segment starts a full degree of latitude away — a trailered gap.
|
||||
val acc = Accumulator()
|
||||
acc.fold(run(5, segmentId = 1, startLat = 51.0))
|
||||
acc.fold(run(5, segmentId = 2, startTs = 600_000, startLat = 52.0))
|
||||
|
||||
assertTrue(
|
||||
"the ~111 km gap leaked into distance: ${acc.distanceM} m",
|
||||
acc.distanceM < 200.0,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onSegmentChanged clears the anchor even within one batch stream`() {
|
||||
val acc = Accumulator()
|
||||
acc.fold(run(5, segmentId = 1, startLat = 51.0))
|
||||
acc.onSegmentChanged()
|
||||
acc.fold(run(5, segmentId = 1, startTs = 600_000, startLat = 52.0))
|
||||
|
||||
assertTrue("anchor was not cleared: ${acc.distanceM} m", acc.distanceM < 200.0)
|
||||
}
|
||||
|
||||
// --- Moving time -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `moving time ignores samples below the speed noise floor`() {
|
||||
val acc = Accumulator()
|
||||
acc.fold((0 until 30).map { point(ts = it * 1_000L, speed = 40f) })
|
||||
acc.fold((30 until 60).map { point(ts = it * 1_000L, speed = 0f) })
|
||||
|
||||
assertEquals(29_000L, acc.movingMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long gap between fixes does not inject phantom moving time`() {
|
||||
val acc = Accumulator()
|
||||
acc.fold(listOf(point(ts = 0, speed = 90f), point(ts = 120_000, lat = 51.001, speed = 90f)))
|
||||
assertEquals(0L, acc.movingMillis)
|
||||
}
|
||||
|
||||
// --- Elevation -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `elevation gain is visible before the climb ends`() {
|
||||
// A steady ascent that never turns back down. Reading the committed total alone
|
||||
// would report zero, so the live figure has to include the pending run.
|
||||
val acc = Accumulator()
|
||||
acc.fold((0 until 101).map { point(ts = it * 1_000L, alt = 1000.0 + it) })
|
||||
assertEquals(100.0, acc.elevationGain(), 6.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a parked bike does not accumulate phantom climbing`() {
|
||||
val rng = kotlin.random.Random(seed = 7)
|
||||
val acc = Accumulator()
|
||||
acc.fold((0 until 600).map {
|
||||
point(ts = it * 1_000L, speed = 0f, alt = 1000.0 + rng.nextDouble(-8.0, 8.0))
|
||||
})
|
||||
assertTrue("phantom climbing: ${acc.elevationGain()} m", acc.elevationGain() < 40.0)
|
||||
}
|
||||
|
||||
// --- Restore ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `restore continues from persisted totals rather than starting over`() {
|
||||
val acc = Accumulator()
|
||||
acc.restore(
|
||||
distanceM = 5_000.0,
|
||||
movingMillis = 300_000,
|
||||
maxSpeedKmh = 95f,
|
||||
elevationGainM = 120.0,
|
||||
pointCount = 600,
|
||||
lastPoint = null,
|
||||
)
|
||||
acc.fold(run(11))
|
||||
|
||||
assertTrue("distance went backwards", acc.distanceM > 5_000.0)
|
||||
assertEquals(5_111.2, acc.distanceM, 5.0)
|
||||
assertEquals(611, acc.pointCount)
|
||||
assertEquals(95f, acc.maxSpeedKmh, 1e-6f)
|
||||
assertTrue("persisted elevation was lost", acc.elevationGain() >= 120.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore keeps a higher persisted max speed`() {
|
||||
val acc = Accumulator()
|
||||
acc.restore(0.0, 0, maxSpeedKmh = 150f, elevationGainM = 0.0, pointCount = 0, lastPoint = null)
|
||||
acc.fold(run(5))
|
||||
assertEquals(150f, acc.maxSpeedKmh, 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears everything`() {
|
||||
val acc = Accumulator()
|
||||
acc.fold(run(20))
|
||||
acc.reset()
|
||||
|
||||
assertEquals(0.0, acc.distanceM, 1e-9)
|
||||
assertEquals(0L, acc.movingMillis)
|
||||
assertEquals(0f, acc.maxSpeedKmh, 1e-9f)
|
||||
assertEquals(0, acc.pointCount)
|
||||
assertEquals(0.0, acc.elevationGain(), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `folding an empty batch is a no-op`() {
|
||||
val acc = Accumulator()
|
||||
acc.fold(emptyList())
|
||||
assertEquals(0, acc.pointCount)
|
||||
assertEquals(0.0, acc.distanceM, 1e-9)
|
||||
}
|
||||
}
|
||||
98
rippr-src/app/src/test/java/com/rippr/TelemetryTest.kt
Normal file
98
rippr-src/app/src/test/java/com/rippr/TelemetryTest.kt
Normal file
@@ -0,0 +1,98 @@
|
||||
package com.rippr
|
||||
|
||||
import com.rippr.data.TrackPoint
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TelemetryTest {
|
||||
|
||||
@Test
|
||||
fun `converts meters per second to kmh`() {
|
||||
assertEquals(36f, Telemetry.msToKmh(10f), 0.001f)
|
||||
assertEquals(0f, Telemetry.msToKmh(0f), 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gps jitter below the noise floor reports as zero`() {
|
||||
assertEquals(0f, Telemetry.sanitizeSpeedKmh(0.9f), 0.001f)
|
||||
assertEquals(0f, Telemetry.sanitizeSpeedKmh(Float.NaN), 0.001f)
|
||||
assertEquals(0f, Telemetry.sanitizeSpeedKmh(-5f), 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real speeds pass through untouched`() {
|
||||
assertEquals(97.3f, Telemetry.sanitizeSpeedKmh(97.3f), 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects fixes worse than the accuracy budget`() {
|
||||
assertTrue(Telemetry.isUsableFix(12f))
|
||||
assertTrue("unknown accuracy must not be discarded", Telemetry.isUsableFix(0f))
|
||||
assertFalse(Telemetry.isUsableFix(120f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formats duration as hh mm ss`() {
|
||||
assertEquals("00:00:00", Telemetry.formatDuration(0))
|
||||
assertEquals("00:00:00", Telemetry.formatDuration(-1))
|
||||
assertEquals("00:01:05", Telemetry.formatDuration(65_000))
|
||||
assertEquals("02:03:04", Telemetry.formatDuration(7_384_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encodes a batch as the documented payload shape`() {
|
||||
val points = listOf(
|
||||
TrackPoint(
|
||||
id = 7,
|
||||
tripId = 3,
|
||||
segmentId = 5,
|
||||
timestamp = 1_700_000_000_000,
|
||||
latitude = 51.0447,
|
||||
longitude = -114.0719,
|
||||
speedKmh = 88.5f,
|
||||
altitudeM = 1045.0,
|
||||
accuracyM = 4.2f,
|
||||
bearingDeg = 271.5f
|
||||
)
|
||||
)
|
||||
|
||||
val json = JSONObject(Telemetry.encodeBatch("device-abc", points))
|
||||
|
||||
assertEquals("device-abc", json.getString("device_id"))
|
||||
val first = json.getJSONArray("points").getJSONObject(0)
|
||||
assertEquals(7L, first.getLong("id"))
|
||||
assertEquals(3L, first.getLong("trip_id"))
|
||||
assertEquals(5L, first.getLong("segment_id"))
|
||||
assertEquals(1_700_000_000_000L, first.getLong("ts"))
|
||||
assertEquals(51.0447, first.getDouble("lat"), 1e-6)
|
||||
assertEquals(-114.0719, first.getDouble("lon"), 1e-6)
|
||||
assertEquals(88.5, first.getDouble("speed_kmh"), 1e-4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a batch spanning two segments labels each point individually`() {
|
||||
// A batch is drawn by id and can cross a boundary, so identity must travel with
|
||||
// the point rather than the batch.
|
||||
val points = listOf(
|
||||
TrackPoint(id = 1, tripId = 3, segmentId = 5, timestamp = 1, latitude = 51.0,
|
||||
longitude = -114.0, speedKmh = 40f, altitudeM = 1000.0),
|
||||
TrackPoint(id = 2, tripId = 3, segmentId = 6, timestamp = 2, latitude = 51.1,
|
||||
longitude = -114.0, speedKmh = 40f, altitudeM = 1000.0),
|
||||
)
|
||||
|
||||
val array = JSONObject(Telemetry.encodeBatch("d", points)).getJSONArray("points")
|
||||
|
||||
assertEquals(5L, array.getJSONObject(0).getLong("segment_id"))
|
||||
assertEquals(6L, array.getJSONObject(1).getLong("segment_id"))
|
||||
assertEquals(3L, array.getJSONObject(0).getLong("trip_id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encodes an empty batch without failing`() {
|
||||
val json = JSONObject(Telemetry.encodeBatch("d", emptyList()))
|
||||
assertEquals(0, json.getJSONArray("points").length())
|
||||
}
|
||||
}
|
||||
150
rippr-src/app/src/test/java/com/rippr/TelemetryUploaderTest.kt
Normal file
150
rippr-src/app/src/test/java/com/rippr/TelemetryUploaderTest.kt
Normal file
@@ -0,0 +1,150 @@
|
||||
package com.rippr
|
||||
|
||||
import com.rippr.data.RideStats
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.data.TrackPointDao
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/** In-memory DAO so the uploader can be exercised without a device. */
|
||||
private class FakeDao : TrackPointDao {
|
||||
val points = mutableListOf<TrackPoint>()
|
||||
private var nextId = 1L
|
||||
|
||||
override suspend fun insertPoint(point: TrackPoint): Long {
|
||||
val id = nextId++
|
||||
points += point.copy(id = id)
|
||||
return id
|
||||
}
|
||||
|
||||
override suspend fun insertPoints(points: List<TrackPoint>): List<Long> =
|
||||
points.map { insertPoint(it) }
|
||||
|
||||
override suspend fun forTrip(tripId: Long): List<TrackPoint> = points.filter { it.tripId == tripId }
|
||||
override suspend fun forSegment(segmentId: Long): List<TrackPoint> =
|
||||
points.filter { it.segmentId == segmentId }
|
||||
override suspend fun lastInSegment(segmentId: Long): TrackPoint? =
|
||||
points.lastOrNull { it.segmentId == segmentId }
|
||||
override suspend fun countForTrip(tripId: Long): Int = points.count { it.tripId == tripId }
|
||||
override fun observeTripStats(tripId: Long): Flow<RideStats> = flowOf(RideStats.EMPTY)
|
||||
override fun observePendingUpload(tripId: Long): Flow<Int> =
|
||||
flowOf(points.count { it.tripId == tripId && !it.synced })
|
||||
override suspend fun reparent(oldTripId: Long, newTripId: Long) {
|
||||
points.replaceAll { if (it.tripId == oldTripId) it.copy(tripId = newTripId) else it }
|
||||
}
|
||||
|
||||
override suspend fun getAllPoints(): List<TrackPoint> = points.toList()
|
||||
|
||||
override suspend fun getUnsyncedPoints(limit: Int): List<TrackPoint> =
|
||||
points.filter { !it.synced }.take(limit)
|
||||
|
||||
override suspend fun markSynced(ids: List<Long>) {
|
||||
ids.forEach { id ->
|
||||
val i = points.indexOfFirst { it.id == id }
|
||||
if (i >= 0) points[i] = points[i].copy(synced = true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun countUnsynced(): Int = points.count { !it.synced }
|
||||
|
||||
override suspend fun clearAll() = points.clear()
|
||||
}
|
||||
|
||||
class TelemetryUploaderTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var dao: FakeDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
dao = FakeDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun uploader(endpoint: String = server.url("/ingest").toString()) =
|
||||
TelemetryUploader(dao, endpoint, "test-device")
|
||||
|
||||
private suspend fun seed(count: Int) {
|
||||
repeat(count) { i ->
|
||||
dao.insertPoint(
|
||||
TrackPoint(
|
||||
tripId = 1,
|
||||
segmentId = 1,
|
||||
timestamp = 1_700_000_000_000 + i,
|
||||
latitude = 51.0 + i * 1e-5,
|
||||
longitude = -114.0,
|
||||
speedKmh = 60f,
|
||||
altitudeM = 1000.0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uploads pending points and marks them synced`() = runTest {
|
||||
seed(3)
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val result = uploader().uploadPending()
|
||||
|
||||
assertTrue(result is TelemetryUploader.Result.Success)
|
||||
assertEquals(3, (result as TelemetryUploader.Result.Success).uploaded)
|
||||
assertEquals(0, dao.countUnsynced())
|
||||
|
||||
val body = JSONObject(server.takeRequest().body.readUtf8())
|
||||
assertEquals("test-device", body.getString("device_id"))
|
||||
assertEquals(3, body.getJSONArray("points").length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server error leaves points unsynced for a later retry`() = runTest {
|
||||
seed(2)
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
val result = uploader().uploadPending()
|
||||
|
||||
assertEquals(TelemetryUploader.Result.Failed, result)
|
||||
assertEquals("points must survive a failed upload", 2, dao.countUnsynced())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry after a failure succeeds and drains the backlog`() = runTest {
|
||||
seed(2)
|
||||
server.enqueue(MockResponse().setResponseCode(503))
|
||||
uploader().uploadPending()
|
||||
assertEquals(2, dao.countUnsynced())
|
||||
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
uploader().uploadPending()
|
||||
assertEquals(0, dao.countUnsynced())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank endpoint disables uploading without touching the data`() = runTest {
|
||||
seed(2)
|
||||
assertEquals(TelemetryUploader.Result.Disabled, uploader(endpoint = "").uploadPending())
|
||||
assertEquals(2, dao.countUnsynced())
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing pending means no request is made`() = runTest {
|
||||
val result = uploader().uploadPending()
|
||||
assertEquals(TelemetryUploader.Result.Success(0), result)
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
}
|
||||
211
rippr-src/app/src/test/java/com/rippr/export/RideExportTest.kt
Normal file
211
rippr-src/app/src/test/java/com/rippr/export/RideExportTest.kt
Normal file
@@ -0,0 +1,211 @@
|
||||
package com.rippr.export
|
||||
|
||||
import com.rippr.data.Segment
|
||||
import com.rippr.data.TrackPoint
|
||||
import com.rippr.data.Trip
|
||||
import com.rippr.data.TripState
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.w3c.dom.Element
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
class RideExportTest {
|
||||
|
||||
private var nextId = 1L
|
||||
|
||||
private val trip = Trip(
|
||||
id = 1,
|
||||
startedAt = 1_700_000_000_000,
|
||||
endedAt = 1_700_000_600_000,
|
||||
state = TripState.COMPLETED,
|
||||
distanceM = 12_345.6,
|
||||
maxSpeedKmh = 118.4f,
|
||||
)
|
||||
|
||||
private fun point(segmentId: Long, i: Int) = TrackPoint(
|
||||
id = nextId++,
|
||||
tripId = 1,
|
||||
segmentId = segmentId,
|
||||
timestamp = 1_700_000_000_000 + i * 1_000L,
|
||||
latitude = 51.0447 + i * 0.0001,
|
||||
longitude = -114.0719,
|
||||
speedKmh = 72f,
|
||||
altitudeM = 1045.0,
|
||||
)
|
||||
|
||||
private fun ride(segmentCount: Int, perSegment: Int): Triple<List<Segment>, List<TrackPoint>, Int> {
|
||||
val segments = (1..segmentCount).map {
|
||||
Segment(id = it.toLong(), tripId = 1, startedAt = 0, endedAt = 1)
|
||||
}
|
||||
val points = segments.flatMap { s -> (0 until perSegment).map { point(s.id, it) } }
|
||||
return Triple(segments, points, segmentCount * perSegment)
|
||||
}
|
||||
|
||||
private fun parse(xml: String) = DocumentBuilderFactory.newInstance()
|
||||
.newDocumentBuilder()
|
||||
.parse(xml.byteInputStream())
|
||||
|
||||
// --- GPX -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `gpx is well-formed and carries the expected structure`() {
|
||||
val (segments, points, _) = ride(segmentCount = 2, perSegment = 5)
|
||||
|
||||
val doc = parse(RideExport.gpx(trip, segments, points, "Morning loop"))
|
||||
|
||||
assertEquals("gpx", doc.documentElement.tagName)
|
||||
assertEquals("1.1", doc.documentElement.getAttribute("version"))
|
||||
assertEquals(1, doc.getElementsByTagName("trk").length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one trkseg per segment, so pauses survive the round trip`() {
|
||||
val (segments, points, _) = ride(segmentCount = 3, perSegment = 4)
|
||||
|
||||
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
|
||||
|
||||
assertEquals(3, doc.getElementsByTagName("trkseg").length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every raw point is exported with no decimation`() {
|
||||
val (segments, points, total) = ride(segmentCount = 2, perSegment = 250)
|
||||
|
||||
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
|
||||
|
||||
assertEquals(
|
||||
"decimation must never reach an export",
|
||||
total,
|
||||
doc.getElementsByTagName("trkpt").length,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timestamps are ISO 8601 in UTC`() {
|
||||
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
|
||||
|
||||
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
|
||||
val time = doc.getElementsByTagName("time").item(1).textContent
|
||||
|
||||
assertEquals("2023-11-14T22:13:20Z", time)
|
||||
assertTrue("must be UTC with a Z suffix", time.endsWith("Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `coordinates and elevation land on the right attributes`() {
|
||||
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
|
||||
|
||||
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
|
||||
val trkpt = doc.getElementsByTagName("trkpt").item(0) as Element
|
||||
|
||||
assertEquals("51.0447000", trkpt.getAttribute("lat"))
|
||||
assertEquals("-114.0719000", trkpt.getAttribute("lon"))
|
||||
assertEquals("1045.0", doc.getElementsByTagName("ele").item(0).textContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `speed is exported in metres per second`() {
|
||||
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
|
||||
|
||||
val doc = parse(RideExport.gpx(trip, segments, points, "Ride"))
|
||||
|
||||
// 72 km/h is exactly 20 m/s.
|
||||
assertEquals("20.0", doc.getElementsByTagName("speed").item(0).textContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hostile trip name still produces valid xml`() {
|
||||
val (segments, points, _) = ride(segmentCount = 1, perSegment = 2)
|
||||
val nasty = """Sam & Dave's <ride> "fast" """
|
||||
|
||||
// Would throw if the name were interpolated unescaped.
|
||||
val doc = parse(RideExport.gpx(trip, segments, points, nasty))
|
||||
|
||||
assertEquals(nasty, doc.getElementsByTagName("name").item(0).textContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `escaping covers every xml metacharacter`() {
|
||||
assertEquals(
|
||||
"&<>"'",
|
||||
RideExport.escapeXml("""&<>"'"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty ride still produces a valid document`() {
|
||||
val doc = parse(RideExport.gpx(trip, emptyList(), emptyList(), "Empty"))
|
||||
|
||||
assertEquals("gpx", doc.documentElement.tagName)
|
||||
assertEquals(0, doc.getElementsByTagName("trkpt").length)
|
||||
assertEquals(0, doc.getElementsByTagName("trkseg").length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `points whose segment is missing are still exported`() {
|
||||
val orphan = listOf(point(segmentId = 99, i = 0))
|
||||
|
||||
val doc = parse(RideExport.gpx(trip, emptyList(), orphan, "Ride"))
|
||||
|
||||
assertEquals("no point may be silently dropped", 1, doc.getElementsByTagName("trkpt").length)
|
||||
}
|
||||
|
||||
// --- GeoJSON ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `geojson coordinates are longitude first`() {
|
||||
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
|
||||
|
||||
val json = JSONObject(RideExport.geoJson(trip, segments, points, "Ride"))
|
||||
val coords = json.getJSONArray("features")
|
||||
.getJSONObject(0)
|
||||
.getJSONObject("geometry")
|
||||
.getJSONArray("coordinates")
|
||||
.getJSONArray(0)
|
||||
|
||||
assertEquals("longitude must come first", -114.0719, coords.getDouble(0), 1e-6)
|
||||
assertEquals(51.0447, coords.getDouble(1), 1e-6)
|
||||
assertEquals(1045.0, coords.getDouble(2), 1e-6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `geojson has one LineString feature per segment`() {
|
||||
val (segments, points, _) = ride(segmentCount = 3, perSegment = 4)
|
||||
|
||||
val json = JSONObject(RideExport.geoJson(trip, segments, points, "Ride"))
|
||||
val features = json.getJSONArray("features")
|
||||
|
||||
assertEquals(3, features.length())
|
||||
assertEquals("LineString", features.getJSONObject(0).getJSONObject("geometry").getString("type"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `geojson exports every raw point`() {
|
||||
val (segments, points, total) = ride(segmentCount = 2, perSegment = 120)
|
||||
|
||||
val json = JSONObject(RideExport.geoJson(trip, segments, points, "Ride"))
|
||||
val exported = (0 until json.getJSONArray("features").length()).sumOf { i ->
|
||||
json.getJSONArray("features").getJSONObject(i)
|
||||
.getJSONObject("geometry").getJSONArray("coordinates").length()
|
||||
}
|
||||
|
||||
assertEquals(total, exported)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hostile name is escaped in json too`() {
|
||||
val (segments, points, _) = ride(segmentCount = 1, perSegment = 1)
|
||||
|
||||
val json = JSONObject(RideExport.geoJson(trip, segments, points, """He said "go" \ fast"""))
|
||||
|
||||
assertEquals("""He said "go" \ fast""", json.getJSONObject("properties").getString("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `geojson of an empty ride parses`() {
|
||||
val json = JSONObject(RideExport.geoJson(trip, emptyList(), emptyList(), "Empty"))
|
||||
assertEquals(0, json.getJSONArray("features").length())
|
||||
}
|
||||
}
|
||||
210
rippr-src/app/src/test/java/com/rippr/geo/GeoTest.kt
Normal file
210
rippr-src/app/src/test/java/com/rippr/geo/GeoTest.kt
Normal file
@@ -0,0 +1,210 @@
|
||||
package com.rippr.geo
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.abs
|
||||
|
||||
class GeoTest {
|
||||
|
||||
private val calgary = LatLon(51.0447, -114.0719)
|
||||
private val edmonton = LatLon(53.5461, -113.4938)
|
||||
|
||||
// --- Haversine ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `matches a known long-distance reference`() {
|
||||
// Calgary to Edmonton, great-circle. Cross-checked by hand: 2.5014° of latitude
|
||||
// is ~278.1 km, and 0.5781° of longitude at ~52.3° is ~39.3 km, giving
|
||||
// sqrt(278.1² + 39.3²) ≈ 280.9 km. (Road distance is ~300 km — not the same thing.)
|
||||
val d = Geo.haversineMeters(calgary, edmonton)
|
||||
assertEquals(280_900.0, d, 280_900.0 * 0.005)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one degree of latitude is about 111 km anywhere`() {
|
||||
val atEquator = Geo.haversineMeters(0.0, 0.0, 1.0, 0.0)
|
||||
val atLatitude60 = Geo.haversineMeters(60.0, 0.0, 61.0, 0.0)
|
||||
assertEquals(111_195.0, atEquator, 200.0)
|
||||
assertEquals("latitude spacing does not vary with longitude", atEquator, atLatitude60, 200.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a degree of longitude shrinks with latitude`() {
|
||||
val atEquator = Geo.haversineMeters(0.0, 0.0, 0.0, 1.0)
|
||||
val atLatitude60 = Geo.haversineMeters(60.0, 0.0, 60.0, 1.0)
|
||||
// cos(60°) = 0.5, so it should be about half.
|
||||
assertEquals(atEquator / 2, atLatitude60, 500.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identical points are zero distance`() {
|
||||
assertEquals(0.0, Geo.haversineMeters(calgary, calgary), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short hops between consecutive fixes stay accurate`() {
|
||||
// ~11 m north, the scale of a 2 Hz fix at road speed.
|
||||
val d = Geo.haversineMeters(51.0447, -114.0719, 51.04480, -114.0719)
|
||||
assertEquals(11.1, d, 0.5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `distance is symmetric`() {
|
||||
assertEquals(
|
||||
Geo.haversineMeters(calgary, edmonton),
|
||||
Geo.haversineMeters(edmonton, calgary),
|
||||
1e-6,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles an antimeridian crossing without exploding`() {
|
||||
// 0.02° apart in longitude, straddling +/-180.
|
||||
val d = Geo.haversineMeters(0.0, 179.99, 0.0, -179.99)
|
||||
assertTrue("expected a short hop, got $d m", d < 3_000)
|
||||
}
|
||||
|
||||
// --- Douglas-Peucker ----------------------------------------------------
|
||||
|
||||
private fun line(n: Int) = (0 until n).map { LatLon(51.0 + it * 0.001, -114.0) }
|
||||
|
||||
@Test
|
||||
fun `a straight line collapses to its endpoints`() {
|
||||
val simplified = Geo.simplify(line(50), epsilonMeters = 5.0)
|
||||
assertEquals(2, simplified.size)
|
||||
assertEquals(line(50).first(), simplified.first())
|
||||
assertEquals(line(50).last(), simplified.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `endpoints always survive`() {
|
||||
val zigzag = (0 until 30).map {
|
||||
LatLon(51.0 + it * 0.001, -114.0 + if (it % 2 == 0) 0.0 else 0.002)
|
||||
}
|
||||
val simplified = Geo.simplify(zigzag, epsilonMeters = 5.0)
|
||||
assertEquals(zigzag.first(), simplified.first())
|
||||
assertEquals(zigzag.last(), simplified.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deviation above epsilon is preserved`() {
|
||||
// Middle point sits ~110 m off the line between its neighbours.
|
||||
val points = listOf(
|
||||
LatLon(51.000, -114.0),
|
||||
LatLon(51.001, -113.999),
|
||||
LatLon(51.002, -114.0),
|
||||
)
|
||||
assertEquals(3, Geo.simplify(points, epsilonMeters = 5.0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deviation below epsilon is dropped`() {
|
||||
val points = listOf(
|
||||
LatLon(51.000, -114.0),
|
||||
LatLon(51.001, -114.00001), // under a metre off the line
|
||||
LatLon(51.002, -114.0),
|
||||
)
|
||||
assertEquals(2, Geo.simplify(points, epsilonMeters = 5.0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epsilon of zero returns the input untouched`() {
|
||||
val input = line(20)
|
||||
assertEquals(input, Geo.simplify(input, epsilonMeters = 0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inputs of two or fewer are returned as-is`() {
|
||||
assertEquals(0, Geo.simplify(emptyList(), 5.0).size)
|
||||
assertEquals(1, Geo.simplify(line(1), 5.0).size)
|
||||
assertEquals(2, Geo.simplify(line(2), 5.0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles a full ride without stack overflow and stays fast`() {
|
||||
// Three hours at 2 Hz, the real scale this has to survive.
|
||||
val ride = (0 until 21_600).map {
|
||||
LatLon(51.0 + it * 0.00001, -114.0 + kotlin.math.sin(it / 100.0) * 0.001)
|
||||
}
|
||||
val started = System.nanoTime()
|
||||
val simplified = Geo.simplify(ride, epsilonMeters = 5.0)
|
||||
val elapsedMs = (System.nanoTime() - started) / 1_000_000
|
||||
|
||||
assertTrue("simplification should reduce the point count", simplified.size < ride.size)
|
||||
assertEquals(ride.first(), simplified.first())
|
||||
assertEquals(ride.last(), simplified.last())
|
||||
assertTrue("took ${elapsedMs}ms, expected well under a second", elapsedMs < 1_000)
|
||||
}
|
||||
|
||||
// --- Perpendicular distance ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a point on the segment has zero perpendicular distance`() {
|
||||
val d = Geo.perpendicularDistanceMeters(
|
||||
LatLon(51.001, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0),
|
||||
)
|
||||
assertTrue("expected ~0, got $d", abs(d) < 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a point beyond the end measures to the endpoint, not the infinite line`() {
|
||||
// Directly past the segment's end along the same bearing.
|
||||
val d = Geo.perpendicularDistanceMeters(
|
||||
LatLon(51.003, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0),
|
||||
)
|
||||
assertEquals("should be the ~111 m to the endpoint", 111.0, d, 5.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a degenerate segment falls back to point distance`() {
|
||||
val a = LatLon(51.0, -114.0)
|
||||
val d = Geo.perpendicularDistanceMeters(LatLon(51.001, -114.0), a, a)
|
||||
assertEquals(111.0, d, 5.0)
|
||||
}
|
||||
|
||||
// --- Bounds -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `bounds is null for an empty path`() {
|
||||
assertNull(Geo.bounds(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bounds spans mixed-sign coordinates`() {
|
||||
val b = Geo.bounds(
|
||||
listOf(LatLon(-10.0, -20.0), LatLon(30.0, 40.0), LatLon(5.0, 0.0)),
|
||||
)!!
|
||||
assertEquals(-10.0, b.minLat, 1e-9)
|
||||
assertEquals(30.0, b.maxLat, 1e-9)
|
||||
assertEquals(-20.0, b.minLon, 1e-9)
|
||||
assertEquals(40.0, b.maxLon, 1e-9)
|
||||
assertEquals(10.0, b.centerLat, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stationary ride reports degenerate bounds`() {
|
||||
val b = Geo.bounds(List(10) { calgary })!!
|
||||
assertTrue("map auto-fit must special-case this", b.isDegenerate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real path is not degenerate`() {
|
||||
assertTrue(!Geo.bounds(line(10))!!.isDegenerate)
|
||||
}
|
||||
|
||||
// --- Path length --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `path length sums consecutive hops`() {
|
||||
val points = line(11) // ten hops of 0.001 degrees latitude
|
||||
assertEquals(10 * 111.19, Geo.pathLengthMeters(points), 20.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `path length of fewer than two points is zero`() {
|
||||
assertEquals(0.0, Geo.pathLengthMeters(emptyList()), 1e-9)
|
||||
assertEquals(0.0, Geo.pathLengthMeters(listOf(calgary)), 1e-9)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.rippr.stats
|
||||
|
||||
import com.rippr.data.Segment
|
||||
import com.rippr.data.TrackPoint
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
class RideStatisticsTest {
|
||||
|
||||
private var nextId = 1L
|
||||
|
||||
private fun point(
|
||||
segmentId: Long = 1,
|
||||
ts: Long,
|
||||
lat: Double = 51.0,
|
||||
lon: Double = -114.0,
|
||||
speed: Float = 50f,
|
||||
alt: Double = 1000.0,
|
||||
) = TrackPoint(
|
||||
id = nextId++,
|
||||
tripId = 1,
|
||||
segmentId = segmentId,
|
||||
timestamp = ts,
|
||||
latitude = lat,
|
||||
longitude = lon,
|
||||
speedKmh = speed,
|
||||
altitudeM = alt,
|
||||
)
|
||||
|
||||
/** A straight northward run: [n] fixes one second apart, 0.0001° (~11 m) each. */
|
||||
private fun straightRun(n: Int, segmentId: Long = 1, startTs: Long = 0, speed: Float = 40f) =
|
||||
(0 until n).map {
|
||||
point(segmentId, startTs + it * 1_000L, lat = 51.0 + it * 0.0001, speed = speed)
|
||||
}
|
||||
|
||||
// --- Distance -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `distance matches the summed haversine hops`() {
|
||||
val summary = RideStatistics.compute(straightRun(11))
|
||||
// Ten hops of 0.0001 degrees latitude, ~11.12 m each.
|
||||
assertEquals(111.2, summary.distanceM, 3.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `distance never spans a pause`() {
|
||||
// Two segments 100 km apart — a rider who trailered between them.
|
||||
val first = straightRun(5, segmentId = 1, startTs = 0)
|
||||
val second = (0 until 5).map {
|
||||
point(segmentId = 2, ts = 600_000 + it * 1_000L, lat = 52.0 + it * 0.0001)
|
||||
}
|
||||
|
||||
val summary = RideStatistics.compute(first + second)
|
||||
|
||||
// Two runs of ~44.5 m each; the ~111 km gap must not appear.
|
||||
assertTrue(
|
||||
"gap leaked into distance: ${summary.distanceM} m",
|
||||
summary.distanceM < 200.0,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single point has zero distance and no NaN`() {
|
||||
val summary = RideStatistics.compute(listOf(point(ts = 0)))
|
||||
assertEquals(0.0, summary.distanceM, 1e-9)
|
||||
assertEquals(0f, summary.avgMovingSpeedKmh, 1e-9f)
|
||||
assertEquals(1, summary.pointCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty input returns the zero summary`() {
|
||||
assertEquals(RideSummary.EMPTY, RideStatistics.compute(emptyList()))
|
||||
}
|
||||
|
||||
// --- Elevation: the regression that matters most -------------------------
|
||||
|
||||
@Test
|
||||
fun `stationary noisy altitude yields near-zero elevation gain`() {
|
||||
// A parked bike for ten minutes with realistic +/-8 m GPS altitude wander.
|
||||
//
|
||||
// Measured: a naive "sum every delta over the threshold" implementation reported
|
||||
// 1498 m of climbing here. Smoothing plus reversal hysteresis brings it to ~30 m.
|
||||
// The bound below is a regression guard, not a target — real GPS altitude error is
|
||||
// correlated rather than uniform, so the true figure is best judged on a real ride.
|
||||
val rng = Random(seed = 42)
|
||||
val points = (0 until 600).map {
|
||||
point(ts = it * 1_000L, speed = 0f, alt = 1000.0 + rng.nextDouble(-8.0, 8.0))
|
||||
}
|
||||
|
||||
val summary = RideStatistics.compute(points)
|
||||
|
||||
assertTrue(
|
||||
"phantom climbing: ${summary.elevationGainM} m over a parked bike",
|
||||
summary.elevationGainM < 35.0,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a genuine climb is recorded`() {
|
||||
val points = (0 until 101).map {
|
||||
point(ts = it * 1_000L, lat = 51.0 + it * 0.0001, alt = 1000.0 + it)
|
||||
}
|
||||
val summary = RideStatistics.compute(points)
|
||||
assertEquals(100.0, summary.elevationGainM, 5.0)
|
||||
assertEquals(0.0, summary.elevationLossM, 5.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a descent counts as loss, not gain`() {
|
||||
val points = (0 until 101).map {
|
||||
point(ts = it * 1_000L, lat = 51.0 + it * 0.0001, alt = 1100.0 - it)
|
||||
}
|
||||
val summary = RideStatistics.compute(points)
|
||||
assertEquals(100.0, summary.elevationLossM, 5.0)
|
||||
assertEquals(0.0, summary.elevationGainM, 5.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out-and-back records both gain and loss`() {
|
||||
val up = (0 until 51).map { point(ts = it * 1_000L, alt = 1000.0 + it) }
|
||||
val down = (0 until 51).map { point(ts = 51_000L + it * 1_000L, alt = 1050.0 - it) }
|
||||
val summary = RideStatistics.compute(up + down)
|
||||
assertEquals(50.0, summary.elevationGainM, 5.0)
|
||||
assertEquals(50.0, summary.elevationLossM, 5.0)
|
||||
}
|
||||
|
||||
// --- Moving vs elapsed time ---------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `moving time excludes time below the speed noise floor`() {
|
||||
val moving = (0 until 60).map { point(ts = it * 1_000L, speed = 40f) }
|
||||
val stopped = (60 until 120).map { point(ts = it * 1_000L, speed = 0f) }
|
||||
|
||||
val summary = RideStatistics.compute(moving + stopped)
|
||||
|
||||
assertEquals("~59 s of movement", 59_000.0, summary.movingMillis.toDouble(), 2_000.0)
|
||||
assertEquals(119_000L, summary.elapsedMillis)
|
||||
assertTrue(summary.stoppedMillis > 55_000L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long gap between fixes does not inject phantom moving time`() {
|
||||
// Two fixes two minutes apart — a tunnel. Without the cap this would count as
|
||||
// 120 s of movement at the last known speed.
|
||||
val points = listOf(
|
||||
point(ts = 0, speed = 90f),
|
||||
point(ts = 120_000, lat = 51.001, speed = 90f),
|
||||
)
|
||||
assertEquals(0L, RideStatistics.compute(points).movingMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `elapsed time prefers closed segment spans when available`() {
|
||||
val points = straightRun(5, startTs = 1_000)
|
||||
val segments = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = 10_000))
|
||||
assertEquals(10_000L, RideStatistics.compute(points, segments).elapsedMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `elapsed falls back to point timestamps when a segment is still open`() {
|
||||
val points = straightRun(5, startTs = 1_000) // 1000..5000
|
||||
val segments = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = null))
|
||||
assertEquals(4_000L, RideStatistics.compute(points, segments).elapsedMillis)
|
||||
}
|
||||
|
||||
// --- Average speed -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `average speed uses distance over moving time, not the sample mean`() {
|
||||
// 100 fixes 1 s apart, each 0.0001 deg (~11.12 m) => ~1101 m over ~99 s
|
||||
// => ~40 km/h. Half the samples report a bogus 0 speed to prove the mean of
|
||||
// samples is not what is being computed.
|
||||
val points = (0 until 100).map {
|
||||
point(ts = it * 1_000L, lat = 51.0 + it * 0.0001, speed = 40f)
|
||||
}
|
||||
val summary = RideStatistics.compute(points)
|
||||
assertEquals(40.0f, summary.avgMovingSpeedKmh, 2.0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `average speed is zero rather than NaN when nothing moved`() {
|
||||
val points = (0 until 10).map { point(ts = it * 1_000L, speed = 0f) }
|
||||
val summary = RideStatistics.compute(points)
|
||||
assertEquals(0f, summary.avgMovingSpeedKmh, 1e-9f)
|
||||
assertTrue("NaN would render literally on screen", !summary.avgMovingSpeedKmh.isNaN())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `max speed is the highest sample`() {
|
||||
val points = listOf(
|
||||
point(ts = 0, speed = 40f),
|
||||
point(ts = 1_000, speed = 118.4f),
|
||||
point(ts = 2_000, speed = 60f),
|
||||
)
|
||||
assertEquals(118.4f, RideStatistics.compute(points).maxSpeedKmh, 0.001f)
|
||||
}
|
||||
|
||||
// --- Histogram ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `histogram measures time in band, not sample count`() {
|
||||
val points = listOf(
|
||||
point(ts = 0, speed = 5f),
|
||||
point(ts = 1_000, speed = 15f), // 1 s in 10-20
|
||||
point(ts = 2_000, speed = 15f), // 1 s in 10-20
|
||||
point(ts = 3_000, speed = 95f), // 1 s in 90-100
|
||||
)
|
||||
val buckets = RideStatistics.speedHistogram(points, bucketKmh = 10)
|
||||
|
||||
assertEquals(2_000L, buckets.first { it.fromKmh == 10 }.millis)
|
||||
assertEquals(1_000L, buckets.first { it.fromKmh == 90 }.millis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `histogram is empty for degenerate input`() {
|
||||
assertTrue(RideStatistics.speedHistogram(emptyList()).isEmpty())
|
||||
assertTrue(RideStatistics.speedHistogram(listOf(point(ts = 0))).isEmpty())
|
||||
}
|
||||
|
||||
// --- Elevation profile ----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `profile plots altitude against cumulative distance`() {
|
||||
val points = (0 until 5).map {
|
||||
point(ts = it * 1_000L, lat = 51.0 + it * 0.0001, alt = 1000.0 + it * 10)
|
||||
}
|
||||
val profile = RideStatistics.elevationProfile(points)
|
||||
|
||||
assertEquals(5, profile.size)
|
||||
assertEquals(0.0, profile.first().distanceM, 1e-9)
|
||||
assertEquals(1040.0, profile.last().altitudeM, 1e-9)
|
||||
assertTrue("distance must increase", profile.last().distanceM > profile.first().distanceM)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile does not accrue distance across a pause`() {
|
||||
val first = (0 until 3).map { point(segmentId = 1, ts = it * 1_000L, lat = 51.0 + it * 0.0001) }
|
||||
val second = (0 until 3).map {
|
||||
point(segmentId = 2, ts = 600_000 + it * 1_000L, lat = 52.0 + it * 0.0001)
|
||||
}
|
||||
val profile = RideStatistics.elevationProfile(first + second)
|
||||
assertTrue(
|
||||
"the 111 km gap leaked into the profile: ${profile.last().distanceM}",
|
||||
profile.last().distanceM < 200.0,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile downsamples a long ride`() {
|
||||
val points = (0 until 21_600).map {
|
||||
point(ts = it * 500L, lat = 51.0 + it * 0.00001, alt = 1000.0)
|
||||
}
|
||||
val profile = RideStatistics.elevationProfile(points, maxSamples = 200)
|
||||
assertTrue("expected ~200 samples, got ${profile.size}", profile.size <= 201)
|
||||
assertEquals(points.last().altitudeM, profile.last().altitudeM, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile handles empty and single-point input`() {
|
||||
assertTrue(RideStatistics.elevationProfile(emptyList()).isEmpty())
|
||||
assertEquals(1, RideStatistics.elevationProfile(listOf(point(ts = 0))).size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user