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:
parent
d1dc833aa6
commit
030cb9b976
6 changed files with 1470 additions and 1 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue