feat(train): record a ride indoors, without GPS (ADR 0030, D66)

A home trainer's or an exercise bike's ride had no place: Cycling waits
for GPS and pauses by speed. Cycling indoors needs no GPS, route or
device: the time, then whichever strap, speed and cadence sensor or
power meter there is. It goes to Health Connect as stationary biking,
and another app's stationary ride comes in as one.

With no GPS, steps or device the recorder has no foreground service
type, which Android 14 on doesn't allow, so it asks for the physical
activity permission and doesn't start without it.

Whats-New: Record a ride on a home trainer or exercise bike with Cycling indoors, without GPS.
Whats-New-De: Zeichne eine Fahrt auf dem Heimtrainer mit Radfahren drinnen auf, ganz ohne GPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-05 16:47:23 +01:00
commit 3bd49920db
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 275 additions and 52 deletions

View file

@ -49,6 +49,7 @@ object HealthRecords {
"walking" -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING
"hiking" -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING
"cycling" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
"indoorCycling" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY
"indoorRowing" -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE
"strength" -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING
else -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
@ -57,7 +58,7 @@ object HealthRecords {
/**
* Train's activity for another app's exercise type; null for one Train
* doesn't record, which isn't brought in. A treadmill's run is a run, a
* stationary bike's ride a ride.
* stationary bike's ride a ride indoors (D66).
*/
fun activityOf(exercise: Int): String? =
when (exercise) {
@ -66,9 +67,8 @@ object HealthRecords {
-> "running"
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "walking"
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "hiking"
ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
-> "cycling"
ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "cycling"
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY -> "indoorCycling"
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE -> "indoorRowing"
else -> null
}

View file

@ -258,8 +258,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private fun begin(intent: Intent) {
path = intent.getStringExtra(EXTRA_PATH) ?: return stopSelf()
val activity = intent.getStringExtra(EXTRA_ACTIVITY) ?: return stopSelf()
// No GPS on the rower, whatever location allows.
val located = activity != "indoorRowing" && granted(Manifest.permission.ACCESS_FINE_LOCATION)
// No GPS indoors, whatever location allows.
val indoors = activity == "indoorRowing" || activity == "indoorCycling"
val located = !indoors && granted(Manifest.permission.ACCESS_FINE_LOCATION)
val counted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || granted(Manifest.permission.ACTIVITY_RECOGNITION)
// The strap and the rower where they were chosen and connecting is
@ -285,7 +286,9 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
notification(),
types(
located,
stepping,
// A ride indoors may have no GPS, steps or device (D66); the app
// asked for the permission that allows this type.
stepping || (activity == "indoorCycling" && counted),
linked = listOf(strapAt, rowerAt, loopAt, speedSensorAt, powerMeterAt).any { it != null },
),
)
@ -600,7 +603,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
/**
* The foreground service's types: location with GPS, health with the step
* detector, connectedDevice with the strap or the rower (Android 14 on asks).
* detector or on a ride indoors, connectedDevice with the strap or the rower
* (Android 14 on asks).
*/
private fun types(located: Boolean, stepping: Boolean, linked: Boolean): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0

View file

@ -135,10 +135,11 @@ class HealthRecordsTest {
@Test
fun `other apps' exercise types map onto Train's activities, or none`() {
assertEquals("running", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL))
assertEquals("cycling", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY))
assertEquals("cycling", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_BIKING))
assertEquals("indoorCycling", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY))
assertEquals("indoorRowing", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE))
assertNull(HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_YOGA))
for (activity in listOf("running", "walking", "hiking", "cycling", "indoorRowing")) {
for (activity in listOf("running", "walking", "hiking", "cycling", "indoorCycling", "indoorRowing")) {
assertEquals(activity, HealthRecords.activityOf(HealthRecords.exerciseOf(activity)))
}
}