craft-native 0.0.167 → 0.0.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1046 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.app.Activity
4
+ import android.content.ActivityNotFoundException
5
+ import android.content.Context
6
+ import android.content.Intent
7
+ import android.content.pm.PackageManager
8
+ import android.net.Uri
9
+ import android.os.Build
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import androidx.health.connect.client.HealthConnectClient
13
+ import androidx.health.connect.client.HealthConnectFeatures
14
+ import androidx.health.connect.client.PermissionController
15
+ import androidx.health.connect.client.aggregate.AggregateMetric
16
+ import androidx.health.connect.client.aggregate.AggregationResult
17
+ import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi
18
+ import androidx.health.connect.client.permission.HealthPermission
19
+ import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
20
+ import androidx.health.connect.client.records.DistanceRecord
21
+ import androidx.health.connect.client.records.ElevationGainedRecord
22
+ import androidx.health.connect.client.records.ExerciseSessionRecord
23
+ import androidx.health.connect.client.records.HeartRateRecord
24
+ import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
25
+ import androidx.health.connect.client.records.MindfulnessSessionRecord
26
+ import androidx.health.connect.client.records.NutritionRecord
27
+ import androidx.health.connect.client.records.Record
28
+ import androidx.health.connect.client.records.RestingHeartRateRecord
29
+ import androidx.health.connect.client.records.SleepSessionRecord
30
+ import androidx.health.connect.client.records.StepsRecord
31
+ import androidx.health.connect.client.records.WeightRecord
32
+ import androidx.health.connect.client.records.metadata.DataOrigin
33
+ import androidx.health.connect.client.records.metadata.Device
34
+ import androidx.health.connect.client.records.metadata.Metadata
35
+ import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
36
+ import androidx.health.connect.client.request.AggregateRequest
37
+ import androidx.health.connect.client.request.ReadRecordsRequest
38
+ import androidx.health.connect.client.time.TimeRangeFilter
39
+ import androidx.health.connect.client.units.Energy
40
+ import androidx.health.connect.client.units.Length
41
+ import androidx.health.connect.client.units.Mass
42
+ import kotlinx.coroutines.CancellationException
43
+ import kotlinx.coroutines.CompletableDeferred
44
+ import kotlinx.coroutines.CoroutineScope
45
+ import kotlinx.coroutines.Dispatchers
46
+ import kotlinx.coroutines.SupervisorJob
47
+ import kotlinx.coroutines.cancel
48
+ import kotlinx.coroutines.launch
49
+ import kotlinx.coroutines.withContext
50
+ import org.json.JSONArray
51
+ import org.json.JSONObject
52
+ import java.time.Duration
53
+ import java.time.Instant
54
+ import java.time.LocalDate
55
+ import java.time.LocalDateTime
56
+ import java.time.Period
57
+ import java.time.ZoneId
58
+ import java.util.TreeMap
59
+ import java.util.concurrent.ConcurrentHashMap
60
+ import java.util.concurrent.atomic.AtomicBoolean
61
+ import java.util.concurrent.atomic.AtomicReference
62
+ import kotlin.math.roundToLong
63
+ import kotlin.reflect.KClass
64
+
65
+ /**
66
+ * Health Connect for the native renderer: the Android counterpart of
67
+ * CraftHealth.swift, answering the same `Health` methods with the same
68
+ * arguments and the same result shapes, so one page's JavaScript reads and
69
+ * writes Health Connect exactly as it reads and writes Apple Health.
70
+ *
71
+ * Where the two stores differ, the answer keeps Apple's shape and this file
72
+ * says what it stands for:
73
+ * - heart rate variability is RMSSD here (Health Connect has no SDNN), still
74
+ * in milliseconds;
75
+ * - Health Connect tells an app which permissions it holds, so asking answers
76
+ * whether reading was allowed, where HealthKit only says the sheet closed;
77
+ * - a record's source is the package that wrote it, named by its app label,
78
+ * so this app's own writes come back under its own name (which a page uses
79
+ * to skip them on the way back in).
80
+ *
81
+ * Every answer is delivered on the main thread, exactly once. The work runs on
82
+ * Dispatchers.IO in a scope that `close()` cancels.
83
+ */
84
+ @OptIn(ExperimentalMindfulnessSessionApi::class)
85
+ internal class CraftNativeHealth(
86
+ private val context: Context,
87
+ /** The foreground activity, or null in a background worker (no permission prompt possible). */
88
+ private val activityProvider: () -> Activity?,
89
+ /** Ask for READ_HEALTH_DATA_IN_BACKGROUND / READ_HEALTH_DATA_HISTORY with the read types when the feature is available (the manifest declares them only when these are true). */
90
+ private val backgroundReads: Boolean,
91
+ private val historyReads: Boolean,
92
+ ) {
93
+ /** A read or write that did not happen: the bridge's error code and the message a page sees. */
94
+ private class HealthFailure(val code: String, message: String) : Exception(message)
95
+
96
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
97
+ private val mainHandler = Handler(Looper.getMainLooper())
98
+ private val closed = AtomicBoolean(false)
99
+ private val permissionContract = PermissionController.createRequestPermissionResultContract()
100
+
101
+ /** The permission sheet on screen, if any: completed by onActivityResult. One at a time. */
102
+ private val pendingPermission = AtomicReference<CompletableDeferred<Unit>?>(null)
103
+
104
+ /** App labels by package, so a read of a thousand records asks the package manager once per app. */
105
+ private val labels = ConcurrentHashMap<String, String>()
106
+
107
+ @Volatile
108
+ private var cachedClient: HealthConnectClient? = null
109
+
110
+ private val ownOrigin = DataOrigin(context.packageName)
111
+
112
+ /** A native screen's call (`Health` in CraftNativeActions), by method and arguments as the bridge sends them. */
113
+ fun perform(method: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
114
+ val options = { index: Int -> args.optJSONObject(index) ?: JSONObject() }
115
+ when (method) {
116
+ "requestAuthorization" -> answer(completion) {
117
+ val requested = args.optJSONArray(0) ?: throw invalid("requestAuthorization needs a list of types")
118
+ val types = (0 until requested.length()).map { index ->
119
+ requested.opt(index) as? String ?: throw invalid("requestAuthorization types must be strings")
120
+ }
121
+ // `write`: false (read only), true (the types' own writes),
122
+ // or a list of the types to write. Health Connect shows
123
+ // every permission asked for, and Play reviews each one, so
124
+ // an app asks to write only what it writes, in one sheet.
125
+ val write = options(1).opt("write")
126
+ val writeTypes = (write as? JSONArray)?.let { list -> (0 until list.length()).mapNotNull { list.opt(it) as? String }.toSet() }
127
+ val readOnly = write == false || options(1).optBoolean("readOnly", false)
128
+ requestAuthorization(types, readOnly, writeTypes)
129
+ }
130
+ "availability" -> answer(completion) { availability() }
131
+ "grantedTypes" -> answer(completion) { grantedTypes() }
132
+ "openSettings" -> answer(completion) { openSettings() }
133
+ "getData" -> answer(completion) {
134
+ val type = args.opt(0) as? String ?: throw invalid("getData needs a type")
135
+ getData(type, options(1))
136
+ }
137
+ "saveData" -> answer(completion) {
138
+ val type = args.opt(0) as? String
139
+ val value = (args.opt(1) as? Number)?.toDouble()
140
+ if (type == null || value == null) throw invalid("saveData needs a type and numeric value")
141
+ saveData(type, value, options(2))
142
+ }
143
+ "saveWorkout" -> answer(completion) { saveWorkout(options(0)) }
144
+ "getWorkouts" -> answer(completion) { getWorkouts(options(0)) }
145
+ "getDailyStatistics" -> answer(completion) {
146
+ val type = args.opt(0) as? String ?: throw invalid("getDailyStatistics needs a type")
147
+ getDailyStatistics(type, options(1))
148
+ }
149
+ "getHeartRateSamples" -> answer(completion) { getHeartRateSamples(options(0)) }
150
+ "getSleepIntervals" -> answer(completion) { getSleepIntervals(options(0)) }
151
+ "saveMindfulSession" -> answer(completion) { saveMindfulSession(options(0)) }
152
+ "getMindfulSessions" -> answer(completion) { getMindfulSessions(options(0)) }
153
+ else -> deliver(completion, CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported Health method: $method"))
154
+ }
155
+ }
156
+
157
+ /** Activity results; returns true when the request code was this class's. */
158
+ fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
159
+ if (requestCode != PERMISSION_REQUEST_CODE) return false
160
+ // What was granted is read back from Health Connect itself, which
161
+ // also counts permissions granted before this sheet opened.
162
+ pendingPermission.getAndSet(null)?.complete(Unit)
163
+ return true
164
+ }
165
+
166
+ fun close() {
167
+ closed.set(true)
168
+ pendingPermission.getAndSet(null)?.cancel()
169
+ scope.cancel()
170
+ }
171
+
172
+ // MARK: - Answering
173
+
174
+ /**
175
+ * Runs one call on the IO scope and hands its answer to `completion` on
176
+ * the main thread, once: the value it returns, or the failure it threw.
177
+ * A call cut short by `close()` still answers, with a failure.
178
+ */
179
+ private fun answer(completion: (CraftNativeCapabilityResult) -> Unit, block: suspend () -> Any) {
180
+ val answered = AtomicBoolean(false)
181
+ val finish = { result: CraftNativeCapabilityResult ->
182
+ if (answered.compareAndSet(false, true)) deliver(completion, result)
183
+ }
184
+ if (closed.get()) return finish(closedResult())
185
+ val job = scope.launch {
186
+ val result = try {
187
+ CraftNativeCapabilityResult(data = block())
188
+ } catch (error: CancellationException) {
189
+ finish(closedResult())
190
+ throw error
191
+ } catch (error: Throwable) {
192
+ failure(error)
193
+ }
194
+ finish(result)
195
+ }
196
+ // A job cancelled before it started never runs its body.
197
+ job.invokeOnCompletion { cause -> if (cause != null) finish(closedResult()) }
198
+ }
199
+
200
+ private fun deliver(completion: (CraftNativeCapabilityResult) -> Unit, result: CraftNativeCapabilityResult) {
201
+ if (Looper.myLooper() == Looper.getMainLooper()) completion(result) else mainHandler.post { completion(result) }
202
+ }
203
+
204
+ private fun closedResult() = CraftNativeCapabilityResult(code = "CRAFT_ERROR", message = "Health Connect was closed before it answered")
205
+
206
+ private fun failure(error: Throwable): CraftNativeCapabilityResult = when (error) {
207
+ is HealthFailure -> CraftNativeCapabilityResult(code = error.code, message = error.message)
208
+ // Health Connect refuses a read (or write) it was not allowed, and a
209
+ // read from the background without the background permission.
210
+ is SecurityException -> CraftNativeCapabilityResult(code = "PERMISSION_DENIED", message = error.message ?: "Health Connect permission was not granted")
211
+ else -> CraftNativeCapabilityResult(code = "CRAFT_ERROR", message = error.message ?: error.javaClass.simpleName)
212
+ }
213
+
214
+ private fun invalid(message: String) = HealthFailure("INVALID_ARGUMENT", message)
215
+
216
+ // MARK: - Health Connect itself
217
+
218
+ private fun sdkStatus(): Int = HealthConnectClient.getSdkStatus(context, PROVIDER_PACKAGE)
219
+
220
+ /** The client, or the failure that says why there is none: not on this device, or the app needs installing or updating. */
221
+ private fun client(): HealthConnectClient {
222
+ cachedClient?.let { return it }
223
+ when (sdkStatus()) {
224
+ HealthConnectClient.SDK_AVAILABLE -> Unit
225
+ HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> throw HealthFailure(
226
+ "HEALTH_CONNECT_UPDATE_REQUIRED",
227
+ "Health Connect needs to be installed or updated from Google Play",
228
+ )
229
+ else -> throw HealthFailure("HEALTH_CONNECT_UNAVAILABLE", "Health Connect is not available on this device")
230
+ }
231
+ return HealthConnectClient.getOrCreate(context, PROVIDER_PACKAGE).also { cachedClient = it }
232
+ }
233
+
234
+ private fun featureAvailable(client: HealthConnectClient, feature: Int): Boolean = try {
235
+ client.features.getFeatureStatus(feature) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE
236
+ } catch (error: Exception) {
237
+ false
238
+ }
239
+
240
+ /** Mindfulness sessions arrived in a later Health Connect; an older one has no such record. */
241
+ private fun mindfulnessAvailable(client: HealthConnectClient) = featureAvailable(client, HealthConnectFeatures.FEATURE_MINDFULNESS_SESSION)
242
+
243
+ // MARK: - Permissions
244
+
245
+ private suspend fun requestAuthorization(types: List<String>, readOnly: Boolean, writeTypes: Set<String>? = null): Any {
246
+ if (types.isEmpty()) throw invalid("requestAuthorization needs at least one type")
247
+ types.firstOrNull { it !in TYPES }?.let { throw invalid("Unknown health data type: $it") }
248
+ writeTypes?.firstOrNull { it !in TYPES }?.let { throw invalid("Unknown health data type: $it") }
249
+ val client = client()
250
+ val mindfulness = mindfulnessAvailable(client)
251
+
252
+ val reads = mutableSetOf<String>()
253
+ val writes = mutableSetOf<String>()
254
+ for (type in types) {
255
+ // A Health Connect without mindfulness sessions cannot grant
256
+ // them; asking would only fail the whole sheet.
257
+ if (type == "mindful" && !mindfulness) continue
258
+ reads += readPermissions(type)
259
+ // A page that only reads asks only to read.
260
+ if (!readOnly && writeTypes == null) writes += writePermissions(type)
261
+ }
262
+ for (type in writeTypes.orEmpty()) {
263
+ if (type == "mindful" && !mindfulness) continue
264
+ writes += writePermissions(type)
265
+ }
266
+ if (reads.isEmpty() && writes.isEmpty()) return false
267
+
268
+ val requested = reads + writes + buildSet {
269
+ // Without these Health Connect answers reads only while the app
270
+ // is in front, and only for the 30 days before the first grant.
271
+ if (reads.isNotEmpty() && backgroundReads && featureAvailable(client, HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND)) {
272
+ add(HealthPermission.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND)
273
+ }
274
+ if (reads.isNotEmpty() && historyReads && featureAvailable(client, HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_HISTORY)) {
275
+ add(HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY)
276
+ }
277
+ }
278
+
279
+ if (client.permissionController.getGrantedPermissions().containsAll(requested)) return true
280
+
281
+ val activity = activityProvider()
282
+ ?: throw HealthFailure("NEEDS_ACTIVITY", "Health Connect permissions can only be asked for while the app is open")
283
+ val answered = CompletableDeferred<Unit>()
284
+ if (!pendingPermission.compareAndSet(null, answered)) {
285
+ throw HealthFailure("BUSY", "A Health Connect permission request is already open")
286
+ }
287
+ try {
288
+ withContext(Dispatchers.Main) {
289
+ // From Android 14 Health Connect's permissions are the
290
+ // platform's own, and the contract's intent is the runtime
291
+ // permission request: only an activity's result registry can
292
+ // launch it (startActivityForResult finds no activity for it).
293
+ val registry = (activity as? androidx.activity.ComponentActivity)?.activityResultRegistry
294
+ if (registry != null) {
295
+ var launcher: androidx.activity.result.ActivityResultLauncher<Set<String>>? = null
296
+ launcher = registry.register("craft-health-${System.nanoTime()}", permissionContract) {
297
+ launcher?.unregister()
298
+ pendingPermission.getAndSet(null)?.complete(Unit)
299
+ }
300
+ launcher.launch(requested)
301
+ } else {
302
+ val intent = permissionContract.createIntent(activity, requested)
303
+ activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
304
+ }
305
+ }
306
+ answered.await()
307
+ } finally {
308
+ pendingPermission.compareAndSet(answered, null)
309
+ }
310
+
311
+ // Unlike HealthKit, Health Connect says what was allowed: reading
312
+ // anything at all counts as connected.
313
+ val granted = client.permissionController.getGrantedPermissions()
314
+ return reads.any { it in granted }
315
+ }
316
+
317
+ private fun availability(): Any = when (sdkStatus()) {
318
+ HealthConnectClient.SDK_AVAILABLE -> "available"
319
+ HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> "updateRequired"
320
+ else -> "unavailable"
321
+ }
322
+
323
+ /** The types whose every read permission is granted, in the order of TYPES. */
324
+ private suspend fun grantedTypes(): Any {
325
+ val granted = client().permissionController.getGrantedPermissions()
326
+ return JSONArray(TYPES.filter { type -> granted.containsAll(readPermissions(type)) })
327
+ }
328
+
329
+ /**
330
+ * Health Connect's own screen for this app's permissions, or Google Play's
331
+ * page for Health Connect when it needs installing or updating. Answers
332
+ * whether a screen opened.
333
+ */
334
+ private suspend fun openSettings(): Any {
335
+ val candidates = mutableListOf<Intent>()
336
+ if (sdkStatus() == HealthConnectClient.SDK_AVAILABLE) {
337
+ candidates += HealthConnectClient.getHealthConnectManageDataIntent(context, PROVIDER_PACKAGE)
338
+ candidates += Intent(HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS)
339
+ } else {
340
+ candidates += Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$PROVIDER_PACKAGE&url=healthconnect%3A%2F%2Fonboarding"))
341
+ .setPackage("com.android.vending")
342
+ candidates += Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$PROVIDER_PACKAGE"))
343
+ }
344
+ return withContext(Dispatchers.Main) {
345
+ val activity = activityProvider()
346
+ candidates.any { intent ->
347
+ try {
348
+ if (activity != null) {
349
+ activity.startActivity(intent)
350
+ } else {
351
+ context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
352
+ }
353
+ true
354
+ } catch (error: ActivityNotFoundException) {
355
+ false
356
+ } catch (error: SecurityException) {
357
+ false
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ // MARK: - Quantities
364
+
365
+ /** Sum, average or latest of one type between two instants, as `{ value, unit }`; 0 when there is nothing. */
366
+ private suspend fun getData(type: String, options: JSONObject): Any {
367
+ val unit = unitOf(type) ?: throw invalid("Unknown health data type: $type")
368
+ val end = options.instant("endDate") ?: Instant.now()
369
+ val start = options.instant("startDate") ?: end.minus(Duration.ofDays(7))
370
+ val filter = range(start, end)
371
+ val client = client()
372
+ val value = when (type) {
373
+ "heartRateVariability" -> readAll(client, HeartRateVariabilityRmssdRecord::class, filter)
374
+ .map { it.heartRateVariabilityMillis }
375
+ .averageOrNull() ?: 0.0
376
+ "bodyMass" -> client.readRecords(
377
+ ReadRecordsRequest(recordType = WeightRecord::class, timeRangeFilter = filter, ascendingOrder = false, pageSize = 1),
378
+ ).records.firstOrNull()?.weight?.inKilograms ?: 0.0
379
+ else -> {
380
+ val metric = aggregateMetric(type) ?: throw invalid("Unknown health data type: $type")
381
+ aggregateValue(client.aggregate(AggregateRequest(setOf(metric), filter)), type) ?: 0.0
382
+ }
383
+ }
384
+ return JSONObject().put("value", value).put("unit", unit)
385
+ }
386
+
387
+ /**
388
+ * Saves one food-log entry (one nutrient of a NutritionRecord) or one
389
+ * weight. Dates are epoch milliseconds; an omitted range records now.
390
+ */
391
+ private suspend fun saveData(type: String, value: Double, options: JSONObject): Any {
392
+ if (!value.isFinite() || value < 0) throw invalid("saveData needs a finite, non-negative value")
393
+ if (type != "bodyMass" && type !in DIETARY_TYPES) throw invalid("saveData supports bodyMass and dietary types, not $type")
394
+ val start = options.instant("startDate") ?: Instant.now()
395
+ val end = options.instant("endDate") ?: start
396
+ if (end.isBefore(start)) throw invalid("saveData needs a valid date range")
397
+ val zone = ZoneId.systemDefault().rules
398
+
399
+ val record: Record = buildRecord {
400
+ if (type == "bodyMass") {
401
+ WeightRecord(
402
+ time = start,
403
+ zoneOffset = zone.getOffset(start),
404
+ weight = Mass.kilograms(value),
405
+ metadata = Metadata.manualEntry(),
406
+ )
407
+ } else {
408
+ // A nutrition record spans time; an instant entry gets a second.
409
+ val until = if (end.isAfter(start)) end else start.plusSeconds(1)
410
+ NutritionRecord(
411
+ startTime = start,
412
+ startZoneOffset = zone.getOffset(start),
413
+ endTime = until,
414
+ endZoneOffset = zone.getOffset(until),
415
+ metadata = Metadata.manualEntry(),
416
+ energy = if (type == "dietaryEnergy") Energy.kilocalories(value) else null,
417
+ protein = if (type == "dietaryProtein") Mass.grams(value) else null,
418
+ totalCarbohydrate = if (type == "dietaryCarbohydrates") Mass.grams(value) else null,
419
+ totalFat = if (type == "dietaryFat") Mass.grams(value) else null,
420
+ )
421
+ }
422
+ }
423
+ val response = client().insertRecords(listOf(record))
424
+ return JSONObject().put("id", response.recordIdsList.firstOrNull() ?: "")
425
+ }
426
+
427
+ // MARK: - Workouts
428
+
429
+ /**
430
+ * Saves a finished session as an exercise session, with its distance and
431
+ * active energy as their own records. Each carries the activity's id as
432
+ * its client record id, so saving the same activity again replaces it
433
+ * instead of adding a second.
434
+ */
435
+ private suspend fun saveWorkout(body: JSONObject): Any {
436
+ val activityId = body.opt("activityId") as? String
437
+ val type = body.opt("type") as? String
438
+ val start = body.instant("startDate")
439
+ val end = body.instant("endDate")
440
+ if (activityId.isNullOrEmpty() || type == null || start == null || end == null || !end.isAfter(start)) {
441
+ throw invalid("A valid activityId, type, startDate, and endDate are required")
442
+ }
443
+ val exerciseType = EXERCISE_TYPES[type] ?: throw invalid("Unsupported workout type: $type")
444
+ val zone = ZoneId.systemDefault().rules
445
+ val startOffset = zone.getOffset(start)
446
+ val endOffset = zone.getOffset(end)
447
+ // A later save of the same activity wins over an earlier one.
448
+ val version = System.currentTimeMillis()
449
+ val metadata = { suffix: String -> Metadata.activelyRecorded(phone(), clientRecordId = activityId + suffix, clientRecordVersion = version) }
450
+ val title = (body.opt("title") as? String)?.takeIf { it.isNotBlank() }
451
+
452
+ val records = buildRecord {
453
+ buildList<Record> {
454
+ add(ExerciseSessionRecord(
455
+ startTime = start,
456
+ startZoneOffset = startOffset,
457
+ endTime = end,
458
+ endZoneOffset = endOffset,
459
+ metadata = metadata(""),
460
+ exerciseType = exerciseType,
461
+ title = title,
462
+ ))
463
+ body.positive("distanceMeters")?.let { meters ->
464
+ add(DistanceRecord(start, startOffset, end, endOffset, Length.meters(meters), metadata("-distance")))
465
+ }
466
+ body.positive("activeEnergyCalories")?.let { kcal ->
467
+ add(ActiveCaloriesBurnedRecord(start, startOffset, end, endOffset, Energy.kilocalories(kcal), metadata("-energy")))
468
+ }
469
+ }
470
+ }
471
+ val response = client().insertRecords(records)
472
+ return JSONObject().put("id", response.recordIdsList.firstOrNull() ?: activityId)
473
+ }
474
+
475
+ /**
476
+ * Exercise sessions in Health Connect, from every app that writes there,
477
+ * newest first, with the numbers a training log needs. Distance, energy,
478
+ * heart rate and climb come from the records the same app wrote over the
479
+ * session's span; a number this app may not read is left out.
480
+ */
481
+ private suspend fun getWorkouts(options: JSONObject): Any {
482
+ val end = options.instant("endDate") ?: Instant.now()
483
+ val start = options.instant("startDate") ?: end.minus(Duration.ofDays(30))
484
+ val cap = (options.int("limit") ?: 200).coerceIn(1, 1000)
485
+ val client = client()
486
+ val sessions = readAll(client, ExerciseSessionRecord::class, range(start, end), ascending = false, cap = cap)
487
+
488
+ val granted = client.permissionController.getGrantedPermissions()
489
+ fun may(type: KClass<out Record>) = HealthPermission.getReadPermission(type) in granted
490
+
491
+ // A workout's numbers are the ones the app that recorded it wrote over
492
+ // its span (the watch's distance, energy and heart rate), read as
493
+ // records and summed here. Health Connect's aggregates would leave out
494
+ // an app that is not on its data-priority list, and a workout's own
495
+ // numbers are not a question of priority.
496
+ val oldest = sessions.minOfOrNull { it.startTime }
497
+ val newest = sessions.maxOfOrNull { it.endTime }
498
+ val span = if (oldest != null && newest != null && oldest.isBefore(newest)) TimeRangeFilter.between(oldest, newest) else null
499
+ val origins = sessions.map { DataOrigin(it.metadata.dataOrigin.packageName) }.toSet()
500
+ suspend fun <T : Record> numbers(type: KClass<T>): List<T> = if (span == null || !may(type)) emptyList() else try {
501
+ readAll(client, type, span, origins = origins)
502
+ } catch (error: CancellationException) {
503
+ throw error
504
+ } catch (error: Exception) {
505
+ // One kind of number failing (a revoked grant) leaves workouts without it, not the read.
506
+ android.util.Log.w("CraftNativeHealth", "Workout numbers could not be read", error)
507
+ emptyList()
508
+ }
509
+ val distances = numbers(DistanceRecord::class)
510
+ val energies = numbers(ActiveCaloriesBurnedRecord::class)
511
+ val beats = numbers(HeartRateRecord::class)
512
+ val climbs = numbers(ElevationGainedRecord::class)
513
+
514
+ val rows = JSONArray()
515
+ for (session in sessions) {
516
+ val origin = session.metadata.dataOrigin.packageName
517
+ val item = JSONObject()
518
+ .put("id", session.metadata.id)
519
+ .put("type", workoutTypeName(session.exerciseType))
520
+ .put("startDate", session.startTime.ms())
521
+ .put("endDate", session.endTime.ms())
522
+ .put("durationSeconds", Duration.between(session.startTime, session.endTime).toMillis() / 1000.0)
523
+ .put("sourceName", sourceName(origin))
524
+ .put("sourceBundleId", origin)
525
+ .put("indoor", session.exerciseType in INDOOR_EXERCISE_TYPES)
526
+ if (session.endTime.isAfter(session.startTime)) {
527
+ // The share of each interval record inside the session.
528
+ fun share(start: Instant, end: Instant): Double {
529
+ val from = maxOf(start, session.startTime)
530
+ val to = minOf(end, session.endTime)
531
+ if (!to.isAfter(from)) return 0.0
532
+ val whole = Duration.between(start, end).toMillis()
533
+ return if (whole <= 0) 1.0 else Duration.between(from, to).toMillis().toDouble() / whole
534
+ }
535
+ val mine = { record: Record -> record.metadata.dataOrigin.packageName == origin }
536
+ distances.filter(mine).sumOf { it.distance.inMeters * share(it.startTime, it.endTime) }
537
+ .takeIf { it > 0 }?.let { item.put("distanceMeters", it) }
538
+ energies.filter(mine).sumOf { it.energy.inKilocalories * share(it.startTime, it.endTime) }
539
+ .takeIf { it > 0 }?.let { item.put("activeEnergyCalories", it) }
540
+ climbs.filter(mine).sumOf { it.elevation.inMeters * share(it.startTime, it.endTime) }
541
+ .takeIf { it > 0 }?.let { item.put("elevationGainMeters", it) }
542
+ val bpm = beats.filter(mine).flatMap { record ->
543
+ record.samples.filter { !it.time.isBefore(session.startTime) && !it.time.isAfter(session.endTime) }
544
+ }.map { it.beatsPerMinute }
545
+ if (bpm.isNotEmpty()) {
546
+ item.put("averageHeartRate", bpm.average())
547
+ item.put("maxHeartRate", bpm.max().toDouble())
548
+ }
549
+ }
550
+ rows.put(item)
551
+ }
552
+ return rows
553
+ }
554
+
555
+ // MARK: - Daily values
556
+
557
+ /**
558
+ * One value per local day that has data: a sum for steps, energy,
559
+ * distance and food, an average for heart rate, resting heart rate and
560
+ * HRV, the latest weight, and hours asleep for sleep (counted on the day
561
+ * the night ended).
562
+ */
563
+ private suspend fun getDailyStatistics(type: String, options: JSONObject): Any {
564
+ if (type != "sleep" && unitOf(type) == null) throw invalid("Unknown health data type: $type")
565
+ val zone = ZoneId.systemDefault()
566
+ val end = options.instant("endDate") ?: Instant.now()
567
+ val firstDay = (options.instant("startDate") ?: end.minus(Duration.ofDays(30))).atZone(zone).toLocalDate()
568
+ val start = firstDay.atStartOfDay(zone).toInstant()
569
+ if (!start.isBefore(end)) return JSONArray()
570
+ val client = client()
571
+
572
+ return when (type) {
573
+ "sleep" -> dailySleep(client, start, end, firstDay, zone)
574
+ "heartRateVariability" -> {
575
+ val byDay = TreeMap<LocalDate, MutableList<Double>>()
576
+ for (record in readAll(client, HeartRateVariabilityRmssdRecord::class, range(start, end))) {
577
+ byDay.getOrPut(record.time.atZone(zone).toLocalDate()) { mutableListOf() } += record.heartRateVariabilityMillis
578
+ }
579
+ JSONArray(byDay.map { (day, values) -> dayRow(day, values.average(), "ms") })
580
+ }
581
+ "bodyMass" -> {
582
+ val byDay = TreeMap<LocalDate, WeightRecord>()
583
+ for (record in readAll(client, WeightRecord::class, range(start, end))) {
584
+ val day = record.time.atZone(zone).toLocalDate()
585
+ val kept = byDay[day]
586
+ if (kept == null || !record.time.isBefore(kept.time)) byDay[day] = record
587
+ }
588
+ JSONArray(byDay.map { (day, record) -> dayRow(day, record.weight.inKilograms, "kg") })
589
+ }
590
+ else -> {
591
+ val metric = aggregateMetric(type) ?: throw invalid("Unknown health data type: $type")
592
+ val unit = unitOf(type) ?: throw invalid("Unknown health data type: $type")
593
+ val groups = client.aggregateGroupByPeriod(
594
+ AggregateGroupByPeriodRequest(
595
+ metrics = setOf(metric),
596
+ timeRangeFilter = TimeRangeFilter.between(LocalDateTime.ofInstant(start, zone), LocalDateTime.ofInstant(end, zone)),
597
+ timeRangeSlicer = Period.ofDays(1),
598
+ ),
599
+ )
600
+ JSONArray(
601
+ groups.sortedBy { it.startTime }.mapNotNull { group ->
602
+ aggregateValue(group.result, type)?.let { dayRow(group.startTime.toLocalDate(), it, unit) }
603
+ },
604
+ )
605
+ }
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Hours asleep per local day, from the evening before the first day so its
611
+ * night counts. Awake, out of bed and awake in bed are not sleep; every
612
+ * other stage is. A session the source did not stage counts whole.
613
+ */
614
+ private suspend fun dailySleep(client: HealthConnectClient, start: Instant, end: Instant, firstDay: LocalDate, zone: ZoneId): Any {
615
+ val secondsByDay = TreeMap<LocalDate, Double>()
616
+ for (session in readAll(client, SleepSessionRecord::class, range(start.minus(Duration.ofHours(12)), end))) {
617
+ val seconds = asleepSeconds(session)
618
+ val day = session.endTime.atZone(zone).toLocalDate()
619
+ if (seconds <= 0 || day.isBefore(firstDay)) continue
620
+ secondsByDay[day] = (secondsByDay[day] ?: 0.0) + seconds
621
+ }
622
+ return JSONArray(secondsByDay.map { (day, seconds) -> dayRow(day, (seconds / 3600 * 100).roundToLong() / 100.0, "hr") })
623
+ }
624
+
625
+ private fun asleepSeconds(session: SleepSessionRecord): Double {
626
+ if (session.stages.isEmpty()) return Duration.between(session.startTime, session.endTime).toMillis() / 1000.0
627
+ return session.stages
628
+ .filter { it.stage !in AWAKE_STAGES }
629
+ .sumOf { Duration.between(it.startTime, it.endTime).toMillis() / 1000.0 }
630
+ }
631
+
632
+ private fun dayRow(day: LocalDate, value: Double, unit: String) = JSONObject()
633
+ .put("date", day.toString())
634
+ .put("value", value)
635
+ .put("unit", unit)
636
+
637
+ // MARK: - Heart rate and sleep intervals
638
+
639
+ /**
640
+ * Every heart-rate reading between two instants, oldest first: one row
641
+ * per reading in a record (Health Connect stores a watch's readings as a
642
+ * series), with the app and device that wrote it.
643
+ */
644
+ private suspend fun getHeartRateSamples(options: JSONObject): Any {
645
+ val end = options.instant("endDate") ?: Instant.now()
646
+ val start = options.instant("startDate") ?: end.minus(Duration.ofDays(1))
647
+ val cap = (options.int("limit") ?: 10_000).coerceIn(1, 50_000)
648
+ val records = readAll(client(), HeartRateRecord::class, range(start, end))
649
+
650
+ // A record that overlaps the range can hold readings outside it, and
651
+ // two apps' records can interleave, so the readings are sorted whole.
652
+ val readings = records
653
+ .flatMap { record -> record.samples.filter { !it.time.isBefore(start) && it.time.isBefore(end) }.map { record to it } }
654
+ .sortedBy { it.second.time }
655
+ .take(cap)
656
+
657
+ val rows = JSONArray()
658
+ for ((record, sample) in readings) {
659
+ val at = sample.time.ms()
660
+ val item = JSONObject()
661
+ .put("bpm", sample.beatsPerMinute.toDouble())
662
+ .put("startDate", at)
663
+ .put("endDate", at)
664
+ .put("sourceName", sourceName(record.metadata.dataOrigin.packageName))
665
+ putDevice(item, record.metadata.device, withModel = true)
666
+ rows.put(item)
667
+ }
668
+ return rows
669
+ }
670
+
671
+ /**
672
+ * Sleep between two instants, oldest first, as the sources wrote it: each
673
+ * stage with its name and whether it counts as asleep. A session with no
674
+ * stages is one asleep stretch.
675
+ */
676
+ private suspend fun getSleepIntervals(options: JSONObject): Any {
677
+ val end = options.instant("endDate") ?: Instant.now()
678
+ val start = options.instant("startDate") ?: end.minus(Duration.ofDays(1))
679
+ val sessions = readAll(client(), SleepSessionRecord::class, range(start, end))
680
+
681
+ val rows = mutableListOf<JSONObject>()
682
+ for (session in sessions) {
683
+ val source = sourceName(session.metadata.dataOrigin.packageName)
684
+ val row = { from: Instant, until: Instant, stage: String, asleep: Boolean ->
685
+ JSONObject()
686
+ .put("startDate", from.ms())
687
+ .put("endDate", until.ms())
688
+ .put("stage", stage)
689
+ .put("asleep", asleep)
690
+ .put("sourceName", source)
691
+ .also { putDevice(it, session.metadata.device, withModel = false) }
692
+ }
693
+ if (session.stages.isEmpty()) {
694
+ rows += row(session.startTime, session.endTime, "asleep", true)
695
+ } else {
696
+ for (stage in session.stages) {
697
+ val awake = stage.stage in AWAKE_STAGES
698
+ rows += row(stage.startTime, stage.endTime, sleepStageName(stage.stage), !awake)
699
+ }
700
+ }
701
+ }
702
+ return JSONArray(rows.sortedBy { it.getDouble("startDate") })
703
+ }
704
+
705
+ // MARK: - Mindful sessions
706
+
707
+ /**
708
+ * Saves a mindfulness session: a meditation or breathing session that ran
709
+ * from `startDate` to `endDate`, epoch milliseconds. `id` is the app's own
710
+ * id for it, kept as the record's client record id, so saving the same
711
+ * session twice keeps the first record: the answer then says
712
+ * `existing: true`.
713
+ */
714
+ private suspend fun saveMindfulSession(body: JSONObject): Any {
715
+ val client = client()
716
+ if (!mindfulnessAvailable(client)) throw HealthFailure("UNSUPPORTED", "This Health Connect has no mindfulness sessions")
717
+ val id = body.opt("id") as? String
718
+ val start = body.instant("startDate")
719
+ val end = body.instant("endDate")
720
+ if (id.isNullOrEmpty() || id.length > 128 || start == null || end == null ||
721
+ !end.isAfter(start) || Duration.between(start, end) > Duration.ofHours(24)
722
+ ) {
723
+ throw invalid("A valid id, startDate and endDate (at most a day apart) are required")
724
+ }
725
+
726
+ // Only this app's own records can be the same session.
727
+ val kept = readAll(
728
+ client,
729
+ MindfulnessSessionRecord::class,
730
+ TimeRangeFilter.between(start.minus(Duration.ofDays(1)), end.plus(Duration.ofDays(1))),
731
+ origins = setOf(ownOrigin),
732
+ ).firstOrNull { it.metadata.clientRecordId == id }
733
+ if (kept != null) {
734
+ return JSONObject().put("id", kept.metadata.id).put("externalId", id).put("existing", true)
735
+ }
736
+
737
+ val zone = ZoneId.systemDefault().rules
738
+ val record = buildRecord {
739
+ MindfulnessSessionRecord(
740
+ startTime = start,
741
+ startZoneOffset = zone.getOffset(start),
742
+ endTime = end,
743
+ endZoneOffset = zone.getOffset(end),
744
+ metadata = Metadata.activelyRecorded(phone(), clientRecordId = id, clientRecordVersion = 0L),
745
+ mindfulnessSessionType = MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_BREATHING,
746
+ )
747
+ }
748
+ val response = client.insertRecords(listOf(record))
749
+ return JSONObject().put("id", response.recordIdsList.firstOrNull() ?: id).put("externalId", id).put("existing", false)
750
+ }
751
+
752
+ /**
753
+ * Mindfulness sessions between two instants, oldest first, from every app
754
+ * that writes them. Each says whether this app wrote it (`ownApp`) and
755
+ * carries the client record id it was saved with, so a page can tell its
756
+ * own sessions from the rest. Nothing where Health Connect has none.
757
+ */
758
+ private suspend fun getMindfulSessions(options: JSONObject): Any {
759
+ val client = client()
760
+ if (!mindfulnessAvailable(client)) return JSONArray()
761
+ val end = options.instant("endDate") ?: Instant.now()
762
+ val start = options.instant("startDate") ?: end.minus(Duration.ofDays(30))
763
+ val cap = (options.int("limit") ?: 1000).coerceIn(1, 5000)
764
+
765
+ val rows = JSONArray()
766
+ for (session in readAll(client, MindfulnessSessionRecord::class, range(start, end), cap = cap)) {
767
+ val origin = session.metadata.dataOrigin.packageName
768
+ val item = JSONObject()
769
+ .put("id", session.metadata.id)
770
+ .put("startDate", session.startTime.ms())
771
+ .put("endDate", session.endTime.ms())
772
+ .put("durationSeconds", Duration.between(session.startTime, session.endTime).toMillis() / 1000.0)
773
+ .put("sourceName", sourceName(origin))
774
+ .put("sourceBundleId", origin)
775
+ .put("ownApp", origin == context.packageName)
776
+ session.metadata.clientRecordId?.let { item.put("externalId", it) }
777
+ putDevice(item, session.metadata.device, withModel = false)
778
+ rows.put(item)
779
+ }
780
+ return rows
781
+ }
782
+
783
+ // MARK: - Helpers
784
+
785
+ /** Every record of a type in a range, page by page, up to `cap`. */
786
+ private suspend fun <T : Record> readAll(
787
+ client: HealthConnectClient,
788
+ type: KClass<T>,
789
+ filter: TimeRangeFilter,
790
+ ascending: Boolean = true,
791
+ origins: Set<DataOrigin> = emptySet(),
792
+ cap: Int = Int.MAX_VALUE,
793
+ ): List<T> {
794
+ val records = ArrayList<T>()
795
+ var token: String? = null
796
+ do {
797
+ val response = client.readRecords(
798
+ ReadRecordsRequest(
799
+ recordType = type,
800
+ timeRangeFilter = filter,
801
+ dataOriginFilter = origins,
802
+ ascendingOrder = ascending,
803
+ pageSize = PAGE_SIZE,
804
+ pageToken = token,
805
+ ),
806
+ )
807
+ records += response.records
808
+ token = response.pageToken?.takeIf { it.isNotEmpty() }
809
+ } while (token != null && records.size < cap)
810
+ return if (records.size > cap) records.subList(0, cap) else records
811
+ }
812
+
813
+ private fun range(start: Instant, end: Instant): TimeRangeFilter {
814
+ if (!start.isBefore(end)) throw invalid("startDate must be before endDate")
815
+ return TimeRangeFilter.between(start, end)
816
+ }
817
+
818
+ /** A record's constructor checks its values (a weight over 1000 kg, a negative distance): that is the caller's argument. */
819
+ private inline fun <T> buildRecord(build: () -> T): T = try {
820
+ build()
821
+ } catch (error: IllegalArgumentException) {
822
+ throw invalid(error.message ?: "Health data is out of range")
823
+ }
824
+
825
+ /** This phone, as the device a session timed in the app was recorded on. */
826
+ private fun phone() = Device(type = Device.TYPE_PHONE, manufacturer = Build.MANUFACTURER, model = Build.MODEL)
827
+
828
+ /**
829
+ * The app that wrote a record, by its label. This app's own writes come
830
+ * back under its own label, which a page compares against to skip them.
831
+ * Another app's label needs package visibility (a <queries> entry);
832
+ * without it the package name stands in.
833
+ */
834
+ private fun sourceName(packageName: String): String = labels.getOrPut(packageName) {
835
+ val packageManager = context.packageManager
836
+ if (packageName == context.packageName) {
837
+ context.applicationInfo.loadLabel(packageManager).toString()
838
+ } else {
839
+ try {
840
+ val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
841
+ packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
842
+ } else {
843
+ @Suppress("DEPRECATION")
844
+ packageManager.getApplicationInfo(packageName, 0)
845
+ }
846
+ info.loadLabel(packageManager).toString()
847
+ } catch (error: PackageManager.NameNotFoundException) {
848
+ packageName
849
+ }
850
+ }
851
+ }
852
+
853
+ private fun putDevice(item: JSONObject, device: Device?, withModel: Boolean) {
854
+ if (device == null) return
855
+ val name = listOfNotNull(device.manufacturer, device.model).filter { it.isNotBlank() }.joinToString(" ")
856
+ if (name.isNotEmpty()) item.put("deviceName", name)
857
+ if (withModel) device.model?.takeIf { it.isNotBlank() }?.let { item.put("deviceModel", it) }
858
+ }
859
+
860
+ private fun Instant.ms(): Double = toEpochMilli().toDouble()
861
+
862
+ private fun JSONObject.number(key: String): Double? = (opt(key) as? Number)?.toDouble()?.takeIf { it.isFinite() }
863
+
864
+ private fun JSONObject.instant(key: String): Instant? = number(key)?.let { Instant.ofEpochMilli(it.toLong()) }
865
+
866
+ private fun JSONObject.int(key: String): Int? = number(key)?.toInt()
867
+
868
+ private fun JSONObject.positive(key: String): Double? = number(key)?.takeIf { it > 0 }
869
+
870
+ private fun List<Double>.averageOrNull(): Double? = if (isEmpty()) null else average()
871
+
872
+ companion object {
873
+ const val PERMISSION_REQUEST_CODE = 8411
874
+
875
+ /** Health Connect's own package: the Play Store app before Android 14, the system module after. */
876
+ private const val PROVIDER_PACKAGE = "com.google.android.apps.healthdata"
877
+ private const val PAGE_SIZE = 1000
878
+
879
+ /** Every type the bridge knows, in the order `grantedTypes` lists them. */
880
+ private val TYPES = listOf(
881
+ "steps", "heartRate", "activeEnergy", "distance", "workouts", "restingHeartRate",
882
+ "heartRateVariability", "bodyMass", "sleep", "mindful",
883
+ "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat",
884
+ )
885
+ private val DIETARY_TYPES = setOf("dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat")
886
+
887
+ private val AWAKE_STAGES = setOf(
888
+ SleepSessionRecord.STAGE_TYPE_AWAKE,
889
+ SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED,
890
+ SleepSessionRecord.STAGE_TYPE_OUT_OF_BED,
891
+ )
892
+
893
+ private val INDOOR_EXERCISE_TYPES = setOf(
894
+ ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL,
895
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
896
+ ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
897
+ ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE,
898
+ ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL,
899
+ ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE,
900
+ )
901
+
902
+ /** The bridge's workout names, as the exercise each is written as. */
903
+ private val EXERCISE_TYPES = mapOf(
904
+ "running" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
905
+ "walking" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING,
906
+ "hiking" to ExerciseSessionRecord.EXERCISE_TYPE_HIKING,
907
+ "cycling" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
908
+ "swimming" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER,
909
+ "strength" to ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
910
+ "hiit" to ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING,
911
+ "yoga" to ExerciseSessionRecord.EXERCISE_TYPE_YOGA,
912
+ "pilates" to ExerciseSessionRecord.EXERCISE_TYPE_PILATES,
913
+ "rowing" to ExerciseSessionRecord.EXERCISE_TYPE_ROWING,
914
+ "elliptical" to ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL,
915
+ "stairClimbing" to ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING,
916
+ "crossTraining" to ExerciseSessionRecord.EXERCISE_TYPE_BOOT_CAMP,
917
+ "mobility" to ExerciseSessionRecord.EXERCISE_TYPE_STRETCHING,
918
+ "other" to ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
919
+ )
920
+
921
+ /**
922
+ * A stable name for an exercise type, the same names CraftHealth.swift
923
+ * gives HealthKit's, so a page maps one list to its own sports.
924
+ */
925
+ private fun workoutTypeName(type: Int): String = when (type) {
926
+ ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
927
+ ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL -> "running"
928
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
929
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY -> "cycling"
930
+ ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "walking"
931
+ ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "hiking"
932
+ ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
933
+ ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER -> "swimming"
934
+ ExerciseSessionRecord.EXERCISE_TYPE_ROWING,
935
+ ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE -> "rowing"
936
+ ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL -> "elliptical"
937
+ ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING,
938
+ ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE -> "stairClimbing"
939
+ ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "yoga"
940
+ ExerciseSessionRecord.EXERCISE_TYPE_PILATES -> "pilates"
941
+ ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
942
+ ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING,
943
+ ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS -> "strength"
944
+ ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING -> "hiit"
945
+ ExerciseSessionRecord.EXERCISE_TYPE_BOOT_CAMP,
946
+ ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS -> "crossTraining"
947
+ ExerciseSessionRecord.EXERCISE_TYPE_SKIING,
948
+ ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING -> "skiing"
949
+ ExerciseSessionRecord.EXERCISE_TYPE_PADDLING -> "paddling"
950
+ ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING -> "climbing"
951
+ ExerciseSessionRecord.EXERCISE_TYPE_DANCING -> "dance"
952
+ ExerciseSessionRecord.EXERCISE_TYPE_STRETCHING,
953
+ ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING -> "mobility"
954
+ else -> "other"
955
+ }
956
+
957
+ /** The same stage names CraftHealth.swift gives HealthKit's sleep values. */
958
+ private fun sleepStageName(stage: Int): String = when (stage) {
959
+ SleepSessionRecord.STAGE_TYPE_LIGHT -> "core"
960
+ SleepSessionRecord.STAGE_TYPE_DEEP -> "deep"
961
+ SleepSessionRecord.STAGE_TYPE_REM -> "rem"
962
+ in AWAKE_STAGES -> "awake"
963
+ else -> "asleep"
964
+ }
965
+
966
+ /** The unit each quantity type answers in, as HealthKit spells it; null for a type with no single number. */
967
+ private fun unitOf(type: String): String? = when (type) {
968
+ "steps" -> "count"
969
+ "activeEnergy", "dietaryEnergy" -> "kcal"
970
+ "distance" -> "m"
971
+ "heartRate", "restingHeartRate" -> "count/min"
972
+ "heartRateVariability" -> "ms"
973
+ "bodyMass" -> "kg"
974
+ "dietaryProtein", "dietaryCarbohydrates", "dietaryFat" -> "g"
975
+ else -> null
976
+ }
977
+
978
+ /** The aggregate each summed or averaged type is read with. HRV and weight have none and are read as records. */
979
+ private fun aggregateMetric(type: String): AggregateMetric<*>? = when (type) {
980
+ "steps" -> StepsRecord.COUNT_TOTAL
981
+ "activeEnergy" -> ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL
982
+ "distance" -> DistanceRecord.DISTANCE_TOTAL
983
+ "heartRate" -> HeartRateRecord.BPM_AVG
984
+ "restingHeartRate" -> RestingHeartRateRecord.BPM_AVG
985
+ "dietaryEnergy" -> NutritionRecord.ENERGY_TOTAL
986
+ "dietaryProtein" -> NutritionRecord.PROTEIN_TOTAL
987
+ "dietaryCarbohydrates" -> NutritionRecord.TOTAL_CARBOHYDRATE_TOTAL
988
+ "dietaryFat" -> NutritionRecord.TOTAL_FAT_TOTAL
989
+ else -> null
990
+ }
991
+
992
+ /** An aggregate's value in the type's unit, or null when there was no data. */
993
+ private fun aggregateValue(result: AggregationResult, type: String): Double? = when (type) {
994
+ "steps" -> result[StepsRecord.COUNT_TOTAL]?.toDouble()
995
+ "activeEnergy" -> result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
996
+ "distance" -> result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
997
+ "heartRate" -> result[HeartRateRecord.BPM_AVG]?.toDouble()
998
+ "restingHeartRate" -> result[RestingHeartRateRecord.BPM_AVG]?.toDouble()
999
+ "dietaryEnergy" -> result[NutritionRecord.ENERGY_TOTAL]?.inKilocalories
1000
+ "dietaryProtein" -> result[NutritionRecord.PROTEIN_TOTAL]?.inGrams
1001
+ "dietaryCarbohydrates" -> result[NutritionRecord.TOTAL_CARBOHYDRATE_TOTAL]?.inGrams
1002
+ "dietaryFat" -> result[NutritionRecord.TOTAL_FAT_TOTAL]?.inGrams
1003
+ else -> null
1004
+ }
1005
+
1006
+ /**
1007
+ * Every Health Connect permission string a type needs, for read and
1008
+ * for write; the builder's manifest list must match this. A workout's
1009
+ * numbers live in their own records, so reading workouts reads those
1010
+ * too. Heart rate variability is RMSSD (Apple's is SDNN). Unknown
1011
+ * types need nothing.
1012
+ */
1013
+ fun readPermissions(type: String): Set<String> = when (type) {
1014
+ "steps" -> setOf(HealthPermission.getReadPermission(StepsRecord::class))
1015
+ "heartRate" -> setOf(HealthPermission.getReadPermission(HeartRateRecord::class))
1016
+ "activeEnergy" -> setOf(HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class))
1017
+ "distance" -> setOf(HealthPermission.getReadPermission(DistanceRecord::class))
1018
+ "workouts" -> setOf(
1019
+ HealthPermission.getReadPermission(ExerciseSessionRecord::class),
1020
+ HealthPermission.getReadPermission(HeartRateRecord::class),
1021
+ HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
1022
+ HealthPermission.getReadPermission(DistanceRecord::class),
1023
+ )
1024
+ "restingHeartRate" -> setOf(HealthPermission.getReadPermission(RestingHeartRateRecord::class))
1025
+ "heartRateVariability" -> setOf(HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class))
1026
+ "bodyMass" -> setOf(HealthPermission.getReadPermission(WeightRecord::class))
1027
+ "sleep" -> setOf(HealthPermission.getReadPermission(SleepSessionRecord::class))
1028
+ "mindful" -> setOf(HealthPermission.getReadPermission(MindfulnessSessionRecord::class))
1029
+ in DIETARY_TYPES -> setOf(HealthPermission.getReadPermission(NutritionRecord::class))
1030
+ else -> emptySet()
1031
+ }
1032
+
1033
+ fun writePermissions(type: String): Set<String> = when (type) {
1034
+ "activeEnergy" -> setOf(HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class))
1035
+ "distance" -> setOf(HealthPermission.getWritePermission(DistanceRecord::class))
1036
+ "workouts" -> setOf(
1037
+ HealthPermission.getWritePermission(ExerciseSessionRecord::class),
1038
+ HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class),
1039
+ HealthPermission.getWritePermission(DistanceRecord::class),
1040
+ )
1041
+ "mindful" -> setOf(HealthPermission.getWritePermission(MindfulnessSessionRecord::class))
1042
+ in DIETARY_TYPES -> setOf(HealthPermission.getWritePermission(NutritionRecord::class))
1043
+ else -> emptySet()
1044
+ }
1045
+ }
1046
+ }