@flomentumsolutions/capacitor-health-extended 0.1.0 → 0.4.1
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.
- package/{FlomentumsolutionsCapacitorHealthExtended.podspec → FlomentumSolutionsCapacitorHealthExtended.podspec} +6 -6
- package/LICENSE +1 -0
- package/Package.swift +27 -0
- package/README.md +123 -24
- package/android/build.gradle +15 -10
- package/android/src/main/AndroidManifest.xml +1 -1
- package/android/src/main/java/com/flomentum/health/capacitor/HealthPlugin.kt +732 -45
- package/android/src/main/java/com/flomentum/health/capacitor/PermissionsRationaleActivity.kt +1 -1
- package/dist/esm/definitions.d.ts +10 -6
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPluginPlugin/HealthPlugin.swift +544 -90
- package/package.json +40 -17
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package com.
|
|
1
|
+
package com.flomentumsolutions.health.capacitor
|
|
2
2
|
|
|
3
3
|
import android.content.Intent
|
|
4
4
|
import android.util.Log
|
|
@@ -14,6 +14,7 @@ import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
|
|
|
14
14
|
import androidx.health.connect.client.request.AggregateRequest
|
|
15
15
|
import androidx.health.connect.client.request.ReadRecordsRequest
|
|
16
16
|
import androidx.health.connect.client.time.TimeRangeFilter
|
|
17
|
+
import androidx.health.connect.client.records.Record
|
|
17
18
|
import com.getcapacitor.JSArray
|
|
18
19
|
import com.getcapacitor.JSObject
|
|
19
20
|
import com.getcapacitor.Plugin
|
|
@@ -25,21 +26,44 @@ import kotlinx.coroutines.CoroutineScope
|
|
|
25
26
|
import kotlinx.coroutines.Dispatchers
|
|
26
27
|
import kotlinx.coroutines.launch
|
|
27
28
|
import java.time.Instant
|
|
29
|
+
import java.time.LocalDate
|
|
28
30
|
import java.time.LocalDateTime
|
|
29
31
|
import java.time.Period
|
|
30
32
|
import java.time.ZoneId
|
|
31
33
|
import java.util.concurrent.atomic.AtomicReference
|
|
32
34
|
import androidx.core.net.toUri
|
|
35
|
+
import kotlin.reflect.KClass
|
|
33
36
|
|
|
34
37
|
enum class CapHealthPermission {
|
|
35
|
-
READ_STEPS,
|
|
36
|
-
,
|
|
38
|
+
READ_STEPS,
|
|
39
|
+
READ_WORKOUTS,
|
|
40
|
+
READ_EXERCISE_TIME,
|
|
41
|
+
READ_HEART_RATE,
|
|
42
|
+
READ_RESTING_HEART_RATE,
|
|
43
|
+
READ_ACTIVE_CALORIES,
|
|
44
|
+
READ_TOTAL_CALORIES,
|
|
45
|
+
READ_DISTANCE,
|
|
46
|
+
READ_WEIGHT,
|
|
47
|
+
READ_HRV,
|
|
48
|
+
READ_BLOOD_PRESSURE,
|
|
49
|
+
READ_HEIGHT,
|
|
50
|
+
READ_ROUTE,
|
|
51
|
+
READ_MINDFULNESS,
|
|
52
|
+
READ_RESPIRATORY_RATE,
|
|
53
|
+
READ_OXYGEN_SATURATION,
|
|
54
|
+
READ_BLOOD_GLUCOSE,
|
|
55
|
+
READ_BODY_TEMPERATURE,
|
|
56
|
+
READ_BASAL_BODY_TEMPERATURE,
|
|
57
|
+
READ_BODY_FAT,
|
|
58
|
+
READ_FLOORS_CLIMBED,
|
|
59
|
+
READ_BASAL_CALORIES,
|
|
60
|
+
READ_SLEEP;
|
|
37
61
|
|
|
38
62
|
companion object {
|
|
39
63
|
fun from(s: String): CapHealthPermission? {
|
|
40
64
|
return try {
|
|
41
65
|
CapHealthPermission.valueOf(s)
|
|
42
|
-
} catch (
|
|
66
|
+
} catch (_: Exception) {
|
|
43
67
|
null
|
|
44
68
|
}
|
|
45
69
|
}
|
|
@@ -53,14 +77,25 @@ enum class CapHealthPermission {
|
|
|
53
77
|
Permission(alias = "READ_WEIGHT", strings = ["android.permission.health.READ_WEIGHT"]),
|
|
54
78
|
Permission(alias = "READ_HEIGHT", strings = ["android.permission.health.READ_HEIGHT"]),
|
|
55
79
|
Permission(alias = "READ_WORKOUTS", strings = ["android.permission.health.READ_EXERCISE"]),
|
|
80
|
+
Permission(alias = "READ_EXERCISE_TIME", strings = ["android.permission.health.READ_EXERCISE"]),
|
|
56
81
|
Permission(alias = "READ_DISTANCE", strings = ["android.permission.health.READ_DISTANCE"]),
|
|
57
82
|
Permission(alias = "READ_ACTIVE_CALORIES", strings = ["android.permission.health.READ_ACTIVE_CALORIES_BURNED"]),
|
|
58
83
|
Permission(alias = "READ_TOTAL_CALORIES", strings = ["android.permission.health.READ_TOTAL_CALORIES_BURNED"]),
|
|
59
84
|
Permission(alias = "READ_HEART_RATE", strings = ["android.permission.health.READ_HEART_RATE"]),
|
|
85
|
+
Permission(alias = "READ_RESTING_HEART_RATE", strings = ["android.permission.health.READ_RESTING_HEART_RATE"]),
|
|
60
86
|
Permission(alias = "READ_HRV", strings = ["android.permission.health.READ_HEART_RATE_VARIABILITY"]),
|
|
61
87
|
Permission(alias = "READ_BLOOD_PRESSURE", strings = ["android.permission.health.READ_BLOOD_PRESSURE"]),
|
|
62
88
|
Permission(alias = "READ_ROUTE", strings = ["android.permission.health.READ_EXERCISE"]),
|
|
63
|
-
Permission(alias = "READ_MINDFULNESS", strings = ["android.permission.health.
|
|
89
|
+
Permission(alias = "READ_MINDFULNESS", strings = ["android.permission.health.READ_MINDFULNESS"]),
|
|
90
|
+
Permission(alias = "READ_RESPIRATORY_RATE", strings = ["android.permission.health.READ_RESPIRATORY_RATE"]),
|
|
91
|
+
Permission(alias = "READ_OXYGEN_SATURATION", strings = ["android.permission.health.READ_OXYGEN_SATURATION"]),
|
|
92
|
+
Permission(alias = "READ_BLOOD_GLUCOSE", strings = ["android.permission.health.READ_BLOOD_GLUCOSE"]),
|
|
93
|
+
Permission(alias = "READ_BODY_TEMPERATURE", strings = ["android.permission.health.READ_BODY_TEMPERATURE"]),
|
|
94
|
+
Permission(alias = "READ_BASAL_BODY_TEMPERATURE", strings = ["android.permission.health.READ_BASAL_BODY_TEMPERATURE"]),
|
|
95
|
+
Permission(alias = "READ_BODY_FAT", strings = ["android.permission.health.READ_BODY_FAT"]),
|
|
96
|
+
Permission(alias = "READ_FLOORS_CLIMBED", strings = ["android.permission.health.READ_FLOORS_CLIMBED"]),
|
|
97
|
+
Permission(alias = "READ_BASAL_CALORIES", strings = ["android.permission.health.READ_BASAL_METABOLIC_RATE"]),
|
|
98
|
+
Permission(alias = "READ_SLEEP", strings = ["android.permission.health.READ_SLEEP"])
|
|
64
99
|
]
|
|
65
100
|
)
|
|
66
101
|
|
|
@@ -83,10 +118,21 @@ class HealthPlugin : Plugin() {
|
|
|
83
118
|
CapHealthPermission.READ_TOTAL_CALORIES to HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
|
|
84
119
|
CapHealthPermission.READ_DISTANCE to HealthPermission.getReadPermission(DistanceRecord::class),
|
|
85
120
|
CapHealthPermission.READ_WORKOUTS to HealthPermission.getReadPermission(ExerciseSessionRecord::class),
|
|
121
|
+
CapHealthPermission.READ_EXERCISE_TIME to HealthPermission.getReadPermission(ExerciseSessionRecord::class),
|
|
86
122
|
CapHealthPermission.READ_HRV to HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class),
|
|
87
123
|
CapHealthPermission.READ_BLOOD_PRESSURE to HealthPermission.getReadPermission(BloodPressureRecord::class),
|
|
88
124
|
CapHealthPermission.READ_ROUTE to HealthPermission.getReadPermission(ExerciseSessionRecord::class),
|
|
89
|
-
CapHealthPermission.READ_MINDFULNESS to HealthPermission.getReadPermission(
|
|
125
|
+
CapHealthPermission.READ_MINDFULNESS to HealthPermission.getReadPermission(MindfulnessSessionRecord::class),
|
|
126
|
+
CapHealthPermission.READ_RESTING_HEART_RATE to HealthPermission.getReadPermission(RestingHeartRateRecord::class),
|
|
127
|
+
CapHealthPermission.READ_RESPIRATORY_RATE to HealthPermission.getReadPermission(RespiratoryRateRecord::class),
|
|
128
|
+
CapHealthPermission.READ_OXYGEN_SATURATION to HealthPermission.getReadPermission(OxygenSaturationRecord::class),
|
|
129
|
+
CapHealthPermission.READ_BLOOD_GLUCOSE to HealthPermission.getReadPermission(BloodGlucoseRecord::class),
|
|
130
|
+
CapHealthPermission.READ_BODY_TEMPERATURE to HealthPermission.getReadPermission(BodyTemperatureRecord::class),
|
|
131
|
+
CapHealthPermission.READ_BASAL_BODY_TEMPERATURE to HealthPermission.getReadPermission(BasalBodyTemperatureRecord::class),
|
|
132
|
+
CapHealthPermission.READ_BODY_FAT to HealthPermission.getReadPermission(BodyFatRecord::class),
|
|
133
|
+
CapHealthPermission.READ_FLOORS_CLIMBED to HealthPermission.getReadPermission(FloorsClimbedRecord::class),
|
|
134
|
+
CapHealthPermission.READ_BASAL_CALORIES to HealthPermission.getReadPermission(BasalMetabolicRateRecord::class),
|
|
135
|
+
CapHealthPermission.READ_SLEEP to HealthPermission.getReadPermission(SleepSessionRecord::class)
|
|
90
136
|
)
|
|
91
137
|
|
|
92
138
|
override fun load() {
|
|
@@ -270,6 +316,11 @@ class HealthPlugin : Plugin() {
|
|
|
270
316
|
private fun getMetricAndMapper(dataType: String): MetricAndMapper {
|
|
271
317
|
return when (dataType) {
|
|
272
318
|
"steps" -> metricAndMapper("steps", CapHealthPermission.READ_STEPS, StepsRecord.COUNT_TOTAL) { it?.toDouble() }
|
|
319
|
+
"heart-rate", "heartRate" -> metricAndMapper(
|
|
320
|
+
"heartRate",
|
|
321
|
+
CapHealthPermission.READ_HEART_RATE,
|
|
322
|
+
HeartRateRecord.BPM_AVG
|
|
323
|
+
) { it?.toDouble() }
|
|
273
324
|
"active-calories", "activeCalories" -> metricAndMapper(
|
|
274
325
|
"calories",
|
|
275
326
|
CapHealthPermission.READ_ACTIVE_CALORIES,
|
|
@@ -281,6 +332,26 @@ class HealthPlugin : Plugin() {
|
|
|
281
332
|
TotalCaloriesBurnedRecord.ENERGY_TOTAL
|
|
282
333
|
) { it?.inKilocalories }
|
|
283
334
|
"distance" -> metricAndMapper("distance", CapHealthPermission.READ_DISTANCE, DistanceRecord.DISTANCE_TOTAL) { it?.inMeters }
|
|
335
|
+
"flights-climbed" -> metricAndMapper(
|
|
336
|
+
"flightsClimbed",
|
|
337
|
+
CapHealthPermission.READ_FLOORS_CLIMBED,
|
|
338
|
+
FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL
|
|
339
|
+
) { it }
|
|
340
|
+
"mindfulness" -> metricAndMapper(
|
|
341
|
+
"mindfulness",
|
|
342
|
+
CapHealthPermission.READ_MINDFULNESS,
|
|
343
|
+
MindfulnessSessionRecord.MINDFULNESS_DURATION_TOTAL
|
|
344
|
+
) { it?.seconds?.toDouble() }
|
|
345
|
+
"basal-calories" -> metricAndMapper(
|
|
346
|
+
"basalCalories",
|
|
347
|
+
CapHealthPermission.READ_BASAL_CALORIES,
|
|
348
|
+
BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL
|
|
349
|
+
) { it?.inKilocalories }
|
|
350
|
+
"resting-heart-rate" -> metricAndMapper(
|
|
351
|
+
"restingHeartRate",
|
|
352
|
+
CapHealthPermission.READ_RESTING_HEART_RATE,
|
|
353
|
+
RestingHeartRateRecord.BPM_AVG
|
|
354
|
+
) { it?.toDouble() }
|
|
284
355
|
else -> throw RuntimeException("Unsupported dataType: $dataType")
|
|
285
356
|
}
|
|
286
357
|
}
|
|
@@ -302,14 +373,27 @@ class HealthPlugin : Plugin() {
|
|
|
302
373
|
try {
|
|
303
374
|
val result = when (dataType) {
|
|
304
375
|
"heart-rate", "heartRate" -> readLatestHeartRate()
|
|
376
|
+
"resting-heart-rate" -> readLatestRestingHeartRate()
|
|
305
377
|
"weight" -> readLatestWeight()
|
|
306
378
|
"height" -> readLatestHeight()
|
|
307
379
|
"steps" -> readLatestSteps()
|
|
308
380
|
"hrv" -> readLatestHrv()
|
|
309
381
|
"blood-pressure" -> readLatestBloodPressure()
|
|
310
382
|
"distance" -> readLatestDistance()
|
|
383
|
+
"distance-cycling" -> readLatestDistance()
|
|
311
384
|
"active-calories" -> readLatestActiveCalories()
|
|
312
385
|
"total-calories" -> readLatestTotalCalories()
|
|
386
|
+
"basal-calories" -> readLatestBasalCalories()
|
|
387
|
+
"respiratory-rate" -> readLatestRespiratoryRate()
|
|
388
|
+
"oxygen-saturation" -> readLatestOxygenSaturation()
|
|
389
|
+
"blood-glucose" -> readLatestBloodGlucose()
|
|
390
|
+
"body-temperature" -> readLatestBodyTemperature()
|
|
391
|
+
"basal-body-temperature" -> readLatestBasalBodyTemperature()
|
|
392
|
+
"body-fat" -> readLatestBodyFat()
|
|
393
|
+
"flights-climbed" -> readLatestFlightsClimbed()
|
|
394
|
+
"exercise-time" -> readLatestExerciseTime()
|
|
395
|
+
"mindfulness" -> readLatestMindfulness()
|
|
396
|
+
"sleep" -> readLatestSleep()
|
|
313
397
|
else -> throw IllegalArgumentException("Unsupported data type: $dataType")
|
|
314
398
|
}
|
|
315
399
|
call.resolve(result)
|
|
@@ -368,13 +452,14 @@ class HealthPlugin : Plugin() {
|
|
|
368
452
|
val request = ReadRecordsRequest(
|
|
369
453
|
recordType = WeightRecord::class,
|
|
370
454
|
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
455
|
+
ascendingOrder = false,
|
|
371
456
|
pageSize = 1
|
|
372
457
|
)
|
|
373
458
|
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
374
459
|
|
|
375
460
|
return JSObject().apply {
|
|
376
|
-
put("value", record?.weight?.inKilograms
|
|
377
|
-
put("timestamp",
|
|
461
|
+
put("value", record?.weight?.inKilograms)
|
|
462
|
+
put("timestamp", record?.time?.toEpochMilli())
|
|
378
463
|
put("unit", "kg")
|
|
379
464
|
}
|
|
380
465
|
}
|
|
@@ -440,13 +525,14 @@ class HealthPlugin : Plugin() {
|
|
|
440
525
|
val request = ReadRecordsRequest(
|
|
441
526
|
recordType = HeightRecord::class,
|
|
442
527
|
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
528
|
+
ascendingOrder = false,
|
|
443
529
|
pageSize = 1
|
|
444
530
|
)
|
|
445
531
|
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
446
532
|
|
|
447
533
|
return JSObject().apply {
|
|
448
|
-
put("value", record?.height?.inMeters
|
|
449
|
-
put("timestamp",
|
|
534
|
+
put("value", record?.height?.inMeters)
|
|
535
|
+
put("timestamp", record?.time?.toEpochMilli())
|
|
450
536
|
put("unit", "m")
|
|
451
537
|
}
|
|
452
538
|
}
|
|
@@ -485,6 +571,239 @@ class HealthPlugin : Plugin() {
|
|
|
485
571
|
}
|
|
486
572
|
}
|
|
487
573
|
|
|
574
|
+
private suspend fun readLatestRestingHeartRate(): JSObject {
|
|
575
|
+
if (!hasPermission(CapHealthPermission.READ_RESTING_HEART_RATE)) {
|
|
576
|
+
throw Exception("Permission for resting heart rate not granted")
|
|
577
|
+
}
|
|
578
|
+
val request = ReadRecordsRequest(
|
|
579
|
+
recordType = RestingHeartRateRecord::class,
|
|
580
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
581
|
+
pageSize = 1
|
|
582
|
+
)
|
|
583
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
584
|
+
return JSObject().apply {
|
|
585
|
+
put("value", record?.beatsPerMinute ?: 0)
|
|
586
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
587
|
+
put("unit", "count/min")
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private suspend fun readLatestRespiratoryRate(): JSObject {
|
|
592
|
+
if (!hasPermission(CapHealthPermission.READ_RESPIRATORY_RATE)) {
|
|
593
|
+
throw Exception("Permission for respiratory rate not granted")
|
|
594
|
+
}
|
|
595
|
+
val request = ReadRecordsRequest(
|
|
596
|
+
recordType = RespiratoryRateRecord::class,
|
|
597
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
598
|
+
pageSize = 1
|
|
599
|
+
)
|
|
600
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
601
|
+
return JSObject().apply {
|
|
602
|
+
put("value", record?.rate ?: 0.0)
|
|
603
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
604
|
+
put("unit", "count/min")
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private suspend fun readLatestOxygenSaturation(): JSObject {
|
|
609
|
+
if (!hasPermission(CapHealthPermission.READ_OXYGEN_SATURATION)) {
|
|
610
|
+
throw Exception("Permission for oxygen saturation not granted")
|
|
611
|
+
}
|
|
612
|
+
val request = ReadRecordsRequest(
|
|
613
|
+
recordType = OxygenSaturationRecord::class,
|
|
614
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
615
|
+
pageSize = 1
|
|
616
|
+
)
|
|
617
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
618
|
+
return JSObject().apply {
|
|
619
|
+
put("value", record?.percentage?.value ?: 0.0)
|
|
620
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
621
|
+
put("unit", "percent")
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private suspend fun readLatestBloodGlucose(): JSObject {
|
|
626
|
+
if (!hasPermission(CapHealthPermission.READ_BLOOD_GLUCOSE)) {
|
|
627
|
+
throw Exception("Permission for blood glucose not granted")
|
|
628
|
+
}
|
|
629
|
+
val request = ReadRecordsRequest(
|
|
630
|
+
recordType = BloodGlucoseRecord::class,
|
|
631
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
632
|
+
pageSize = 1
|
|
633
|
+
)
|
|
634
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
635
|
+
val meta = JSObject()
|
|
636
|
+
record?.let {
|
|
637
|
+
meta.put("specimenSource", it.specimenSource)
|
|
638
|
+
meta.put("relationToMeal", it.relationToMeal)
|
|
639
|
+
meta.put("mealType", it.mealType)
|
|
640
|
+
}
|
|
641
|
+
return JSObject().apply {
|
|
642
|
+
put("value", record?.level?.inMilligramsPerDeciliter ?: 0.0)
|
|
643
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
644
|
+
put("unit", "mg/dL")
|
|
645
|
+
put("metadata", meta)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private suspend fun readLatestBodyTemperature(): JSObject {
|
|
650
|
+
if (!hasPermission(CapHealthPermission.READ_BODY_TEMPERATURE)) {
|
|
651
|
+
throw Exception("Permission for body temperature not granted")
|
|
652
|
+
}
|
|
653
|
+
val request = ReadRecordsRequest(
|
|
654
|
+
recordType = BodyTemperatureRecord::class,
|
|
655
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
656
|
+
pageSize = 1
|
|
657
|
+
)
|
|
658
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
659
|
+
return JSObject().apply {
|
|
660
|
+
put("value", record?.temperature?.inCelsius ?: 0.0)
|
|
661
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
662
|
+
put("unit", "degC")
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private suspend fun readLatestBasalBodyTemperature(): JSObject {
|
|
667
|
+
if (!hasPermission(CapHealthPermission.READ_BASAL_BODY_TEMPERATURE)) {
|
|
668
|
+
throw Exception("Permission for basal body temperature not granted")
|
|
669
|
+
}
|
|
670
|
+
val request = ReadRecordsRequest(
|
|
671
|
+
recordType = BasalBodyTemperatureRecord::class,
|
|
672
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
673
|
+
pageSize = 1
|
|
674
|
+
)
|
|
675
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
676
|
+
return JSObject().apply {
|
|
677
|
+
put("value", record?.temperature?.inCelsius ?: 0.0)
|
|
678
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
679
|
+
put("unit", "degC")
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private suspend fun readLatestBodyFat(): JSObject {
|
|
684
|
+
if (!hasPermission(CapHealthPermission.READ_BODY_FAT)) {
|
|
685
|
+
throw Exception("Permission for body fat not granted")
|
|
686
|
+
}
|
|
687
|
+
val request = ReadRecordsRequest(
|
|
688
|
+
recordType = BodyFatRecord::class,
|
|
689
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
690
|
+
pageSize = 1
|
|
691
|
+
)
|
|
692
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
693
|
+
return JSObject().apply {
|
|
694
|
+
put("value", record?.percentage?.value ?: 0.0)
|
|
695
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
696
|
+
put("unit", "percent")
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private suspend fun readLatestBasalCalories(): JSObject {
|
|
701
|
+
if (!hasPermission(CapHealthPermission.READ_BASAL_CALORIES)) {
|
|
702
|
+
throw Exception("Permission for basal calories not granted")
|
|
703
|
+
}
|
|
704
|
+
val request = ReadRecordsRequest(
|
|
705
|
+
recordType = BasalMetabolicRateRecord::class,
|
|
706
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
707
|
+
pageSize = 1
|
|
708
|
+
)
|
|
709
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
710
|
+
return JSObject().apply {
|
|
711
|
+
put("value", record?.basalMetabolicRate?.inKilocaloriesPerDay ?: 0.0)
|
|
712
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
713
|
+
put("unit", "kcal/day")
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private suspend fun readLatestFlightsClimbed(): JSObject {
|
|
718
|
+
if (!hasPermission(CapHealthPermission.READ_FLOORS_CLIMBED)) {
|
|
719
|
+
throw Exception("Permission for floors climbed not granted")
|
|
720
|
+
}
|
|
721
|
+
val request = ReadRecordsRequest(
|
|
722
|
+
recordType = FloorsClimbedRecord::class,
|
|
723
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
724
|
+
pageSize = 1
|
|
725
|
+
)
|
|
726
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
727
|
+
return JSObject().apply {
|
|
728
|
+
put("value", record?.floors ?: 0.0)
|
|
729
|
+
put("timestamp", (record?.endTime?.epochSecond ?: 0) * 1000)
|
|
730
|
+
put("unit", "count")
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private suspend fun readLatestExerciseTime(): JSObject {
|
|
735
|
+
if (!hasPermission(CapHealthPermission.READ_WORKOUTS)) {
|
|
736
|
+
throw Exception("Permission for exercise sessions not granted")
|
|
737
|
+
}
|
|
738
|
+
val request = ReadRecordsRequest(
|
|
739
|
+
recordType = ExerciseSessionRecord::class,
|
|
740
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
741
|
+
pageSize = 1
|
|
742
|
+
)
|
|
743
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
744
|
+
val duration = record?.let { session ->
|
|
745
|
+
if (session.segments.isEmpty()) {
|
|
746
|
+
session.endTime.epochSecond - session.startTime.epochSecond
|
|
747
|
+
} else {
|
|
748
|
+
session.segments.sumOf { it.endTime.epochSecond - it.startTime.epochSecond }
|
|
749
|
+
}
|
|
750
|
+
} ?: 0
|
|
751
|
+
return JSObject().apply {
|
|
752
|
+
put("value", duration / 60.0)
|
|
753
|
+
put("timestamp", (record?.startTime?.epochSecond ?: 0) * 1000)
|
|
754
|
+
put("endTimestamp", (record?.endTime?.epochSecond ?: 0) * 1000)
|
|
755
|
+
put("unit", "min")
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private suspend fun readLatestMindfulness(): JSObject {
|
|
760
|
+
if (!hasPermission(CapHealthPermission.READ_MINDFULNESS)) {
|
|
761
|
+
throw Exception("Permission for mindfulness not granted")
|
|
762
|
+
}
|
|
763
|
+
val request = ReadRecordsRequest(
|
|
764
|
+
recordType = MindfulnessSessionRecord::class,
|
|
765
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
766
|
+
pageSize = 1
|
|
767
|
+
)
|
|
768
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
769
|
+
val durationSeconds = record?.let { it.endTime.epochSecond - it.startTime.epochSecond } ?: 0
|
|
770
|
+
val metadata = JSObject()
|
|
771
|
+
record?.notes?.let { metadata.put("notes", it) }
|
|
772
|
+
record?.title?.let { metadata.put("title", it) }
|
|
773
|
+
record?.mindfulnessSessionType?.let { metadata.put("type", it) }
|
|
774
|
+
return JSObject().apply {
|
|
775
|
+
put("value", durationSeconds / 60.0)
|
|
776
|
+
put("timestamp", (record?.startTime?.epochSecond ?: 0) * 1000)
|
|
777
|
+
put("endTimestamp", (record?.endTime?.epochSecond ?: 0) * 1000)
|
|
778
|
+
put("unit", "min")
|
|
779
|
+
put("metadata", metadata)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private suspend fun readLatestSleep(): JSObject {
|
|
784
|
+
val hasSleepPermission = hasPermission(CapHealthPermission.READ_SLEEP)
|
|
785
|
+
if (!hasSleepPermission) {
|
|
786
|
+
throw Exception("Permission for sleep not granted")
|
|
787
|
+
}
|
|
788
|
+
val request = ReadRecordsRequest(
|
|
789
|
+
recordType = SleepSessionRecord::class,
|
|
790
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
791
|
+
pageSize = 1
|
|
792
|
+
)
|
|
793
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
794
|
+
val durationMinutes = record?.let { (it.endTime.epochSecond - it.startTime.epochSecond) / 60.0 } ?: 0.0
|
|
795
|
+
val metadata = JSObject()
|
|
796
|
+
record?.title?.let { metadata.put("title", it) }
|
|
797
|
+
metadata.put("stagesCount", record?.stages?.size ?: 0)
|
|
798
|
+
return JSObject().apply {
|
|
799
|
+
put("value", durationMinutes)
|
|
800
|
+
put("timestamp", (record?.startTime?.epochSecond ?: 0) * 1000)
|
|
801
|
+
put("endTimestamp", (record?.endTime?.epochSecond ?: 0) * 1000)
|
|
802
|
+
put("unit", "min")
|
|
803
|
+
put("metadata", metadata)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
488
807
|
private suspend fun readLatestTotalCalories(): JSObject {
|
|
489
808
|
if (!hasPermission(CapHealthPermission.READ_TOTAL_CALORIES)) {
|
|
490
809
|
throw Exception("Permission for total calories not granted")
|
|
@@ -516,46 +835,133 @@ class HealthPlugin : Plugin() {
|
|
|
516
835
|
return
|
|
517
836
|
}
|
|
518
837
|
|
|
519
|
-
val startDateTime =
|
|
520
|
-
|
|
838
|
+
val (startDateTime, endDateTime) = normalizeTimeRangeForBucket(
|
|
839
|
+
Instant.parse(startDate),
|
|
840
|
+
Instant.parse(endDate),
|
|
841
|
+
bucket
|
|
842
|
+
)
|
|
521
843
|
|
|
522
844
|
val period = when (bucket) {
|
|
523
845
|
"day" -> Period.ofDays(1)
|
|
524
846
|
else -> throw RuntimeException("Unsupported bucket: $bucket")
|
|
525
847
|
}
|
|
526
848
|
|
|
527
|
-
|
|
528
|
-
// removed in Health Connect 1.1‑rc03. We calculate the daily average
|
|
529
|
-
// from raw samples instead.
|
|
530
|
-
if (dataType == "hrv") {
|
|
531
|
-
CoroutineScope(Dispatchers.IO).launch {
|
|
532
|
-
try {
|
|
533
|
-
val hrvSamples = aggregateHrvByPeriod(
|
|
534
|
-
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
535
|
-
period
|
|
536
|
-
)
|
|
537
|
-
val aggregatedList = JSArray()
|
|
538
|
-
hrvSamples.forEach { aggregatedList.put(it.toJs()) }
|
|
539
|
-
val finalResult = JSObject()
|
|
540
|
-
finalResult.put("aggregatedData", aggregatedList)
|
|
541
|
-
call.resolve(finalResult)
|
|
542
|
-
} catch (e: Exception) {
|
|
543
|
-
call.reject("Error querying aggregated HRV data: ${e.message}")
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
return // skip the normal aggregate path
|
|
547
|
-
}
|
|
849
|
+
val timeRange = TimeRangeFilter.between(startDateTime, endDateTime)
|
|
548
850
|
|
|
549
851
|
CoroutineScope(Dispatchers.IO).launch {
|
|
550
852
|
try {
|
|
551
|
-
val metricAndMapper = getMetricAndMapper(dataType)
|
|
552
|
-
val r = queryAggregatedMetric(
|
|
553
|
-
metricAndMapper,
|
|
554
|
-
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
555
|
-
period
|
|
556
|
-
)
|
|
557
853
|
val aggregatedList = JSArray()
|
|
558
|
-
|
|
854
|
+
|
|
855
|
+
when {
|
|
856
|
+
// Special handling for HRV (RMSSD) because aggregate metrics were
|
|
857
|
+
// removed in Health Connect 1.1‑rc03. We calculate the daily average
|
|
858
|
+
// from raw samples instead.
|
|
859
|
+
dataType == "hrv" -> aggregateHrvByPeriod(timeRange, period)
|
|
860
|
+
.forEach { aggregatedList.put(it.toJs()) }
|
|
861
|
+
|
|
862
|
+
dataType == "exercise-time" -> aggregateExerciseTime(timeRange, period)
|
|
863
|
+
.forEach { aggregatedList.put(it.toJs()) }
|
|
864
|
+
|
|
865
|
+
dataType == "sleep" -> aggregateSleepSessions(timeRange, period)
|
|
866
|
+
.forEach { aggregatedList.put(it.toJs()) }
|
|
867
|
+
|
|
868
|
+
dataType == "blood-pressure" -> aggregateBloodPressure(timeRange, period)
|
|
869
|
+
.forEach { aggregatedList.put(it.toJs()) }
|
|
870
|
+
|
|
871
|
+
dataType == "distance-cycling" -> aggregateCyclingDistance(timeRange, period)
|
|
872
|
+
.forEach { aggregatedList.put(it.toJs()) }
|
|
873
|
+
|
|
874
|
+
dataType == "weight" -> aggregateInstantLatestPerDay(
|
|
875
|
+
WeightRecord::class,
|
|
876
|
+
CapHealthPermission.READ_WEIGHT,
|
|
877
|
+
timeRange,
|
|
878
|
+
period,
|
|
879
|
+
{ it.time }
|
|
880
|
+
) { it.weight.inKilograms }
|
|
881
|
+
.forEach { aggregatedList.put(it.toJs()) }
|
|
882
|
+
|
|
883
|
+
dataType == "height" -> aggregateInstantLatestPerDay(
|
|
884
|
+
HeightRecord::class,
|
|
885
|
+
CapHealthPermission.READ_HEIGHT,
|
|
886
|
+
timeRange,
|
|
887
|
+
period,
|
|
888
|
+
{ it.time }
|
|
889
|
+
) { it.height.inMeters }
|
|
890
|
+
.forEach { aggregatedList.put(it.toJs()) }
|
|
891
|
+
|
|
892
|
+
setOf(
|
|
893
|
+
"respiratory-rate",
|
|
894
|
+
"oxygen-saturation",
|
|
895
|
+
"blood-glucose",
|
|
896
|
+
"body-temperature",
|
|
897
|
+
"basal-body-temperature",
|
|
898
|
+
"body-fat"
|
|
899
|
+
).contains(dataType) -> {
|
|
900
|
+
val aggregated = when (dataType) {
|
|
901
|
+
"respiratory-rate" -> aggregateInstantAverage(
|
|
902
|
+
RespiratoryRateRecord::class,
|
|
903
|
+
CapHealthPermission.READ_RESPIRATORY_RATE,
|
|
904
|
+
timeRange,
|
|
905
|
+
period,
|
|
906
|
+
{ it.time }
|
|
907
|
+
) { it.rate }
|
|
908
|
+
|
|
909
|
+
"oxygen-saturation" -> aggregateInstantAverage(
|
|
910
|
+
OxygenSaturationRecord::class,
|
|
911
|
+
CapHealthPermission.READ_OXYGEN_SATURATION,
|
|
912
|
+
timeRange,
|
|
913
|
+
period,
|
|
914
|
+
{ it.time }
|
|
915
|
+
) { it.percentage.value }
|
|
916
|
+
|
|
917
|
+
"blood-glucose" -> aggregateInstantAverage(
|
|
918
|
+
BloodGlucoseRecord::class,
|
|
919
|
+
CapHealthPermission.READ_BLOOD_GLUCOSE,
|
|
920
|
+
timeRange,
|
|
921
|
+
period,
|
|
922
|
+
{ it.time }
|
|
923
|
+
) { it.level.inMilligramsPerDeciliter }
|
|
924
|
+
|
|
925
|
+
"body-temperature" -> aggregateInstantAverage(
|
|
926
|
+
BodyTemperatureRecord::class,
|
|
927
|
+
CapHealthPermission.READ_BODY_TEMPERATURE,
|
|
928
|
+
timeRange,
|
|
929
|
+
period,
|
|
930
|
+
{ it.time }
|
|
931
|
+
) { it.temperature.inCelsius }
|
|
932
|
+
|
|
933
|
+
"basal-body-temperature" -> aggregateInstantAverage(
|
|
934
|
+
BasalBodyTemperatureRecord::class,
|
|
935
|
+
CapHealthPermission.READ_BASAL_BODY_TEMPERATURE,
|
|
936
|
+
timeRange,
|
|
937
|
+
period,
|
|
938
|
+
{ it.time }
|
|
939
|
+
) { it.temperature.inCelsius }
|
|
940
|
+
|
|
941
|
+
"body-fat" -> aggregateInstantAverage(
|
|
942
|
+
BodyFatRecord::class,
|
|
943
|
+
CapHealthPermission.READ_BODY_FAT,
|
|
944
|
+
timeRange,
|
|
945
|
+
period,
|
|
946
|
+
{ it.time }
|
|
947
|
+
) { it.percentage.value }
|
|
948
|
+
|
|
949
|
+
else -> emptyList()
|
|
950
|
+
}
|
|
951
|
+
aggregated.forEach { aggregatedList.put(it.toJs()) }
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
else -> {
|
|
955
|
+
val metricAndMapper = getMetricAndMapper(dataType)
|
|
956
|
+
val r = queryAggregatedMetric(
|
|
957
|
+
metricAndMapper,
|
|
958
|
+
timeRange,
|
|
959
|
+
period
|
|
960
|
+
)
|
|
961
|
+
r.forEach { aggregatedList.put(it.toJs()) }
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
559
965
|
val finalResult = JSObject()
|
|
560
966
|
finalResult.put("aggregatedData", aggregatedList)
|
|
561
967
|
call.resolve(finalResult)
|
|
@@ -570,6 +976,25 @@ class HealthPlugin : Plugin() {
|
|
|
570
976
|
}
|
|
571
977
|
|
|
572
978
|
|
|
979
|
+
private fun normalizeTimeRangeForBucket(
|
|
980
|
+
startInstant: Instant,
|
|
981
|
+
endInstant: Instant,
|
|
982
|
+
bucket: String
|
|
983
|
+
): Pair<LocalDateTime, LocalDateTime> {
|
|
984
|
+
val zone = ZoneId.systemDefault()
|
|
985
|
+
if (bucket == "day") {
|
|
986
|
+
val startOfDay = startInstant.atZone(zone).toLocalDate().atStartOfDay()
|
|
987
|
+
val endOfDay = endInstant.atZone(zone).toLocalDate().plusDays(1).atStartOfDay()
|
|
988
|
+
return Pair(startOfDay, endOfDay)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return Pair(
|
|
992
|
+
startInstant.atZone(zone).toLocalDateTime(),
|
|
993
|
+
endInstant.atZone(zone).toLocalDateTime()
|
|
994
|
+
)
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
|
|
573
998
|
private fun <M : Any> metricAndMapper(
|
|
574
999
|
name: String,
|
|
575
1000
|
permission: CapHealthPermission,
|
|
@@ -603,6 +1028,24 @@ class HealthPlugin : Plugin() {
|
|
|
603
1028
|
}
|
|
604
1029
|
}
|
|
605
1030
|
|
|
1031
|
+
data class AggregatedBloodPressureSample(
|
|
1032
|
+
val startDate: LocalDateTime,
|
|
1033
|
+
val endDate: LocalDateTime,
|
|
1034
|
+
val systolic: Double?,
|
|
1035
|
+
val diastolic: Double?
|
|
1036
|
+
) {
|
|
1037
|
+
fun toJs(): JSObject {
|
|
1038
|
+
val o = JSObject()
|
|
1039
|
+
o.put("startDate", startDate)
|
|
1040
|
+
o.put("endDate", endDate)
|
|
1041
|
+
o.put("systolic", systolic)
|
|
1042
|
+
o.put("diastolic", diastolic)
|
|
1043
|
+
o.put("value", systolic)
|
|
1044
|
+
o.put("unit", "mmHg")
|
|
1045
|
+
return o
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
606
1049
|
private suspend fun queryAggregatedMetric(
|
|
607
1050
|
metricAndMapper: MetricAndMapper, timeRange: TimeRangeFilter, period: Period,
|
|
608
1051
|
): List<AggregatedSample> {
|
|
@@ -650,15 +1093,253 @@ class HealthPlugin : Plugin() {
|
|
|
650
1093
|
.groupBy { it.time.atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
651
1094
|
.map { (localDate, recs) ->
|
|
652
1095
|
val avg = recs.map { it.heartRateVariabilityMillis }.average()
|
|
1096
|
+
val start = localDate.atStartOfDay()
|
|
1097
|
+
AggregatedSample(
|
|
1098
|
+
start,
|
|
1099
|
+
start.plusDays(1),
|
|
1100
|
+
if (avg.isNaN()) null else avg
|
|
1101
|
+
)
|
|
1102
|
+
}
|
|
1103
|
+
.sortedBy { it.startDate }
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private suspend fun <T : Record> aggregateInstantAverage(
|
|
1107
|
+
recordType: KClass<T>,
|
|
1108
|
+
permission: CapHealthPermission,
|
|
1109
|
+
timeRange: TimeRangeFilter,
|
|
1110
|
+
period: Period,
|
|
1111
|
+
timeSelector: (T) -> Instant,
|
|
1112
|
+
valueSelector: (T) -> Double?
|
|
1113
|
+
): List<AggregatedSample> {
|
|
1114
|
+
if (!hasPermission(permission)) {
|
|
1115
|
+
return emptyList()
|
|
1116
|
+
}
|
|
1117
|
+
if (period != Period.ofDays(1)) {
|
|
1118
|
+
throw RuntimeException("Unsupported bucket for aggregation")
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
val response = healthConnectClient.readRecords(
|
|
1122
|
+
ReadRecordsRequest(
|
|
1123
|
+
recordType = recordType,
|
|
1124
|
+
timeRangeFilter = timeRange
|
|
1125
|
+
)
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
return response.records
|
|
1129
|
+
.groupBy { timeSelector(it).atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1130
|
+
.map { (localDate, recs) ->
|
|
1131
|
+
val avg = recs.mapNotNull(valueSelector).average()
|
|
1132
|
+
val start = localDate.atStartOfDay()
|
|
653
1133
|
AggregatedSample(
|
|
654
|
-
|
|
655
|
-
|
|
1134
|
+
start,
|
|
1135
|
+
start.plusDays(1),
|
|
656
1136
|
if (avg.isNaN()) null else avg
|
|
657
1137
|
)
|
|
658
1138
|
}
|
|
659
1139
|
.sortedBy { it.startDate }
|
|
660
1140
|
}
|
|
661
1141
|
|
|
1142
|
+
// Average systolic/diastolic per day from raw blood pressure samples.
|
|
1143
|
+
private suspend fun aggregateBloodPressure(
|
|
1144
|
+
timeRange: TimeRangeFilter,
|
|
1145
|
+
period: Period
|
|
1146
|
+
): List<AggregatedBloodPressureSample> {
|
|
1147
|
+
if (!hasPermission(CapHealthPermission.READ_BLOOD_PRESSURE)) {
|
|
1148
|
+
return emptyList()
|
|
1149
|
+
}
|
|
1150
|
+
if (period != Period.ofDays(1)) {
|
|
1151
|
+
throw RuntimeException("Unsupported bucket for blood pressure aggregation")
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
val response = healthConnectClient.readRecords(
|
|
1155
|
+
ReadRecordsRequest(
|
|
1156
|
+
recordType = BloodPressureRecord::class,
|
|
1157
|
+
timeRangeFilter = timeRange
|
|
1158
|
+
)
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
return response.records
|
|
1162
|
+
.groupBy { it.time.atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1163
|
+
.map { (localDate, recs) ->
|
|
1164
|
+
val systolicAvg = recs.map { it.systolic.inMillimetersOfMercury }.average()
|
|
1165
|
+
val diastolicAvg = recs.map { it.diastolic.inMillimetersOfMercury }.average()
|
|
1166
|
+
val start = localDate.atStartOfDay()
|
|
1167
|
+
AggregatedBloodPressureSample(
|
|
1168
|
+
start,
|
|
1169
|
+
start.plusDays(1),
|
|
1170
|
+
if (systolicAvg.isNaN()) null else systolicAvg,
|
|
1171
|
+
if (diastolicAvg.isNaN()) null else diastolicAvg
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
.sortedBy { it.startDate }
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Sum cycling distance by restricting to biking sessions to avoid mixing in walking/running data.
|
|
1178
|
+
private suspend fun aggregateCyclingDistance(
|
|
1179
|
+
timeRange: TimeRangeFilter,
|
|
1180
|
+
period: Period
|
|
1181
|
+
): List<AggregatedSample> {
|
|
1182
|
+
if (!hasPermission(CapHealthPermission.READ_WORKOUTS) || !hasPermission(CapHealthPermission.READ_DISTANCE)) {
|
|
1183
|
+
return emptyList()
|
|
1184
|
+
}
|
|
1185
|
+
if (period != Period.ofDays(1)) {
|
|
1186
|
+
throw RuntimeException("Unsupported bucket for cycling distance aggregation")
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
val response = healthConnectClient.readRecords(
|
|
1190
|
+
ReadRecordsRequest(
|
|
1191
|
+
recordType = ExerciseSessionRecord::class,
|
|
1192
|
+
timeRangeFilter = timeRange,
|
|
1193
|
+
dataOriginFilter = emptySet(),
|
|
1194
|
+
ascendingOrder = true,
|
|
1195
|
+
pageSize = 1000
|
|
1196
|
+
)
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
val cyclingTypes = setOf(
|
|
1200
|
+
ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
|
|
1201
|
+
ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
val dailyDistance = mutableMapOf<LocalDate, Double>()
|
|
1205
|
+
for (session in response.records.filter { cyclingTypes.contains(it.exerciseType) }) {
|
|
1206
|
+
try {
|
|
1207
|
+
val request = AggregateRequest(
|
|
1208
|
+
setOf(DistanceRecord.DISTANCE_TOTAL),
|
|
1209
|
+
TimeRangeFilter.between(session.startTime, session.endTime),
|
|
1210
|
+
setOf(session.metadata.dataOrigin)
|
|
1211
|
+
)
|
|
1212
|
+
val aggregation = healthConnectClient.aggregate(request)
|
|
1213
|
+
val distance = aggregation[DistanceRecord.DISTANCE_TOTAL]?.inMeters ?: 0.0
|
|
1214
|
+
val localDate = session.startTime.atZone(ZoneId.systemDefault()).toLocalDate()
|
|
1215
|
+
dailyDistance[localDate] = dailyDistance.getOrDefault(localDate, 0.0) + distance
|
|
1216
|
+
} catch (e: Exception) {
|
|
1217
|
+
Log.e(tag, "aggregateCyclingDistance: Failed to aggregate session distance", e)
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return dailyDistance.entries
|
|
1222
|
+
.map { (localDate, distance) ->
|
|
1223
|
+
val start = localDate.atStartOfDay()
|
|
1224
|
+
AggregatedSample(
|
|
1225
|
+
start,
|
|
1226
|
+
start.plusDays(1),
|
|
1227
|
+
distance
|
|
1228
|
+
)
|
|
1229
|
+
}
|
|
1230
|
+
.sortedBy { it.startDate }
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
private suspend fun aggregateExerciseTime(
|
|
1234
|
+
timeRange: TimeRangeFilter,
|
|
1235
|
+
period: Period
|
|
1236
|
+
): List<AggregatedSample> {
|
|
1237
|
+
if (!hasPermission(CapHealthPermission.READ_WORKOUTS)) {
|
|
1238
|
+
return emptyList()
|
|
1239
|
+
}
|
|
1240
|
+
if (period != Period.ofDays(1)) {
|
|
1241
|
+
throw RuntimeException("Unsupported bucket: $period")
|
|
1242
|
+
}
|
|
1243
|
+
val response = healthConnectClient.readRecords(
|
|
1244
|
+
ReadRecordsRequest(
|
|
1245
|
+
recordType = ExerciseSessionRecord::class,
|
|
1246
|
+
timeRangeFilter = timeRange,
|
|
1247
|
+
dataOriginFilter = emptySet(),
|
|
1248
|
+
ascendingOrder = true,
|
|
1249
|
+
pageSize = 1000
|
|
1250
|
+
)
|
|
1251
|
+
)
|
|
1252
|
+
return response.records
|
|
1253
|
+
.groupBy { it.startTime.atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1254
|
+
.map { (localDate, sessions) ->
|
|
1255
|
+
val totalSeconds = sessions.sumOf { session ->
|
|
1256
|
+
if (session.segments.isEmpty()) {
|
|
1257
|
+
session.endTime.epochSecond - session.startTime.epochSecond
|
|
1258
|
+
} else {
|
|
1259
|
+
session.segments.sumOf { it.endTime.epochSecond - it.startTime.epochSecond }
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
val start = localDate.atStartOfDay()
|
|
1263
|
+
AggregatedSample(
|
|
1264
|
+
start,
|
|
1265
|
+
start.plusDays(1),
|
|
1266
|
+
totalSeconds / 60.0
|
|
1267
|
+
)
|
|
1268
|
+
}
|
|
1269
|
+
.sortedBy { it.startDate }
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
private suspend fun <T : Record> aggregateInstantLatestPerDay(
|
|
1273
|
+
recordType: KClass<T>,
|
|
1274
|
+
permission: CapHealthPermission,
|
|
1275
|
+
timeRange: TimeRangeFilter,
|
|
1276
|
+
period: Period,
|
|
1277
|
+
timeSelector: (T) -> Instant,
|
|
1278
|
+
valueSelector: (T) -> Double?
|
|
1279
|
+
): List<AggregatedSample> {
|
|
1280
|
+
if (!hasPermission(permission)) {
|
|
1281
|
+
return emptyList()
|
|
1282
|
+
}
|
|
1283
|
+
if (period != Period.ofDays(1)) {
|
|
1284
|
+
throw RuntimeException("Unsupported bucket for aggregation")
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
val response = healthConnectClient.readRecords(
|
|
1288
|
+
ReadRecordsRequest(
|
|
1289
|
+
recordType = recordType,
|
|
1290
|
+
timeRangeFilter = timeRange,
|
|
1291
|
+
dataOriginFilter = emptySet(),
|
|
1292
|
+
ascendingOrder = false,
|
|
1293
|
+
pageSize = 1000
|
|
1294
|
+
)
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
return response.records
|
|
1298
|
+
.groupBy { timeSelector(it).atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1299
|
+
.mapNotNull { (localDate, recs) ->
|
|
1300
|
+
val latest = recs.maxByOrNull { timeSelector(it) } ?: return@mapNotNull null
|
|
1301
|
+
val value = valueSelector(latest)
|
|
1302
|
+
val start = localDate.atStartOfDay()
|
|
1303
|
+
AggregatedSample(
|
|
1304
|
+
start,
|
|
1305
|
+
start.plusDays(1),
|
|
1306
|
+
value
|
|
1307
|
+
)
|
|
1308
|
+
}
|
|
1309
|
+
.sortedBy { it.startDate }
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
private suspend fun aggregateSleepSessions(
|
|
1313
|
+
timeRange: TimeRangeFilter,
|
|
1314
|
+
period: Period
|
|
1315
|
+
): List<AggregatedSample> {
|
|
1316
|
+
val hasSleepPermission = hasPermission(CapHealthPermission.READ_SLEEP)
|
|
1317
|
+
if (!hasSleepPermission) {
|
|
1318
|
+
return emptyList()
|
|
1319
|
+
}
|
|
1320
|
+
if (period != Period.ofDays(1)) {
|
|
1321
|
+
throw RuntimeException("Unsupported bucket: $period")
|
|
1322
|
+
}
|
|
1323
|
+
val response = healthConnectClient.readRecords(
|
|
1324
|
+
ReadRecordsRequest(
|
|
1325
|
+
recordType = SleepSessionRecord::class,
|
|
1326
|
+
timeRangeFilter = timeRange
|
|
1327
|
+
)
|
|
1328
|
+
)
|
|
1329
|
+
return response.records
|
|
1330
|
+
.groupBy { it.startTime.atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1331
|
+
.map { (localDate, sessions) ->
|
|
1332
|
+
val totalSeconds = sessions.sumOf { it.endTime.epochSecond - it.startTime.epochSecond }
|
|
1333
|
+
val start = localDate.atStartOfDay()
|
|
1334
|
+
AggregatedSample(
|
|
1335
|
+
start,
|
|
1336
|
+
start.plusDays(1),
|
|
1337
|
+
totalSeconds.toDouble()
|
|
1338
|
+
)
|
|
1339
|
+
}
|
|
1340
|
+
.sortedBy { it.startDate }
|
|
1341
|
+
}
|
|
1342
|
+
|
|
662
1343
|
private suspend fun hasPermission(p: CapHealthPermission): Boolean {
|
|
663
1344
|
val granted = healthConnectClient.permissionController.getGrantedPermissions()
|
|
664
1345
|
val targetPermission = permissionMapping[p]
|
|
@@ -683,7 +1364,13 @@ class HealthPlugin : Plugin() {
|
|
|
683
1364
|
|
|
684
1365
|
val timeRange = TimeRangeFilter.between(startDateTime, endDateTime)
|
|
685
1366
|
val request =
|
|
686
|
-
ReadRecordsRequest(
|
|
1367
|
+
ReadRecordsRequest(
|
|
1368
|
+
recordType = ExerciseSessionRecord::class,
|
|
1369
|
+
timeRangeFilter = timeRange,
|
|
1370
|
+
dataOriginFilter = emptySet(),
|
|
1371
|
+
ascendingOrder = true,
|
|
1372
|
+
pageSize = 1000
|
|
1373
|
+
)
|
|
687
1374
|
|
|
688
1375
|
CoroutineScope(Dispatchers.IO).launch {
|
|
689
1376
|
try {
|
|
@@ -776,7 +1463,7 @@ class HealthPlugin : Plugin() {
|
|
|
776
1463
|
try {
|
|
777
1464
|
val request = AggregateRequest(
|
|
778
1465
|
setOf(metricAndMapper.metric),
|
|
779
|
-
TimeRangeFilter.
|
|
1466
|
+
TimeRangeFilter.between(workout.startTime, workout.endTime),
|
|
780
1467
|
emptySet()
|
|
781
1468
|
)
|
|
782
1469
|
val aggregation = healthConnectClient.aggregate(request)
|
|
@@ -890,4 +1577,4 @@ class HealthPlugin : Plugin() {
|
|
|
890
1577
|
82 to "WHEELCHAIR",
|
|
891
1578
|
83 to "YOGA"
|
|
892
1579
|
)
|
|
893
|
-
}
|
|
1580
|
+
}
|