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
10 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)))
}
}
}

View file

@ -0,0 +1,198 @@
import 'package:flutter/services.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Health Connect on Android, through the app's own `HealthConnectBridge`
/// (Kotlin, `android/app/src/main/kotlin/`, ADR 0030, D30): times cross in
/// UTC milliseconds, positions in 1e-7 degrees, lengths in centimetres.
class AndroidHealthPlatform() implements HealthPlatform {
static const _channel = MethodChannel('app.halkyon.train/health');
static const _rationale = EventChannel('app.halkyon.train/health/rationale');
@override
Future<HealthAvailability> availability() async =>
switch (await _channel.invokeMethod<String>('status')) {
'available' => HealthAvailability.available,
'update' => HealthAvailability.needsUpdate,
_ => HealthAvailability.unavailable,
};
@override
Future<Set<HealthAccess>> granted() async =>
_accessOf(await _call<List<Object?>>('granted'));
@override
Future<Set<HealthAccess>> ask(Set<HealthAccess> wanted) async => _accessOf(
await _call<List<Object?>>('ask', [
for (final access in wanted) access.name,
]),
);
@override
Future<void> write(List<HealthRecord> records) =>
_call<void>('write', [for (final record in records) recordJson(record)]);
@override
Future<void> delete(Map<HealthRecordKind, List<String>> clientIds) =>
_call<void>('delete', {
for (final MapEntry(:key, :value) in clientIds.entries) key.name: value,
});
@override
Future<String> changesToken() async => (await _call<String>('changesToken'))!;
@override
Future<HealthChanges> changes(String token) async {
final answer = (await _call<Map<Object?, Object?>>('changes', token))!;
if (answer['expired'] == true) {
return const HealthChanges(expired: true);
}
return HealthChanges(
token: answer['token']! as String,
sessions: [
for (final session in answer['sessions']! as List<Object?>)
sessionOf(session! as Map<Object?, Object?>),
],
);
}
@override
Future<List<OtherSession>> sessions({
required DateTime from,
Set<String>? apps,
}) async => [
for (final session in (await _call<List<Object?>>('sessions', {
'from': from.millisecondsSinceEpoch,
'apps': apps?.toList(),
}))!)
sessionOf(session! as Map<Object?, Object?>),
];
@override
Future<OtherSamples> samples(OtherSession session) async {
final answer = (await _call<Map<Object?, Object?>>('samples', {
'app': session.app,
'start': session.start.millisecondsSinceEpoch,
'end': session.end.millisecondsSinceEpoch,
}))!;
return OtherSamples(
heartRates: [
for (final sample in _rows(answer['heartRate']))
HeartRate(time: _time(sample[0]! as int), bpm: sample[1]! as int),
],
metres: (answer['metres'] as num?)?.toDouble(),
power: [
for (final sample in _rows(answer['power']))
(_time(sample[0]! as int), (sample[1]! as num).toDouble()),
],
);
}
@override
Future<List<Fix>?> route(String sessionId) async {
final route = await _call<List<Object?>>('route', sessionId);
return route == null ? null : routeOf(route);
}
@override
Future<void> openSettings() => _call<void>('openSettings');
@override
Stream<void> get rationaleRequests => _rationale.receiveBroadcastStream();
/// [method] called, its refusal thrown as [HealthRefused].
static Future<T?> _call<T>(String method, [Object? arguments]) async {
try {
return await _channel.invokeMethod<T>(method, arguments);
} on PlatformException catch (error) {
if (error.code == 'refused') {
throw HealthRefused();
}
rethrow;
}
}
static Set<HealthAccess> _accessOf(List<Object?>? names) => {
for (final name in names ?? const [])
?HealthAccess.values.asNameMap()[name],
};
}
/// [rows], a list of lists as the channel sends them; none for null.
Iterable<List<Object?>> _rows(Object? rows) =>
(rows as List<Object?>? ?? const []).cast<List<Object?>>();
DateTime _time(int milliseconds) =>
DateTime.fromMillisecondsSinceEpoch(milliseconds, isUtc: true);
/// [record] as `HealthRecords.kt` reads it.
Map<String, Object?> recordJson(HealthRecord record) => {
'kind': record.kind.name,
'id': record.clientId,
'version': record.version,
'start': record.start.millisecondsSinceEpoch,
'end': record.end.millisecondsSinceEpoch,
...switch (record) {
HealthSession() => {
'activity': record.activity.name,
'title': record.title,
'notes': record.notes,
'pauses': [
for (final (from, to) in record.pauses)
[from.millisecondsSinceEpoch, to.millisecondsSinceEpoch],
],
'route': [
for (final fix in record.route)
[
fix.time.millisecondsSinceEpoch,
fix.latE7,
fix.lonE7,
fix.altitudeCm,
fix.accuracyCm,
],
],
},
HealthSamples() => {
'samples': [
for (final (time, value) in record.samples)
[time.millisecondsSinceEpoch, value],
],
},
HealthTotal() => {'metres': record.metres},
},
};
/// Another app's session as `HealthRecords.kt` sends it.
OtherSession sessionOf(Map<Object?, Object?> json) => OtherSession(
id: json['id']! as String,
app: json['app']! as String,
appName: json['appName'] as String?,
start: _time(json['start']! as int),
end: _time(json['end']! as int),
activity: Activity.values.asNameMap()[json['activity']],
title: json['title'] as String?,
notes: json['notes'] as String?,
pauses: [
for (final pause in _rows(json['pauses']))
(_time(pause[0]! as int), _time(pause[1]! as int)),
],
route: switch (json['route']) {
'data' => RouteAvailability.readable,
'consent' => RouteAvailability.asksConsent,
_ => RouteAvailability.none,
},
);
/// A route as `HealthRecords.kt` sends it; a location without its accuracy
/// counts as precise, as the app that wrote it kept it.
List<Fix> routeOf(List<Object?> route) => [
for (final location in _rows(route))
Fix(
time: _time(location[0]! as int),
latE7: location[1]! as int,
lonE7: location[2]! as int,
altitudeCm: location.elementAtOrNull(3) as int?,
accuracyCm: location.elementAtOrNull(4) as int? ?? 0,
),
];

View file

@ -0,0 +1,232 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Whether this device has Health Connect (ADR 0030, D31).
enum HealthAvailability() {
/// There, built into Android (14 on) or as its app.
available,
/// Its app is there but too old for Train.
needsUpdate,
/// Not there: before Android 14 without its app, or no Android at all.
unavailable,
}
/// What Train may do there, each one switch's permissions (D31).
enum HealthAccess() {
/// Writing its workouts: sessions, routes, heart rate, speed, power,
/// distance and elevation gained.
write,
/// Reading other apps' sessions, their heart rate, distance and power.
read,
/// Reading what is older than 30 days before reading was granted.
history,
}
/// The kinds of record Train writes, by their names on the channel.
enum HealthRecordKind() {
session,
heartRate,
speed,
power,
distance,
elevation,
}
/// A record Train writes to Health Connect (D31): its client record ID,
/// which a record written again with a higher [version] replaces, and the
/// time it covers.
sealed class const HealthRecord({
required final String clientId,
required final int version,
required final DateTime start,
required final DateTime end,
}) with Equatable {
/// What it is.
HealthRecordKind get kind;
@override
List<Object?> get props => [kind, clientId, version, start, end];
}
/// A workout as a session: its activity, name, notes, pauses and route.
final class const HealthSession({
required super.clientId,
required super.version,
required super.start,
required super.end,
required final Activity activity,
final String? title,
final String? notes,
/// Each pause, from its start to its end.
final List<(DateTime, DateTime)> pauses = const [],
/// The fixes it moved by; none for a row.
final List<Fix> route = const [],
}) extends HealthRecord {
@override
HealthRecordKind get kind => HealthRecordKind.session;
@override
List<Object?> get props => [
...super.props,
activity,
title,
notes,
pauses,
route,
];
}
/// Samples of one kind over a stretch: the heart rate in beats a minute,
/// the speed in metres a second, or power in watts.
final class const HealthSamples({
@override required final HealthRecordKind kind,
required super.clientId,
required super.version,
required super.start,
required super.end,
required final List<(DateTime, num)> samples,
}) extends HealthRecord {
@override
List<Object?> get props => [...super.props, samples];
}
/// A total over the workout: its distance or elevation gained, in metres.
final class const HealthTotal({
@override required final HealthRecordKind kind,
required super.clientId,
required super.version,
required super.start,
required super.end,
required final num metres,
}) extends HealthRecord {
@override
List<Object?> get props => [...super.props, metres];
}
/// Whether another app's session has a route Train may read.
enum RouteAvailability() {
/// There, readable.
readable,
/// There, asking the person's consent for this session.
asksConsent,
/// None.
none,
}
/// A session another app wrote (D31).
final class const OtherSession({
/// Health Connect's ID of it.
required final String id,
/// The app that wrote it, its package, and its name where Android lets
/// Train see it.
required final String app,
final String? appName,
required final DateTime start,
required final DateTime end,
/// Train's activity for it; null for one Train doesn't record.
final Activity? activity,
final String? title,
final String? notes,
final List<(DateTime, DateTime)> pauses = const [],
final RouteAvailability route = RouteAvailability.none,
}) with Equatable {
@override
List<Object?> get props => [
id,
app,
appName,
start,
end,
activity,
title,
notes,
pauses,
route,
];
}
/// What another app recorded over one of its sessions.
final class const OtherSamples({
final List<HeartRate> heartRates = const [],
/// Its distance, summed; null where it wrote none.
final double? metres,
/// Its power, in watts.
final List<(DateTime, double)> power = const [],
}) with Equatable {
@override
List<Object?> get props => [heartRates, metres, power];
}
/// The sessions other apps wrote since a changes token, and the token to
/// ask with next; [expired] where the token was too old, all to be read
/// again.
final class const HealthChanges({
final String? token,
final List<OtherSession> sessions = const [],
final bool expired = false,
}) with Equatable {
@override
List<Object?> get props => [token, sessions, expired];
}
/// The person refused what Health Connect needed, or took it back.
final class HealthRefused() implements Exception;
/// Health Connect on this device (D30): Android's, through Train's own
/// Kotlin, or a fake in tests.
abstract interface class HealthPlatform() {
/// Whether it is here.
Future<HealthAvailability> availability();
/// What Train is granted.
Future<Set<HealthAccess>> granted();
/// Asks for [wanted] where not granted yet, and answers what is granted.
Future<Set<HealthAccess>> ask(Set<HealthAccess> wanted);
/// Writes [records], each replacing the one of its client ID; throws
/// [HealthRefused] without the permission.
Future<void> write(List<HealthRecord> records);
/// Deletes Train's records of these client IDs.
Future<void> delete(Map<HealthRecordKind, List<String>> clientIds);
/// A token to ask for the sessions written after now.
Future<String> changesToken();
/// The sessions other apps wrote since [token].
Future<HealthChanges> changes(String token);
/// Other apps' sessions since [from] (as far back as Health Connect lets
/// Train read), of [apps] where given.
Future<List<OtherSession>> sessions({
required DateTime from,
Set<String>? apps,
});
/// What [session]'s app recorded over it.
Future<OtherSamples> samples(OtherSession session);
/// [sessionId]'s route, asking the person where Health Connect wants it;
/// null where they refused or there is none.
Future<List<Fix>?> route(String sessionId);
/// Opens Health Connect's settings, where what Train is granted changes.
Future<void> openSettings();
/// Fires when Health Connect asks for Train's rationale (D31).
Stream<void> get rationaleRequests;
}

