@flomentumsolutions/capacitor-health-extended 0.1.0 → 0.3.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} +7 -7
- package/LICENSE +1 -0
- package/Package.swift +27 -0
- package/README.md +38 -11
- 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 +548 -6
- package/dist/esm/definitions.d.ts +7 -6
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPluginPlugin/HealthPlugin.swift +262 -26
- package/package.json +34 -16
|
@@ -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,8 @@ 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.units.Energy
|
|
18
|
+
import androidx.health.connect.client.records.InstantaneousRecord
|
|
17
19
|
import com.getcapacitor.JSArray
|
|
18
20
|
import com.getcapacitor.JSObject
|
|
19
21
|
import com.getcapacitor.Plugin
|
|
@@ -30,10 +32,32 @@ 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? {
|
|
@@ -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() {
|
|
@@ -281,6 +327,27 @@ class HealthPlugin : Plugin() {
|
|
|
281
327
|
TotalCaloriesBurnedRecord.ENERGY_TOTAL
|
|
282
328
|
) { it?.inKilocalories }
|
|
283
329
|
"distance" -> metricAndMapper("distance", CapHealthPermission.READ_DISTANCE, DistanceRecord.DISTANCE_TOTAL) { it?.inMeters }
|
|
330
|
+
"distance-cycling" -> metricAndMapper("distance", CapHealthPermission.READ_DISTANCE, DistanceRecord.DISTANCE_TOTAL) { it?.inMeters }
|
|
331
|
+
"flights-climbed" -> metricAndMapper(
|
|
332
|
+
"flightsClimbed",
|
|
333
|
+
CapHealthPermission.READ_FLOORS_CLIMBED,
|
|
334
|
+
FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL
|
|
335
|
+
) { it?.toDouble() }
|
|
336
|
+
"mindfulness" -> metricAndMapper(
|
|
337
|
+
"mindfulness",
|
|
338
|
+
CapHealthPermission.READ_MINDFULNESS,
|
|
339
|
+
MindfulnessSessionRecord.MINDFULNESS_DURATION_TOTAL
|
|
340
|
+
) { it?.seconds?.toDouble() }
|
|
341
|
+
"basal-calories" -> metricAndMapper(
|
|
342
|
+
"basalCalories",
|
|
343
|
+
CapHealthPermission.READ_BASAL_CALORIES,
|
|
344
|
+
BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL
|
|
345
|
+
) { (it as Energy?)?.kilocalories }
|
|
346
|
+
"resting-heart-rate" -> metricAndMapper(
|
|
347
|
+
"restingHeartRate",
|
|
348
|
+
CapHealthPermission.READ_RESTING_HEART_RATE,
|
|
349
|
+
RestingHeartRateRecord.BPM_AVG
|
|
350
|
+
) { (it as Long?)?.toDouble() }
|
|
284
351
|
else -> throw RuntimeException("Unsupported dataType: $dataType")
|
|
285
352
|
}
|
|
286
353
|
}
|
|
@@ -302,14 +369,27 @@ class HealthPlugin : Plugin() {
|
|
|
302
369
|
try {
|
|
303
370
|
val result = when (dataType) {
|
|
304
371
|
"heart-rate", "heartRate" -> readLatestHeartRate()
|
|
372
|
+
"resting-heart-rate" -> readLatestRestingHeartRate()
|
|
305
373
|
"weight" -> readLatestWeight()
|
|
306
374
|
"height" -> readLatestHeight()
|
|
307
375
|
"steps" -> readLatestSteps()
|
|
308
376
|
"hrv" -> readLatestHrv()
|
|
309
377
|
"blood-pressure" -> readLatestBloodPressure()
|
|
310
378
|
"distance" -> readLatestDistance()
|
|
379
|
+
"distance-cycling" -> readLatestDistance()
|
|
311
380
|
"active-calories" -> readLatestActiveCalories()
|
|
312
381
|
"total-calories" -> readLatestTotalCalories()
|
|
382
|
+
"basal-calories" -> readLatestBasalCalories()
|
|
383
|
+
"respiratory-rate" -> readLatestRespiratoryRate()
|
|
384
|
+
"oxygen-saturation" -> readLatestOxygenSaturation()
|
|
385
|
+
"blood-glucose" -> readLatestBloodGlucose()
|
|
386
|
+
"body-temperature" -> readLatestBodyTemperature()
|
|
387
|
+
"basal-body-temperature" -> readLatestBasalBodyTemperature()
|
|
388
|
+
"body-fat" -> readLatestBodyFat()
|
|
389
|
+
"flights-climbed" -> readLatestFlightsClimbed()
|
|
390
|
+
"exercise-time" -> readLatestExerciseTime()
|
|
391
|
+
"mindfulness" -> readLatestMindfulness()
|
|
392
|
+
"sleep" -> readLatestSleep()
|
|
313
393
|
else -> throw IllegalArgumentException("Unsupported data type: $dataType")
|
|
314
394
|
}
|
|
315
395
|
call.resolve(result)
|
|
@@ -485,6 +565,239 @@ class HealthPlugin : Plugin() {
|
|
|
485
565
|
}
|
|
486
566
|
}
|
|
487
567
|
|
|
568
|
+
private suspend fun readLatestRestingHeartRate(): JSObject {
|
|
569
|
+
if (!hasPermission(CapHealthPermission.READ_RESTING_HEART_RATE)) {
|
|
570
|
+
throw Exception("Permission for resting heart rate not granted")
|
|
571
|
+
}
|
|
572
|
+
val request = ReadRecordsRequest(
|
|
573
|
+
recordType = RestingHeartRateRecord::class,
|
|
574
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
575
|
+
pageSize = 1
|
|
576
|
+
)
|
|
577
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
578
|
+
return JSObject().apply {
|
|
579
|
+
put("value", record?.beatsPerMinute ?: 0)
|
|
580
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
581
|
+
put("unit", "count/min")
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private suspend fun readLatestRespiratoryRate(): JSObject {
|
|
586
|
+
if (!hasPermission(CapHealthPermission.READ_RESPIRATORY_RATE)) {
|
|
587
|
+
throw Exception("Permission for respiratory rate not granted")
|
|
588
|
+
}
|
|
589
|
+
val request = ReadRecordsRequest(
|
|
590
|
+
recordType = RespiratoryRateRecord::class,
|
|
591
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
592
|
+
pageSize = 1
|
|
593
|
+
)
|
|
594
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
595
|
+
return JSObject().apply {
|
|
596
|
+
put("value", record?.rate ?: 0.0)
|
|
597
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
598
|
+
put("unit", "count/min")
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private suspend fun readLatestOxygenSaturation(): JSObject {
|
|
603
|
+
if (!hasPermission(CapHealthPermission.READ_OXYGEN_SATURATION)) {
|
|
604
|
+
throw Exception("Permission for oxygen saturation not granted")
|
|
605
|
+
}
|
|
606
|
+
val request = ReadRecordsRequest(
|
|
607
|
+
recordType = OxygenSaturationRecord::class,
|
|
608
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
609
|
+
pageSize = 1
|
|
610
|
+
)
|
|
611
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
612
|
+
return JSObject().apply {
|
|
613
|
+
put("value", record?.percentage?.value ?: 0.0)
|
|
614
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
615
|
+
put("unit", "percent")
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
private suspend fun readLatestBloodGlucose(): JSObject {
|
|
620
|
+
if (!hasPermission(CapHealthPermission.READ_BLOOD_GLUCOSE)) {
|
|
621
|
+
throw Exception("Permission for blood glucose not granted")
|
|
622
|
+
}
|
|
623
|
+
val request = ReadRecordsRequest(
|
|
624
|
+
recordType = BloodGlucoseRecord::class,
|
|
625
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
626
|
+
pageSize = 1
|
|
627
|
+
)
|
|
628
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
629
|
+
val meta = JSObject()
|
|
630
|
+
record?.let {
|
|
631
|
+
meta.put("specimenSource", it.specimenSource)
|
|
632
|
+
meta.put("relationToMeal", it.relationToMeal)
|
|
633
|
+
meta.put("mealType", it.mealType)
|
|
634
|
+
}
|
|
635
|
+
return JSObject().apply {
|
|
636
|
+
put("value", record?.level?.milligramsPerDeciliter ?: 0.0)
|
|
637
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
638
|
+
put("unit", "mg/dL")
|
|
639
|
+
put("metadata", meta)
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private suspend fun readLatestBodyTemperature(): JSObject {
|
|
644
|
+
if (!hasPermission(CapHealthPermission.READ_BODY_TEMPERATURE)) {
|
|
645
|
+
throw Exception("Permission for body temperature not granted")
|
|
646
|
+
}
|
|
647
|
+
val request = ReadRecordsRequest(
|
|
648
|
+
recordType = BodyTemperatureRecord::class,
|
|
649
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
650
|
+
pageSize = 1
|
|
651
|
+
)
|
|
652
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
653
|
+
return JSObject().apply {
|
|
654
|
+
put("value", record?.temperature?.celsius ?: 0.0)
|
|
655
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
656
|
+
put("unit", "degC")
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private suspend fun readLatestBasalBodyTemperature(): JSObject {
|
|
661
|
+
if (!hasPermission(CapHealthPermission.READ_BASAL_BODY_TEMPERATURE)) {
|
|
662
|
+
throw Exception("Permission for basal body temperature not granted")
|
|
663
|
+
}
|
|
664
|
+
val request = ReadRecordsRequest(
|
|
665
|
+
recordType = BasalBodyTemperatureRecord::class,
|
|
666
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
667
|
+
pageSize = 1
|
|
668
|
+
)
|
|
669
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
670
|
+
return JSObject().apply {
|
|
671
|
+
put("value", record?.temperature?.celsius ?: 0.0)
|
|
672
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
673
|
+
put("unit", "degC")
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private suspend fun readLatestBodyFat(): JSObject {
|
|
678
|
+
if (!hasPermission(CapHealthPermission.READ_BODY_FAT)) {
|
|
679
|
+
throw Exception("Permission for body fat not granted")
|
|
680
|
+
}
|
|
681
|
+
val request = ReadRecordsRequest(
|
|
682
|
+
recordType = BodyFatRecord::class,
|
|
683
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
684
|
+
pageSize = 1
|
|
685
|
+
)
|
|
686
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
687
|
+
return JSObject().apply {
|
|
688
|
+
put("value", record?.percentage?.value ?: 0.0)
|
|
689
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
690
|
+
put("unit", "percent")
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private suspend fun readLatestBasalCalories(): JSObject {
|
|
695
|
+
if (!hasPermission(CapHealthPermission.READ_BASAL_CALORIES)) {
|
|
696
|
+
throw Exception("Permission for basal calories not granted")
|
|
697
|
+
}
|
|
698
|
+
val request = ReadRecordsRequest(
|
|
699
|
+
recordType = BasalMetabolicRateRecord::class,
|
|
700
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
701
|
+
pageSize = 1
|
|
702
|
+
)
|
|
703
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
704
|
+
return JSObject().apply {
|
|
705
|
+
put("value", record?.basalMetabolicRate?.kilocaloriesPerDay ?: 0.0)
|
|
706
|
+
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
707
|
+
put("unit", "kcal/day")
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private suspend fun readLatestFlightsClimbed(): JSObject {
|
|
712
|
+
if (!hasPermission(CapHealthPermission.READ_FLOORS_CLIMBED)) {
|
|
713
|
+
throw Exception("Permission for floors climbed not granted")
|
|
714
|
+
}
|
|
715
|
+
val request = ReadRecordsRequest(
|
|
716
|
+
recordType = FloorsClimbedRecord::class,
|
|
717
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
718
|
+
pageSize = 1
|
|
719
|
+
)
|
|
720
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
721
|
+
return JSObject().apply {
|
|
722
|
+
put("value", record?.floors ?: 0.0)
|
|
723
|
+
put("timestamp", (record?.endTime?.epochSecond ?: 0) * 1000)
|
|
724
|
+
put("unit", "count")
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private suspend fun readLatestExerciseTime(): JSObject {
|
|
729
|
+
if (!hasPermission(CapHealthPermission.READ_WORKOUTS)) {
|
|
730
|
+
throw Exception("Permission for exercise sessions not granted")
|
|
731
|
+
}
|
|
732
|
+
val request = ReadRecordsRequest(
|
|
733
|
+
recordType = ExerciseSessionRecord::class,
|
|
734
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
735
|
+
pageSize = 1
|
|
736
|
+
)
|
|
737
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
738
|
+
val duration = record?.let { session ->
|
|
739
|
+
if (session.segments.isEmpty()) {
|
|
740
|
+
session.endTime.epochSecond - session.startTime.epochSecond
|
|
741
|
+
} else {
|
|
742
|
+
session.segments.sumOf { it.endTime.epochSecond - it.startTime.epochSecond }
|
|
743
|
+
}
|
|
744
|
+
} ?: 0
|
|
745
|
+
return JSObject().apply {
|
|
746
|
+
put("value", duration / 60.0)
|
|
747
|
+
put("timestamp", (record?.startTime?.epochSecond ?: 0) * 1000)
|
|
748
|
+
put("endTimestamp", (record?.endTime?.epochSecond ?: 0) * 1000)
|
|
749
|
+
put("unit", "min")
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private suspend fun readLatestMindfulness(): JSObject {
|
|
754
|
+
if (!hasPermission(CapHealthPermission.READ_MINDFULNESS)) {
|
|
755
|
+
throw Exception("Permission for mindfulness not granted")
|
|
756
|
+
}
|
|
757
|
+
val request = ReadRecordsRequest(
|
|
758
|
+
recordType = MindfulnessSessionRecord::class,
|
|
759
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
760
|
+
pageSize = 1
|
|
761
|
+
)
|
|
762
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
763
|
+
val durationSeconds = record?.let { it.endTime.epochSecond - it.startTime.epochSecond } ?: 0
|
|
764
|
+
val metadata = JSObject()
|
|
765
|
+
record?.notes?.let { metadata.put("notes", it) }
|
|
766
|
+
record?.title?.let { metadata.put("title", it) }
|
|
767
|
+
record?.mindfulnessSessionType?.let { metadata.put("type", it) }
|
|
768
|
+
return JSObject().apply {
|
|
769
|
+
put("value", durationSeconds / 60.0)
|
|
770
|
+
put("timestamp", (record?.startTime?.epochSecond ?: 0) * 1000)
|
|
771
|
+
put("endTimestamp", (record?.endTime?.epochSecond ?: 0) * 1000)
|
|
772
|
+
put("unit", "min")
|
|
773
|
+
put("metadata", metadata)
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private suspend fun readLatestSleep(): JSObject {
|
|
778
|
+
val hasSleepPermission = hasPermission(CapHealthPermission.READ_SLEEP)
|
|
779
|
+
if (!hasSleepPermission) {
|
|
780
|
+
throw Exception("Permission for sleep not granted")
|
|
781
|
+
}
|
|
782
|
+
val request = ReadRecordsRequest(
|
|
783
|
+
recordType = SleepSessionRecord::class,
|
|
784
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
785
|
+
pageSize = 1
|
|
786
|
+
)
|
|
787
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
788
|
+
val durationMinutes = record?.let { (it.endTime.epochSecond - it.startTime.epochSecond) / 60.0 } ?: 0.0
|
|
789
|
+
val metadata = JSObject()
|
|
790
|
+
record?.title?.let { metadata.put("title", it) }
|
|
791
|
+
metadata.put("stagesCount", record?.stages?.size ?: 0)
|
|
792
|
+
return JSObject().apply {
|
|
793
|
+
put("value", durationMinutes)
|
|
794
|
+
put("timestamp", (record?.startTime?.epochSecond ?: 0) * 1000)
|
|
795
|
+
put("endTimestamp", (record?.endTime?.epochSecond ?: 0) * 1000)
|
|
796
|
+
put("unit", "min")
|
|
797
|
+
put("metadata", metadata)
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
488
801
|
private suspend fun readLatestTotalCalories(): JSObject {
|
|
489
802
|
if (!hasPermission(CapHealthPermission.READ_TOTAL_CALORIES)) {
|
|
490
803
|
throw Exception("Permission for total calories not granted")
|
|
@@ -546,6 +859,133 @@ class HealthPlugin : Plugin() {
|
|
|
546
859
|
return // skip the normal aggregate path
|
|
547
860
|
}
|
|
548
861
|
|
|
862
|
+
if (dataType == "exercise-time") {
|
|
863
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
864
|
+
try {
|
|
865
|
+
val exerciseDurations = aggregateExerciseTime(
|
|
866
|
+
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
867
|
+
period
|
|
868
|
+
)
|
|
869
|
+
val aggregatedList = JSArray()
|
|
870
|
+
exerciseDurations.forEach { aggregatedList.put(it.toJs()) }
|
|
871
|
+
val finalResult = JSObject()
|
|
872
|
+
finalResult.put("aggregatedData", aggregatedList)
|
|
873
|
+
call.resolve(finalResult)
|
|
874
|
+
} catch (e: Exception) {
|
|
875
|
+
call.reject("Error querying aggregated exercise time: ${e.message}")
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (dataType == "sleep") {
|
|
882
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
883
|
+
try {
|
|
884
|
+
val sleepDurations = aggregateSleepSessions(
|
|
885
|
+
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
886
|
+
period
|
|
887
|
+
)
|
|
888
|
+
val aggregatedList = JSArray()
|
|
889
|
+
sleepDurations.forEach { aggregatedList.put(it.toJs()) }
|
|
890
|
+
val finalResult = JSObject()
|
|
891
|
+
finalResult.put("aggregatedData", aggregatedList)
|
|
892
|
+
call.resolve(finalResult)
|
|
893
|
+
} catch (e: Exception) {
|
|
894
|
+
call.reject("Error querying aggregated sleep data: ${e.message}")
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (dataType == "mindfulness") {
|
|
901
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
902
|
+
try {
|
|
903
|
+
val metricAndMapper = getMetricAndMapper(dataType)
|
|
904
|
+
val r = queryAggregatedMetric(
|
|
905
|
+
metricAndMapper,
|
|
906
|
+
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
907
|
+
period
|
|
908
|
+
)
|
|
909
|
+
val aggregatedList = JSArray()
|
|
910
|
+
r.forEach { aggregatedList.put(it.toJs()) }
|
|
911
|
+
val finalResult = JSObject()
|
|
912
|
+
finalResult.put("aggregatedData", aggregatedList)
|
|
913
|
+
call.resolve(finalResult)
|
|
914
|
+
} catch (e: Exception) {
|
|
915
|
+
call.reject("Error querying aggregated mindfulness data: ${e.message}")
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (setOf(
|
|
922
|
+
"respiratory-rate",
|
|
923
|
+
"oxygen-saturation",
|
|
924
|
+
"blood-glucose",
|
|
925
|
+
"body-temperature",
|
|
926
|
+
"basal-body-temperature",
|
|
927
|
+
"body-fat"
|
|
928
|
+
).contains(dataType)
|
|
929
|
+
) {
|
|
930
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
931
|
+
try {
|
|
932
|
+
val aggregated = when (dataType) {
|
|
933
|
+
"respiratory-rate" -> aggregateInstantAverage(
|
|
934
|
+
RespiratoryRateRecord::class,
|
|
935
|
+
CapHealthPermission.READ_RESPIRATORY_RATE,
|
|
936
|
+
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
937
|
+
period
|
|
938
|
+
) { it.rate }
|
|
939
|
+
|
|
940
|
+
"oxygen-saturation" -> aggregateInstantAverage(
|
|
941
|
+
OxygenSaturationRecord::class,
|
|
942
|
+
CapHealthPermission.READ_OXYGEN_SATURATION,
|
|
943
|
+
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
944
|
+
period
|
|
945
|
+
) { it.percentage.value }
|
|
946
|
+
|
|
947
|
+
"blood-glucose" -> aggregateInstantAverage(
|
|
948
|
+
BloodGlucoseRecord::class,
|
|
949
|
+
CapHealthPermission.READ_BLOOD_GLUCOSE,
|
|
950
|
+
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
951
|
+
period
|
|
952
|
+
) { it.level.milligramsPerDeciliter }
|
|
953
|
+
|
|
954
|
+
"body-temperature" -> aggregateInstantAverage(
|
|
955
|
+
BodyTemperatureRecord::class,
|
|
956
|
+
CapHealthPermission.READ_BODY_TEMPERATURE,
|
|
957
|
+
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
958
|
+
period
|
|
959
|
+
) { it.temperature.celsius }
|
|
960
|
+
|
|
961
|
+
"basal-body-temperature" -> aggregateInstantAverage(
|
|
962
|
+
BasalBodyTemperatureRecord::class,
|
|
963
|
+
CapHealthPermission.READ_BASAL_BODY_TEMPERATURE,
|
|
964
|
+
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
965
|
+
period
|
|
966
|
+
) { it.temperature.celsius }
|
|
967
|
+
|
|
968
|
+
"body-fat" -> aggregateInstantAverage(
|
|
969
|
+
BodyFatRecord::class,
|
|
970
|
+
CapHealthPermission.READ_BODY_FAT,
|
|
971
|
+
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
972
|
+
period
|
|
973
|
+
) { it.percentage.value }
|
|
974
|
+
|
|
975
|
+
else -> emptyList()
|
|
976
|
+
}
|
|
977
|
+
val aggregatedList = JSArray()
|
|
978
|
+
aggregated.forEach { aggregatedList.put(it.toJs()) }
|
|
979
|
+
val finalResult = JSObject()
|
|
980
|
+
finalResult.put("aggregatedData", aggregatedList)
|
|
981
|
+
call.resolve(finalResult)
|
|
982
|
+
} catch (e: Exception) {
|
|
983
|
+
call.reject("Error querying aggregated data: ${e.message}")
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
|
|
549
989
|
CoroutineScope(Dispatchers.IO).launch {
|
|
550
990
|
try {
|
|
551
991
|
val metricAndMapper = getMetricAndMapper(dataType)
|
|
@@ -659,6 +1099,108 @@ class HealthPlugin : Plugin() {
|
|
|
659
1099
|
.sortedBy { it.startDate }
|
|
660
1100
|
}
|
|
661
1101
|
|
|
1102
|
+
private suspend fun <T : InstantaneousRecord> aggregateInstantAverage(
|
|
1103
|
+
recordType: KClass<T>,
|
|
1104
|
+
permission: CapHealthPermission,
|
|
1105
|
+
timeRange: TimeRangeFilter,
|
|
1106
|
+
period: Period,
|
|
1107
|
+
valueSelector: (T) -> Double?
|
|
1108
|
+
): List<AggregatedSample> {
|
|
1109
|
+
if (!hasPermission(permission)) {
|
|
1110
|
+
return emptyList()
|
|
1111
|
+
}
|
|
1112
|
+
if (period != Period.ofDays(1)) {
|
|
1113
|
+
throw RuntimeException("Unsupported bucket for aggregation")
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
val response = healthConnectClient.readRecords(
|
|
1117
|
+
ReadRecordsRequest(
|
|
1118
|
+
recordType = recordType,
|
|
1119
|
+
timeRangeFilter = timeRange
|
|
1120
|
+
)
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
return response.records
|
|
1124
|
+
.groupBy { it.time.atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1125
|
+
.map { (localDate, recs) ->
|
|
1126
|
+
val avg = recs.mapNotNull(valueSelector).average()
|
|
1127
|
+
AggregatedSample(
|
|
1128
|
+
localDate.atStartOfDay(),
|
|
1129
|
+
localDate.plusDays(1).atStartOfDay(),
|
|
1130
|
+
if (avg.isNaN()) null else avg
|
|
1131
|
+
)
|
|
1132
|
+
}
|
|
1133
|
+
.sortedBy { it.startDate }
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
private suspend fun aggregateExerciseTime(
|
|
1137
|
+
timeRange: TimeRangeFilter,
|
|
1138
|
+
period: Period
|
|
1139
|
+
): List<AggregatedSample> {
|
|
1140
|
+
if (!hasPermission(CapHealthPermission.READ_WORKOUTS)) {
|
|
1141
|
+
return emptyList()
|
|
1142
|
+
}
|
|
1143
|
+
if (period != Period.ofDays(1)) {
|
|
1144
|
+
throw RuntimeException("Unsupported bucket: $period")
|
|
1145
|
+
}
|
|
1146
|
+
val response = healthConnectClient.readRecords(
|
|
1147
|
+
ReadRecordsRequest(
|
|
1148
|
+
recordType = ExerciseSessionRecord::class,
|
|
1149
|
+
timeRangeFilter = timeRange,
|
|
1150
|
+
dataOriginsFilter = emptySet(),
|
|
1151
|
+
ascendingOrder = true,
|
|
1152
|
+
pageSize = 1000
|
|
1153
|
+
)
|
|
1154
|
+
)
|
|
1155
|
+
return response.records
|
|
1156
|
+
.groupBy { it.startTime.atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1157
|
+
.map { (localDate, sessions) ->
|
|
1158
|
+
val totalSeconds = sessions.sumOf { session ->
|
|
1159
|
+
if (session.segments.isEmpty()) {
|
|
1160
|
+
session.endTime.epochSecond - session.startTime.epochSecond
|
|
1161
|
+
} else {
|
|
1162
|
+
session.segments.sumOf { it.endTime.epochSecond - it.startTime.epochSecond }
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
AggregatedSample(
|
|
1166
|
+
localDate.atStartOfDay(),
|
|
1167
|
+
localDate.plusDays(1).atStartOfDay(),
|
|
1168
|
+
totalSeconds / 60.0
|
|
1169
|
+
)
|
|
1170
|
+
}
|
|
1171
|
+
.sortedBy { it.startDate }
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
private suspend fun aggregateSleepSessions(
|
|
1175
|
+
timeRange: TimeRangeFilter,
|
|
1176
|
+
period: Period
|
|
1177
|
+
): List<AggregatedSample> {
|
|
1178
|
+
val hasSleepPermission = hasPermission(CapHealthPermission.READ_SLEEP)
|
|
1179
|
+
if (!hasSleepPermission) {
|
|
1180
|
+
return emptyList()
|
|
1181
|
+
}
|
|
1182
|
+
if (period != Period.ofDays(1)) {
|
|
1183
|
+
throw RuntimeException("Unsupported bucket: $period")
|
|
1184
|
+
}
|
|
1185
|
+
val response = healthConnectClient.readRecords(
|
|
1186
|
+
ReadRecordsRequest(
|
|
1187
|
+
recordType = SleepSessionRecord::class,
|
|
1188
|
+
timeRangeFilter = timeRange
|
|
1189
|
+
)
|
|
1190
|
+
)
|
|
1191
|
+
return response.records
|
|
1192
|
+
.groupBy { it.startTime.atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1193
|
+
.map { (localDate, sessions) ->
|
|
1194
|
+
val totalSeconds = sessions.sumOf { it.endTime.epochSecond - it.startTime.epochSecond }
|
|
1195
|
+
AggregatedSample(
|
|
1196
|
+
localDate.atStartOfDay(),
|
|
1197
|
+
localDate.plusDays(1).atStartOfDay(),
|
|
1198
|
+
totalSeconds.toDouble()
|
|
1199
|
+
)
|
|
1200
|
+
}
|
|
1201
|
+
.sortedBy { it.startDate }
|
|
1202
|
+
}
|
|
1203
|
+
|
|
662
1204
|
private suspend fun hasPermission(p: CapHealthPermission): Boolean {
|
|
663
1205
|
val granted = healthConnectClient.permissionController.getGrantedPermissions()
|
|
664
1206
|
val targetPermission = permissionMapping[p]
|
|
@@ -890,4 +1432,4 @@ class HealthPlugin : Plugin() {
|
|
|
890
1432
|
82 to "WHEELCHAIR",
|
|
891
1433
|
83 to "YOGA"
|
|
892
1434
|
)
|
|
893
|
-
}
|
|
1435
|
+
}
|