feat(train): Health Connect's bridge, in Kotlin of Train's own (ADR 0030, D30)

connect-client 1.1.0 behind app.halkyon.train/health: its status, the
permissions by the app's three names (write, read, history), Train's
records written by their client IDs and deleted by them, other apps'
sessions read through the changes token or whole, their samples, and a
route with the person's consent; the rationale's links from Health
Connect. Android 8 at least now, as the client needs it. The records'
mapping is tested on the JVM, the channel's encoding in Dart; the fake
platform is for the parts to come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 16:49:42 +01:00
commit 030cb9b976
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 1470 additions and 1 deletions

View file

@ -19,7 +19,8 @@ android {
applicationId = "app.halkyon.train"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
// Android 8: Health Connect's client needs it (ADR 0030, D31).
minSdk = 26
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
@ -52,6 +53,8 @@ dependencies {
// NotificationCompat and ServiceCompat for the recording's foreground service,
// the version Learn's build uses.
implementation("androidx.core:core:1.18.0")
// Health Connect (ADR 0030, D30, D31): AndroidX's client, no Play services.
implementation("androidx.health.connect:connect-client:1.1.0")
// The recorder's pure Kotlin parts (auto-pause) tested on the JVM.
testImplementation("junit:junit:4.13.2")
}

View file

@ -25,6 +25,21 @@
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<!-- Health Connect (ADR 0030, D30, D31): Train's workouts written there,
other apps' sessions read, each asked for as its switch is turned on;
the history so that sessions older than 30 days come in too. -->
<uses-permission android:name="android.permission.health.WRITE_EXERCISE"/>
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE"/>
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
<uses-permission android:name="android.permission.health.WRITE_SPEED"/>
<uses-permission android:name="android.permission.health.WRITE_POWER"/>
<uses-permission android:name="android.permission.health.WRITE_DISTANCE"/>
<uses-permission android:name="android.permission.health.WRITE_ELEVATION_GAINED"/>
<uses-permission android:name="android.permission.health.READ_EXERCISE"/>
<uses-permission android:name="android.permission.health.READ_HEART_RATE"/>
<uses-permission android:name="android.permission.health.READ_DISTANCE"/>
<uses-permission android:name="android.permission.health.READ_POWER"/>
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY"/>
<uses-feature android:name="android.hardware.location.gps" android:required="false"/>
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
@ -53,7 +68,22 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Health Connect's link to Train's rationale, before Android 14. -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
</intent-filter>
</activity>
<!-- Health Connect's link to Train's rationale, from Android 14 on. -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".MainActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
<category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
</intent-filter>
</activity-alias>
<!-- Receives the way back from SimpleLogin's sign-in (app.halkyon.train://simplelogin-callback). -->
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
@ -82,6 +112,14 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<!-- Health Connect as an app, before Android 14. -->
<package android:name="com.google.android.apps.healthdata"/>
<!-- The apps writing workouts to Health Connect that Train names by
their names (D31); any other by its package. -->
<package android:name="nodomain.freeyourgadget.gadgetbridge"/>
<package android:name="com.vescape.christopeit"/>
<package android:name="de.dennisguse.opentracks"/>
<package android:name="de.dennisguse.opentracks.playStore"/>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>

View file

@ -0,0 +1,312 @@
package app.halkyon.train
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.PermissionController
import androidx.health.connect.client.changes.UpsertionChange
import androidx.health.connect.client.contracts.ExerciseRouteRequestContract
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ElevationGainedRecord
import androidx.health.connect.client.records.ExerciseRouteResult
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.health.connect.client.request.ChangesTokenRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.time.Instant
import kotlin.reflect.KClass
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Health Connect for the app (ADR 0030, D30, D31), over
* `app.halkyon.train/health`: whether it is there, its permissions by the
* app's names, writing and deleting Train's own records, and reading the
* sessions other apps wrote, their samples and, with consent, their routes.
* The app decides what to write and what to take; this only speaks to
* Health Connect.
*/
class HealthConnectBridge(private val activity: Activity) : MethodChannel.MethodCallHandler {
private val scope: CoroutineScope = MainScope()
private val client by lazy { HealthConnectClient.getOrCreate(activity) }
/** The app's question for permissions, answered once Health Connect has asked. */
private var asking: MethodChannel.Result? = null
/** The app's question for a route, answered once the person has said. */
private var routing: MethodChannel.Result? = null
/** Where Health Connect's rationale is asked for, while the app listens. */
private var rationale: EventChannel.EventSink? = null
private var rationaleWanted = false
/** Listens for Health Connect asking for Train's rationale (D31). */
val rationaleRequests =
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
rationale = events
if (rationaleWanted) {
rationaleWanted = false
events.success(null)
}
}
override fun onCancel(arguments: Any?) {
rationale = null
}
}
/** Shows the rationale where [intent] asks for it, now or once the app listens. */
fun onIntent(intent: Intent?) {
if (intent?.action !in RATIONALE_ACTIONS) return
rationale?.success(null) ?: run { rationaleWanted = true }
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "status") {
result.success(status())
return
}
if (status() != "available") {
result.error("unavailable", "Health Connect isn't available", null)
return
}
scope.launch {
try {
when (call.method) {
"granted" -> result.success(granted())
"ask" -> ask(call.arguments<List<String>>().orEmpty(), result)
"write" -> {
val withRoute = HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE in grantedPermissions()
val records = HealthRecords.recordsOf(call.arguments<List<Map<String, Any?>>>().orEmpty(), withRoute)
if (records.isNotEmpty()) client.insertRecords(records)
result.success(null)
}
"delete" -> {
for ((kind, ids) in call.arguments<Map<String, List<String>>>().orEmpty()) {
val type = HealthRecords.kinds[kind] ?: continue
if (ids.isNotEmpty()) client.deleteRecords(type, emptyList(), ids)
}
result.success(null)
}
"changesToken" ->
result.success(client.getChangesToken(ChangesTokenRequest(setOf(ExerciseSessionRecord::class))))
"changes" -> result.success(changes(call.arguments as String))
"sessions" -> result.success(sessions(call.argument<Number>("from")!!.toLong(), call.argument("apps")))
"samples" ->
result.success(
samples(
call.argument("app")!!,
call.argument<Number>("start")!!.toLong(),
call.argument<Number>("end")!!.toLong(),
),
)
"route" -> route(call.arguments as String, result)
"openSettings" -> {
activity.startActivity(Intent(HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS))
result.success(null)
}
else -> result.notImplemented()
}
} catch (error: SecurityException) {
result.error("refused", error.message, null)
} catch (error: Exception) {
result.error("failed", error.message, null)
}
}
}
/** Answers a question Health Connect's screens took over; false for any other. */
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
when (requestCode) {
PERMISSIONS_REQUEST -> {
val result = asking ?: return true
asking = null
scope.launch { result.success(granted()) }
}
ROUTE_REQUEST -> {
val result = routing ?: return true
routing = null
result.success(ExerciseRouteRequestContract().parseResult(resultCode, data)?.let(HealthRecords::routeToChannel))
}
else -> return false
}
return true
}
/** Stops what is still running. */
fun dispose() {
scope.cancel()
}
private fun status(): String =
when (HealthConnectClient.getSdkStatus(activity)) {
HealthConnectClient.SDK_AVAILABLE -> "available"
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> "update"
else -> "unavailable"
}
private suspend fun grantedPermissions(): Set<String> = client.permissionController.getGrantedPermissions()
/** The app's names whose permissions are all granted. */
private suspend fun granted(): List<String> {
val permissions = grantedPermissions()
return PERMISSIONS.filter { (_, wanted) -> permissions.containsAll(wanted) }.keys.toList()
}
private suspend fun ask(names: List<String>, result: MethodChannel.Result) {
val wanted = names.flatMap { PERMISSIONS[it].orEmpty() }.toSet() - grantedPermissions()
if (wanted.isEmpty() || asking != null) {
result.success(granted())
return
}
asking = result
val contract = PermissionController.createRequestPermissionResultContract()
@Suppress("DEPRECATION") // FlutterActivity is no ComponentActivity, so no result registry.
activity.startActivityForResult(contract.createIntent(activity, wanted), PERMISSIONS_REQUEST)
}
/**
* The sessions other apps wrote since [token], and the token to ask with
* next; `expired` where the token is too old (30 days), the app then
* reading the sessions again.
*/
private suspend fun changes(token: String): Map<String, Any?> {
val sessions = mutableListOf<Map<String, Any?>>()
var next = token
while (true) {
val response = client.getChanges(next)
if (response.changesTokenExpired) return mapOf("expired" to true)
for (change in response.changes) {
val record = (change as? UpsertionChange)?.record as? ExerciseSessionRecord ?: continue
if (record.metadata.dataOrigin.packageName != activity.packageName) {
sessions += HealthRecords.sessionOf(record, appNameOf(record.metadata.dataOrigin.packageName))
}
}
next = response.nextChangesToken
if (!response.hasMore) break
}
return mapOf("token" to next, "sessions" to sessions)
}
/** Other apps' sessions since [from] (as far back as Health Connect lets Train read), of [apps] where given. */
private suspend fun sessions(from: Long, apps: List<String>?): List<Map<String, Any?>> =
readAll(ExerciseSessionRecord::class, Instant.ofEpochMilli(from), Instant.now().plusSeconds(60), apps)
.filter { it.metadata.dataOrigin.packageName != activity.packageName }
.map { HealthRecords.sessionOf(it, appNameOf(it.metadata.dataOrigin.packageName)) }
/** What [app] recorded from [start] to [end]: its heart rate, distance and power. */
private suspend fun samples(app: String, start: Long, end: Long): Map<String, Any?> {
val (from, to) = Instant.ofEpochMilli(start) to Instant.ofEpochMilli(end)
val apps = listOf(app)
return mapOf(
"heartRate" to
readAll(HeartRateRecord::class, from, to, apps).flatMap { record ->
record.samples.map { listOf(it.time.toEpochMilli(), it.beatsPerMinute) }
},
"metres" to
readAll(DistanceRecord::class, from, to, apps)
.takeIf { it.isNotEmpty() }
?.sumOf { it.distance.inMeters },
"power" to
readAll(PowerRecord::class, from, to, apps).flatMap { record ->
record.samples.map { listOf(it.time.toEpochMilli(), it.power.inWatts) }
},
)
}
/** Another app's route of the session [id], asking the person where Health Connect wants it. */
private suspend fun route(id: String, result: MethodChannel.Result) {
val record = client.readRecord(ExerciseSessionRecord::class, id).record
when (val route = record.exerciseRouteResult) {
is ExerciseRouteResult.Data -> result.success(HealthRecords.routeToChannel(route.exerciseRoute))
is ExerciseRouteResult.ConsentRequired -> {
if (routing != null) {
result.error("busy", "Another route is being asked for", null)
return
}
routing = result
@Suppress("DEPRECATION") // As in ask: no result registry.
activity.startActivityForResult(ExerciseRouteRequestContract().createIntent(activity, id), ROUTE_REQUEST)
}
else -> result.success(null)
}
}
private suspend fun <T : Record> readAll(type: KClass<T>, from: Instant, to: Instant, apps: List<String>?): List<T> {
val records = mutableListOf<T>()
var page: String? = null
do {
val response =
client.readRecords(
ReadRecordsRequest(
recordType = type,
timeRangeFilter = TimeRangeFilter.between(from, to),
dataOriginFilter = apps.orEmpty().map(::DataOrigin).toSet(),
pageToken = page,
),
)
records += response.records
page = response.pageToken
} while (page != null)
return records
}
/** What [packageName] is called, where Android lets Train see it (the apps the manifest names). */
private fun appNameOf(packageName: String): String? =
try {
val info =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
} else {
activity.packageManager.getApplicationInfo(packageName, 0)
}
activity.packageManager.getApplicationLabel(info).toString()
} catch (_: PackageManager.NameNotFoundException) {
null
}
private companion object {
const val PERMISSIONS_REQUEST = 2
const val ROUTE_REQUEST = 3
/** Where Health Connect asks for the rationale: Android 14 on, and before. */
val RATIONALE_ACTIONS = setOf("android.intent.action.VIEW_PERMISSION_USAGE", "androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE")
/** Health Connect's permissions behind each of the app's names. */
val PERMISSIONS: Map<String, Set<String>> =
mapOf(
"write" to
setOf(
HealthPermission.getWritePermission(ExerciseSessionRecord::class),
HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE,
HealthPermission.getWritePermission(HeartRateRecord::class),
HealthPermission.getWritePermission(SpeedRecord::class),
HealthPermission.getWritePermission(PowerRecord::class),
HealthPermission.getWritePermission(DistanceRecord::class),
HealthPermission.getWritePermission(ElevationGainedRecord::class),
),
"read" to
setOf(
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
HealthPermission.getReadPermission(HeartRateRecord::class),
HealthPermission.getReadPermission(DistanceRecord::class),
HealthPermission.getReadPermission(PowerRecord::class),
),
"history" to setOf(HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY),
)
}
}

View file

@ -0,0 +1,245 @@
package app.halkyon.train
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ElevationGainedRecord
import androidx.health.connect.client.records.ExerciseRoute
import androidx.health.connect.client.records.ExerciseRouteResult
import androidx.health.connect.client.records.ExerciseSegment
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.metadata.Device
import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.Length
import androidx.health.connect.client.units.Power
import androidx.health.connect.client.units.Velocity
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.reflect.KClass
/**
* Health Connect's records as the app's channel speaks them (ADR 0030, D30,
* D31): the app builds what is written, in plain maps and lists, times in
* UTC milliseconds, positions in 1e-7 degrees, lengths in centimetres; this
* turns them into records, and the sessions other apps wrote back into maps.
*/
object HealthRecords {
/** Each kind of record the app writes, by its name on the channel. */
val kinds: Map<String, KClass<out Record>> =
mapOf(
"session" to ExerciseSessionRecord::class,
"heartRate" to HeartRateRecord::class,
"speed" to SpeedRecord::class,
"power" to PowerRecord::class,
"distance" to DistanceRecord::class,
"elevation" to ElevationGainedRecord::class,
)
/** Health Connect's exercise type for one of Train's activities. */
fun exerciseOf(activity: String): Int =
when (activity) {
"running" -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING
"walking" -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING
"hiking" -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING
"cycling" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
"indoorRowing" -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE
else -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
}
/**
* 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.
*/
fun activityOf(exercise: Int): String? =
when (exercise) {
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL,
-> "running"
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "walking"
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "hiking"
ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
-> "cycling"
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE -> "indoorRowing"
else -> null
}
/**
* The records [written] describes, each a map with its `kind`, `id` (the
* client record ID), `version`, `start` and `end`; a session without its
* route where [withRoute] is false (its permission refused). Samples
* Health Connect would refuse (a heart rate beyond 1 to 300, say) are
* left out rather than failing the whole workout.
*/
fun recordsOf(written: List<Map<String, Any?>>, withRoute: Boolean): List<Record> =
written.mapNotNull { recordOf(it, withRoute) }
private fun recordOf(map: Map<String, Any?>, withRoute: Boolean): Record? {
val start = instant(map["start"])
// Health Connect wants an interval's end after its start, a single
// sample's too.
val end = maxOf(instant(map["end"]), start.plusMillis(1))
val metadata =
Metadata.activelyRecorded(
Device(type = Device.TYPE_PHONE),
map["id"] as String,
(map["version"] as Number).toLong(),
)
val samples = (map["samples"] as List<*>?).orEmpty().map { it as List<*> }
return when (map["kind"]) {
"session" ->
ExerciseSessionRecord(
startTime = start,
startZoneOffset = offsetAt(start),
endTime = end,
endZoneOffset = offsetAt(end),
metadata = metadata,
exerciseType = exerciseOf(map["activity"] as String),
title = map["title"] as String?,
notes = map["notes"] as String?,
segments = pausesOf(map["pauses"], start, end),
exerciseRoute = if (withRoute) routeOf(map["route"], start, end) else null,
)
"heartRate" ->
HeartRateRecord(
startTime = start,
startZoneOffset = offsetAt(start),
endTime = end,
endZoneOffset = offsetAt(end),
samples =
samples
.filter { (it[1] as Number).toLong() in 1..300 }
.map { HeartRateRecord.Sample(instant(it[0]), (it[1] as Number).toLong()) },
metadata = metadata,
).takeIf { it.samples.isNotEmpty() }
"speed" ->
SpeedRecord(
startTime = start,
startZoneOffset = offsetAt(start),
endTime = end,
endZoneOffset = offsetAt(end),
samples =
samples
.filter { (it[1] as Number).toDouble() in 0.0..1_000.0 }
.map {
SpeedRecord.Sample(instant(it[0]), Velocity.metersPerSecond((it[1] as Number).toDouble()))
},
metadata = metadata,
).takeIf { it.samples.isNotEmpty() }
"power" ->
PowerRecord(
startTime = start,
startZoneOffset = offsetAt(start),
endTime = end,
endZoneOffset = offsetAt(end),
samples =
samples
.filter { (it[1] as Number).toDouble() in 0.0..100_000.0 }
.map { PowerRecord.Sample(instant(it[0]), Power.watts((it[1] as Number).toDouble())) },
metadata = metadata,
).takeIf { it.samples.isNotEmpty() }
"distance" ->
DistanceRecord(
startTime = start,
startZoneOffset = offsetAt(start),
endTime = end,
endZoneOffset = offsetAt(end),
distance = Length.meters((map["metres"] as Number).toDouble()),
metadata = metadata,
)
"elevation" ->
ElevationGainedRecord(
startTime = start,
startZoneOffset = offsetAt(start),
endTime = end,
endZoneOffset = offsetAt(end),
elevation = Length.meters((map["metres"] as Number).toDouble()),
metadata = metadata,
)
else -> null
}
}
/** The pauses `[start, end]` as pause segments, kept within the session and apart. */
private fun pausesOf(pauses: Any?, start: Instant, end: Instant): List<ExerciseSegment> {
var after = start
return (pauses as List<*>?).orEmpty().mapNotNull { pause ->
val (from, to) = (pause as List<*>).let { instant(it[0]) to instant(it[1]) }
val first = maxOf(from, after)
val last = minOf(to, end)
if (!first.isBefore(last)) {
null
} else {
after = last
ExerciseSegment(first, last, ExerciseSegment.EXERCISE_SEGMENT_TYPE_PAUSE)
}
}
}
/** The route `[time, latE7, lonE7, altitudeCm?, accuracyCm?]` within the session; null without one. */
private fun routeOf(route: Any?, start: Instant, end: Instant): ExerciseRoute? {
val locations =
(route as List<*>?).orEmpty().map { it as List<*> }.mapNotNull { fix ->
val time = instant(fix[0])
if (time.isBefore(start) || time.isAfter(end)) return@mapNotNull null
ExerciseRoute.Location(
time = time,
latitude = (fix[1] as Number).toDouble() / 1e7,
longitude = (fix[2] as Number).toDouble() / 1e7,
altitude = (fix.getOrNull(3) as Number?)?.let { Length.meters(it.toDouble() / 100) },
horizontalAccuracy = (fix.getOrNull(4) as Number?)?.let { Length.meters(it.toDouble() / 100) },
)
}
return if (locations.isEmpty()) null else ExerciseRoute(locations)
}
/**
* Another app's session as the channel carries it: its record ID, the app
* it came from and that app's name, its times, Train's activity (null:
* none of Train's), its title, notes and pauses, and whether a route is
* there (`data`), asks consent (`consent`) or isn't (`none`).
*/
fun sessionOf(record: ExerciseSessionRecord, appName: String?): Map<String, Any?> =
mapOf(
"id" to record.metadata.id,
"app" to record.metadata.dataOrigin.packageName,
"appName" to appName,
"start" to record.startTime.toEpochMilli(),
"end" to record.endTime.toEpochMilli(),
"activity" to activityOf(record.exerciseType),
"title" to record.title,
"notes" to record.notes,
"pauses" to
record.segments
.filter {
it.segmentType == ExerciseSegment.EXERCISE_SEGMENT_TYPE_PAUSE ||
it.segmentType == ExerciseSegment.EXERCISE_SEGMENT_TYPE_REST
}.map { listOf(it.startTime.toEpochMilli(), it.endTime.toEpochMilli()) },
"route" to
when (record.exerciseRouteResult) {
is ExerciseRouteResult.Data -> "data"
is ExerciseRouteResult.ConsentRequired -> "consent"
else -> "none"
},
)
/** [route] as the channel carries it, in the units the app writes. */
fun routeToChannel(route: ExerciseRoute): List<List<Any?>> =
route.route.map {
listOf(
it.time.toEpochMilli(),
Math.round(it.latitude * 1e7),
Math.round(it.longitude * 1e7),
it.altitude?.let { altitude -> Math.round(altitude.inMeters * 100) },
it.horizontalAccuracy?.let { accuracy -> Math.round(accuracy.inMeters * 100) },
)
}
private fun instant(value: Any?): Instant = Instant.ofEpochMilli((value as Number).toLong())
private fun offsetAt(time: Instant): ZoneOffset = ZoneId.systemDefault().rules.getOffset(time)
}