View file

@ -0,0 +1,128 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/android_health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30);
final ms = start.millisecondsSinceEpoch;
test('a session crosses with its pauses and route in the units '
'HealthRecords.kt reads', () {
final json = recordJson(
HealthSession(
clientId: 'w1',
version: 2,
start: start,
end: start.add(const Duration(minutes: 30)),
activity: Activity.indoorRowing,
title: 'Row',
pauses: [
(
start.add(const Duration(minutes: 5)),
start.add(const Duration(minutes: 6)),
),
],
route: [
Fix(
time: start,
latE7: 525200000,
lonE7: 134050000,
accuracyCm: 500,
altitudeCm: 3400,
),
],
),
);
expect(json, {
'kind': 'session',
'id': 'w1',
'version': 2,
'start': ms,
'end': ms + 1800000,
'activity': 'indoorRowing',
'title': 'Row',
'notes': null,
'pauses': [
[ms + 300000, ms + 360000],
],
'route': [
[ms, 525200000, 134050000, 3400, 500],
],
});
});
test('samples and totals cross as their values', () {
expect(
recordJson(
HealthSamples(
kind: HealthRecordKind.heartRate,
clientId: 'w1/heartRate/0',
version: 1,
start: start,
end: start,
samples: [(start, 140)],
),
),
containsPair('samples', [
[ms, 140],
]),
);
expect(
recordJson(
HealthTotal(
kind: HealthRecordKind.distance,
clientId: 'w1/distance',
version: 1,
start: start,
end: start,
metres: 5120,
),
),
allOf(containsPair('kind', 'distance'), containsPair('metres', 5120)),
);
});
test("another app's session is read with Train's activity, or none", () {
final session = sessionOf({
'id': 'hc1',
'app': 'nodomain.freeyourgadget.gadgetbridge',
'appName': 'Gadgetbridge',
'start': ms,
'end': ms + 600000,
'activity': 'running',
'title': null,
'notes': null,
'pauses': [
[ms + 1000, ms + 2000],
],
'route': 'consent',
});
expect(session.activity, Activity.running);
expect(session.appName, 'Gadgetbridge');
expect(session.pauses.single.$1, start.add(const Duration(seconds: 1)));
expect(session.route, RouteAvailability.asksConsent);
expect(
sessionOf({
'id': 'hc2',
'app': 'x',
'start': ms,
'end': ms,
'activity': null,
}).activity,
isNull,
);
});
test('a route without accuracy counts as precise', () {
expect(
routeOf([
[ms, 525200000, 134050000, null, null],
]),
[Fix(time: start, latE7: 525200000, lonE7: 134050000, accuracyCm: 0)],
);
});
}

View file

@ -0,0 +1,146 @@
import 'dart:async';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
/// Health Connect as tests set it up: what it grants when asked, the
/// records Train wrote by their client IDs, and the sessions other apps
/// wrote, a changes token being how many of them were there.
class FakeHealthPlatform({
var HealthAvailability available = HealthAvailability.available,
Set<HealthAccess>? grants,
}) implements HealthPlatform {
/// What the person grants when asked; everything by default.
final Set<HealthAccess> grants = grants ?? HealthAccess.values.toSet();
/// What is granted now.
final grantedNow = <HealthAccess>{};
/// What Train asked for, each time.
final asked = <Set<HealthAccess>>[];
/// Train's records by their client IDs, as last written.
final written = <String, HealthRecord>{};
/// How many times Train wrote.
int writes = 0;
/// Other apps' sessions, in the order they wrote them.
final others = <OtherSession>[];
/// What each of [others] recorded, by its ID.
final samplesOf = <String, OtherSamples>{};
/// Each session's route, by its ID; asking for one not here is refused.
final routes = <String, List<Fix>>{};
/// Whether the changes tokens handed out have expired.
bool tokensExpired = false;
/// How many times the sessions were read whole, since when.
final fullReads = <DateTime>[];
final _rationale = StreamController<void>.broadcast();
/// Health Connect asking for Train's rationale.
void askForRationale() => _rationale.add(null);
@override
Future<HealthAvailability> availability() async => available;
@override
Future<Set<HealthAccess>> granted() async => {...grantedNow};
@override
Future<Set<HealthAccess>> ask(Set<HealthAccess> wanted) async {
asked.add(wanted);
grantedNow.addAll(wanted.intersection(grants));
return {...grantedNow};
}
@override
Future<void> write(List<HealthRecord> records) async {
if (!grantedNow.contains(HealthAccess.write)) {
throw HealthRefused();
}
writes++;
for (final record in records) {
final before = written[record.clientId];
if (before == null || before.version < record.version) {
written[record.clientId] = record;
}
}
}
@override
Future<void> delete(Map<HealthRecordKind, List<String>> clientIds) async {
for (final MapEntry(:key, :value) in clientIds.entries) {
for (final id in value) {
if (written[id]?.kind == key) {
written.remove(id);
}
}
}
}
void _mayRead() {
if (!grantedNow.contains(HealthAccess.read)) {
throw HealthRefused();
}
}
@override
Future<String> changesToken() async {
_mayRead();
return '${others.length}';
}
@override
Future<HealthChanges> changes(String token) async {
_mayRead();
if (tokensExpired) {
return const HealthChanges(expired: true);
}
return HealthChanges(
token: '${others.length}',
sessions: others.sublist(int.parse(token)),
);
}
@override
Future<List<OtherSession>> sessions({
required DateTime from,
Set<String>? apps,
}) async {
_mayRead();
fullReads.add(from);
// Health Connect lets Train read 30 days back without the history.
final since = grantedNow.contains(HealthAccess.history)
? from
: DateTime.now().toUtc().subtract(const Duration(days: 30));
return [
for (final session in others)
if (!session.start.isBefore(since) &&
(apps == null || apps.contains(session.app)))
session,
];
}
@override
Future<OtherSamples> samples(OtherSession session) async {
_mayRead();
return samplesOf[session.id] ?? const OtherSamples();
}
@override
Future<List<Fix>?> route(String sessionId) async => routes[sessionId];
/// How many times Health Connect's settings were opened.
int settingsOpened = 0;
@override
Future<void> openSettings() async => settingsOpened++;
@override
Stream<void> get rationaleRequests => _rationale.stream;
}