View file

@ -1,8 +1,10 @@
package app.halkyon.train
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
@ -12,6 +14,27 @@ class MainActivity : FlutterActivity() {
/** The app's question for permissions, answered once Android has asked. */
private var asking: Pair<List<String>, MethodChannel.Result>? = null
/** Health Connect's side (ADR 0030, D30, D31). */
private val health by lazy { HealthConnectBridge(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
health.onIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
health.onIntent(intent)
}
@Deprecated("FlutterActivity has no result registry")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (!health.onActivityResult(requestCode, resultCode, data)) {
@Suppress("DEPRECATION")
super.onActivityResult(requestCode, resultCode, data)
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
@ -46,6 +69,8 @@ class MainActivity : FlutterActivity() {
.setStreamHandler(DeviceScanner(this, HeartRateMeasurement.SERVICE))
EventChannel(messenger, "app.halkyon.train/recorder/rowers")
.setStreamHandler(DeviceScanner(this, RowerData.SERVICE, ::mayBeRower))
MethodChannel(messenger, "app.halkyon.train/health").setMethodCallHandler(health)
EventChannel(messenger, "app.halkyon.train/health/rationale").setStreamHandler(health.rationaleRequests)
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
@ -64,6 +89,7 @@ class MainActivity : FlutterActivity() {
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
RecorderService.lines = null
health.dispose()
super.cleanUpFlutterEngine(flutterEngine)
}

View file

@ -0,0 +1,141 @@
package app.halkyon.train
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ExerciseSegment
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.PowerRecord
import java.time.Instant
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HealthRecordsTest {
private val start = 1_761_634_800_000L
private fun session(vararg extra: Pair<String, Any?>): Map<String, Any?> =
mapOf(
"kind" to "session",
"id" to "w1",
"version" to 3,
"start" to start,
"end" to start + 1_800_000,
"activity" to "running",
"title" to "Morning run",
) + extra
@Test
fun `a session carries its type, title, client ID and version`() {
val record = HealthRecords.recordsOf(listOf(session()), withRoute = true).single() as ExerciseSessionRecord
assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, record.exerciseType)
assertEquals("Morning run", record.title)
assertEquals("w1", record.metadata.clientRecordId)
assertEquals(3L, record.metadata.clientRecordVersion)
assertEquals(Instant.ofEpochMilli(start), record.startTime)
}
@Test
fun `pauses become pause segments, kept within the session and apart`() {
val record =
HealthRecords.recordsOf(
listOf(
session(
"pauses" to
listOf(
listOf(start - 1_000, start + 60_000),
listOf(start + 50_000, start + 120_000),
listOf(start + 1_700_000, start + 2_000_000),
),
),
),
withRoute = true,
).single() as ExerciseSessionRecord
assertEquals(
listOf(
start to start + 60_000,
start + 60_000 to start + 120_000,
start + 1_700_000 to start + 1_800_000,
),
record.segments.map { it.startTime.toEpochMilli() to it.endTime.toEpochMilli() },
)
assertTrue(record.segments.all { it.segmentType == ExerciseSegment.EXERCISE_SEGMENT_TYPE_PAUSE })
}
@Test
fun `the route goes with the session only where its permission is granted`() {
val route = listOf(listOf(start + 1_000, 525_200_000, 134_050_000, 3_400, 500))
val with = HealthRecords.recordsOf(listOf(session("route" to route)), withRoute = true).single() as ExerciseSessionRecord
val without = HealthRecords.recordsOf(listOf(session("route" to route)), withRoute = false).single() as ExerciseSessionRecord
val location = with.exerciseRouteResult.let { it as androidx.health.connect.client.records.ExerciseRouteResult.Data }.exerciseRoute.route.single()
assertEquals(52.52, location.latitude, 1e-9)
assertEquals(13.405, location.longitude, 1e-9)
assertEquals(34.0, location.altitude!!.inMeters, 1e-9)
assertEquals(5.0, location.horizontalAccuracy!!.inMeters, 1e-9)
assertTrue(without.exerciseRouteResult !is androidx.health.connect.client.records.ExerciseRouteResult.Data)
}
@Test
fun `samples Health Connect would refuse are left out, and a record without any`() {
val records =
HealthRecords.recordsOf(
listOf(
mapOf(
"kind" to "heartRate", "id" to "w1/heartRate/0", "version" to 1, "start" to start, "end" to start + 2_000,
"samples" to listOf(listOf(start, 0), listOf(start + 1_000, 140), listOf(start + 2_000, 400)),
),
mapOf(
"kind" to "power", "id" to "w1/power/0", "version" to 1, "start" to start, "end" to start,
"samples" to listOf(listOf(start, -5)),
),
),
withRoute = true,
)
val heart = records.single() as HeartRateRecord
assertEquals(listOf(140L), heart.samples.map { it.beatsPerMinute })
}
@Test
fun `a single sample's record still ends after it starts`() {
val record =
HealthRecords.recordsOf(
listOf(
mapOf(
"kind" to "power", "id" to "w1/power/0", "version" to 1, "start" to start, "end" to start,
"samples" to listOf(listOf(start, 180)),
),
),
withRoute = true,
).single() as PowerRecord
assertTrue(record.endTime.isAfter(record.startTime))
assertEquals(180.0, record.samples.single().power.inWatts, 1e-9)
}
@Test
fun `totals carry their metres`() {
val record =
HealthRecords.recordsOf(
listOf(mapOf("kind" to "distance", "id" to "w1/distance", "version" to 1, "start" to start, "end" to start + 60_000, "metres" to 5_120)),
withRoute = true,
).single() as DistanceRecord
assertEquals(5_120.0, record.distance.inMeters, 1e-9)
}
@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("indoorRowing", HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE))
assertNull(HealthRecords.activityOf(ExerciseSessionRecord.EXERCISE_TYPE_YOGA))
for (activity in listOf("running", "walking", "hiking", "cycling", "indoorRowing")) {
assertEquals(activity, HealthRecords.activityOf(HealthRecords.exerciseOf(activity)))
}
}
}