@flomentumsolutions/capacitor-health-extended 0.3.1 → 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 +1 -1
- package/Package.swift +2 -2
- package/README.md +86 -14
- package/android/src/main/java/com/flomentum/health/capacitor/HealthPlugin.kt +327 -182
- package/android/src/main/java/com/flomentum/health/capacitor/PermissionsRationaleActivity.kt +1 -1
- package/dist/esm/definitions.d.ts +3 -0
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPluginPlugin/HealthPlugin.swift +287 -69
- package/package.json +7 -2
|
@@ -14,8 +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.
|
|
18
|
-
import androidx.health.connect.client.records.InstantaneousRecord
|
|
17
|
+
import androidx.health.connect.client.records.Record
|
|
19
18
|
import com.getcapacitor.JSArray
|
|
20
19
|
import com.getcapacitor.JSObject
|
|
21
20
|
import com.getcapacitor.Plugin
|
|
@@ -27,6 +26,7 @@ import kotlinx.coroutines.CoroutineScope
|
|
|
27
26
|
import kotlinx.coroutines.Dispatchers
|
|
28
27
|
import kotlinx.coroutines.launch
|
|
29
28
|
import java.time.Instant
|
|
29
|
+
import java.time.LocalDate
|
|
30
30
|
import java.time.LocalDateTime
|
|
31
31
|
import java.time.Period
|
|
32
32
|
import java.time.ZoneId
|
|
@@ -63,7 +63,7 @@ enum class CapHealthPermission {
|
|
|
63
63
|
fun from(s: String): CapHealthPermission? {
|
|
64
64
|
return try {
|
|
65
65
|
CapHealthPermission.valueOf(s)
|
|
66
|
-
} catch (
|
|
66
|
+
} catch (_: Exception) {
|
|
67
67
|
null
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -316,6 +316,11 @@ class HealthPlugin : Plugin() {
|
|
|
316
316
|
private fun getMetricAndMapper(dataType: String): MetricAndMapper {
|
|
317
317
|
return when (dataType) {
|
|
318
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() }
|
|
319
324
|
"active-calories", "activeCalories" -> metricAndMapper(
|
|
320
325
|
"calories",
|
|
321
326
|
CapHealthPermission.READ_ACTIVE_CALORIES,
|
|
@@ -327,12 +332,11 @@ class HealthPlugin : Plugin() {
|
|
|
327
332
|
TotalCaloriesBurnedRecord.ENERGY_TOTAL
|
|
328
333
|
) { it?.inKilocalories }
|
|
329
334
|
"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
335
|
"flights-climbed" -> metricAndMapper(
|
|
332
336
|
"flightsClimbed",
|
|
333
337
|
CapHealthPermission.READ_FLOORS_CLIMBED,
|
|
334
338
|
FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL
|
|
335
|
-
) { it
|
|
339
|
+
) { it }
|
|
336
340
|
"mindfulness" -> metricAndMapper(
|
|
337
341
|
"mindfulness",
|
|
338
342
|
CapHealthPermission.READ_MINDFULNESS,
|
|
@@ -342,12 +346,12 @@ class HealthPlugin : Plugin() {
|
|
|
342
346
|
"basalCalories",
|
|
343
347
|
CapHealthPermission.READ_BASAL_CALORIES,
|
|
344
348
|
BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL
|
|
345
|
-
) {
|
|
349
|
+
) { it?.inKilocalories }
|
|
346
350
|
"resting-heart-rate" -> metricAndMapper(
|
|
347
351
|
"restingHeartRate",
|
|
348
352
|
CapHealthPermission.READ_RESTING_HEART_RATE,
|
|
349
353
|
RestingHeartRateRecord.BPM_AVG
|
|
350
|
-
) {
|
|
354
|
+
) { it?.toDouble() }
|
|
351
355
|
else -> throw RuntimeException("Unsupported dataType: $dataType")
|
|
352
356
|
}
|
|
353
357
|
}
|
|
@@ -448,13 +452,14 @@ class HealthPlugin : Plugin() {
|
|
|
448
452
|
val request = ReadRecordsRequest(
|
|
449
453
|
recordType = WeightRecord::class,
|
|
450
454
|
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
455
|
+
ascendingOrder = false,
|
|
451
456
|
pageSize = 1
|
|
452
457
|
)
|
|
453
458
|
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
454
459
|
|
|
455
460
|
return JSObject().apply {
|
|
456
|
-
put("value", record?.weight?.inKilograms
|
|
457
|
-
put("timestamp",
|
|
461
|
+
put("value", record?.weight?.inKilograms)
|
|
462
|
+
put("timestamp", record?.time?.toEpochMilli())
|
|
458
463
|
put("unit", "kg")
|
|
459
464
|
}
|
|
460
465
|
}
|
|
@@ -520,13 +525,14 @@ class HealthPlugin : Plugin() {
|
|
|
520
525
|
val request = ReadRecordsRequest(
|
|
521
526
|
recordType = HeightRecord::class,
|
|
522
527
|
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
528
|
+
ascendingOrder = false,
|
|
523
529
|
pageSize = 1
|
|
524
530
|
)
|
|
525
531
|
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
526
532
|
|
|
527
533
|
return JSObject().apply {
|
|
528
|
-
put("value", record?.height?.inMeters
|
|
529
|
-
put("timestamp",
|
|
534
|
+
put("value", record?.height?.inMeters)
|
|
535
|
+
put("timestamp", record?.time?.toEpochMilli())
|
|
530
536
|
put("unit", "m")
|
|
531
537
|
}
|
|
532
538
|
}
|
|
@@ -633,7 +639,7 @@ class HealthPlugin : Plugin() {
|
|
|
633
639
|
meta.put("mealType", it.mealType)
|
|
634
640
|
}
|
|
635
641
|
return JSObject().apply {
|
|
636
|
-
put("value", record?.level?.
|
|
642
|
+
put("value", record?.level?.inMilligramsPerDeciliter ?: 0.0)
|
|
637
643
|
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
638
644
|
put("unit", "mg/dL")
|
|
639
645
|
put("metadata", meta)
|
|
@@ -651,7 +657,7 @@ class HealthPlugin : Plugin() {
|
|
|
651
657
|
)
|
|
652
658
|
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
653
659
|
return JSObject().apply {
|
|
654
|
-
put("value", record?.temperature?.
|
|
660
|
+
put("value", record?.temperature?.inCelsius ?: 0.0)
|
|
655
661
|
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
656
662
|
put("unit", "degC")
|
|
657
663
|
}
|
|
@@ -668,7 +674,7 @@ class HealthPlugin : Plugin() {
|
|
|
668
674
|
)
|
|
669
675
|
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
670
676
|
return JSObject().apply {
|
|
671
|
-
put("value", record?.temperature?.
|
|
677
|
+
put("value", record?.temperature?.inCelsius ?: 0.0)
|
|
672
678
|
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
673
679
|
put("unit", "degC")
|
|
674
680
|
}
|
|
@@ -702,7 +708,7 @@ class HealthPlugin : Plugin() {
|
|
|
702
708
|
)
|
|
703
709
|
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
704
710
|
return JSObject().apply {
|
|
705
|
-
put("value", record?.basalMetabolicRate?.
|
|
711
|
+
put("value", record?.basalMetabolicRate?.inKilocaloriesPerDay ?: 0.0)
|
|
706
712
|
put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
|
|
707
713
|
put("unit", "kcal/day")
|
|
708
714
|
}
|
|
@@ -829,173 +835,133 @@ class HealthPlugin : Plugin() {
|
|
|
829
835
|
return
|
|
830
836
|
}
|
|
831
837
|
|
|
832
|
-
val startDateTime =
|
|
833
|
-
|
|
838
|
+
val (startDateTime, endDateTime) = normalizeTimeRangeForBucket(
|
|
839
|
+
Instant.parse(startDate),
|
|
840
|
+
Instant.parse(endDate),
|
|
841
|
+
bucket
|
|
842
|
+
)
|
|
834
843
|
|
|
835
844
|
val period = when (bucket) {
|
|
836
845
|
"day" -> Period.ofDays(1)
|
|
837
846
|
else -> throw RuntimeException("Unsupported bucket: $bucket")
|
|
838
847
|
}
|
|
839
848
|
|
|
840
|
-
|
|
841
|
-
// removed in Health Connect 1.1‑rc03. We calculate the daily average
|
|
842
|
-
// from raw samples instead.
|
|
843
|
-
if (dataType == "hrv") {
|
|
844
|
-
CoroutineScope(Dispatchers.IO).launch {
|
|
845
|
-
try {
|
|
846
|
-
val hrvSamples = aggregateHrvByPeriod(
|
|
847
|
-
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
848
|
-
period
|
|
849
|
-
)
|
|
850
|
-
val aggregatedList = JSArray()
|
|
851
|
-
hrvSamples.forEach { aggregatedList.put(it.toJs()) }
|
|
852
|
-
val finalResult = JSObject()
|
|
853
|
-
finalResult.put("aggregatedData", aggregatedList)
|
|
854
|
-
call.resolve(finalResult)
|
|
855
|
-
} catch (e: Exception) {
|
|
856
|
-
call.reject("Error querying aggregated HRV data: ${e.message}")
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
return // skip the normal aggregate path
|
|
860
|
-
}
|
|
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 }
|
|
849
|
+
val timeRange = TimeRangeFilter.between(startDateTime, endDateTime)
|
|
939
850
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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 }
|
|
851
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
852
|
+
try {
|
|
853
|
+
val aggregatedList = JSArray()
|
|
960
854
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
+
}
|
|
967
953
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
954
|
+
else -> {
|
|
955
|
+
val metricAndMapper = getMetricAndMapper(dataType)
|
|
956
|
+
val r = queryAggregatedMetric(
|
|
957
|
+
metricAndMapper,
|
|
958
|
+
timeRange,
|
|
972
959
|
period
|
|
973
|
-
)
|
|
974
|
-
|
|
975
|
-
else -> emptyList()
|
|
960
|
+
)
|
|
961
|
+
r.forEach { aggregatedList.put(it.toJs()) }
|
|
976
962
|
}
|
|
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
963
|
}
|
|
985
|
-
}
|
|
986
|
-
return
|
|
987
|
-
}
|
|
988
964
|
|
|
989
|
-
CoroutineScope(Dispatchers.IO).launch {
|
|
990
|
-
try {
|
|
991
|
-
val metricAndMapper = getMetricAndMapper(dataType)
|
|
992
|
-
val r = queryAggregatedMetric(
|
|
993
|
-
metricAndMapper,
|
|
994
|
-
TimeRangeFilter.between(startDateTime, endDateTime),
|
|
995
|
-
period
|
|
996
|
-
)
|
|
997
|
-
val aggregatedList = JSArray()
|
|
998
|
-
r.forEach { aggregatedList.put(it.toJs()) }
|
|
999
965
|
val finalResult = JSObject()
|
|
1000
966
|
finalResult.put("aggregatedData", aggregatedList)
|
|
1001
967
|
call.resolve(finalResult)
|
|
@@ -1010,6 +976,25 @@ class HealthPlugin : Plugin() {
|
|
|
1010
976
|
}
|
|
1011
977
|
|
|
1012
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
|
+
|
|
1013
998
|
private fun <M : Any> metricAndMapper(
|
|
1014
999
|
name: String,
|
|
1015
1000
|
permission: CapHealthPermission,
|
|
@@ -1043,6 +1028,24 @@ class HealthPlugin : Plugin() {
|
|
|
1043
1028
|
}
|
|
1044
1029
|
}
|
|
1045
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
|
+
|
|
1046
1049
|
private suspend fun queryAggregatedMetric(
|
|
1047
1050
|
metricAndMapper: MetricAndMapper, timeRange: TimeRangeFilter, period: Period,
|
|
1048
1051
|
): List<AggregatedSample> {
|
|
@@ -1090,20 +1093,22 @@ class HealthPlugin : Plugin() {
|
|
|
1090
1093
|
.groupBy { it.time.atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1091
1094
|
.map { (localDate, recs) ->
|
|
1092
1095
|
val avg = recs.map { it.heartRateVariabilityMillis }.average()
|
|
1096
|
+
val start = localDate.atStartOfDay()
|
|
1093
1097
|
AggregatedSample(
|
|
1094
|
-
|
|
1095
|
-
|
|
1098
|
+
start,
|
|
1099
|
+
start.plusDays(1),
|
|
1096
1100
|
if (avg.isNaN()) null else avg
|
|
1097
1101
|
)
|
|
1098
1102
|
}
|
|
1099
1103
|
.sortedBy { it.startDate }
|
|
1100
1104
|
}
|
|
1101
1105
|
|
|
1102
|
-
private suspend fun <T :
|
|
1106
|
+
private suspend fun <T : Record> aggregateInstantAverage(
|
|
1103
1107
|
recordType: KClass<T>,
|
|
1104
1108
|
permission: CapHealthPermission,
|
|
1105
1109
|
timeRange: TimeRangeFilter,
|
|
1106
1110
|
period: Period,
|
|
1111
|
+
timeSelector: (T) -> Instant,
|
|
1107
1112
|
valueSelector: (T) -> Double?
|
|
1108
1113
|
): List<AggregatedSample> {
|
|
1109
1114
|
if (!hasPermission(permission)) {
|
|
@@ -1121,18 +1126,110 @@ class HealthPlugin : Plugin() {
|
|
|
1121
1126
|
)
|
|
1122
1127
|
|
|
1123
1128
|
return response.records
|
|
1124
|
-
.groupBy { it.
|
|
1129
|
+
.groupBy { timeSelector(it).atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1125
1130
|
.map { (localDate, recs) ->
|
|
1126
1131
|
val avg = recs.mapNotNull(valueSelector).average()
|
|
1132
|
+
val start = localDate.atStartOfDay()
|
|
1127
1133
|
AggregatedSample(
|
|
1128
|
-
|
|
1129
|
-
|
|
1134
|
+
start,
|
|
1135
|
+
start.plusDays(1),
|
|
1130
1136
|
if (avg.isNaN()) null else avg
|
|
1131
1137
|
)
|
|
1132
1138
|
}
|
|
1133
1139
|
.sortedBy { it.startDate }
|
|
1134
1140
|
}
|
|
1135
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
|
+
|
|
1136
1233
|
private suspend fun aggregateExerciseTime(
|
|
1137
1234
|
timeRange: TimeRangeFilter,
|
|
1138
1235
|
period: Period
|
|
@@ -1147,7 +1244,7 @@ class HealthPlugin : Plugin() {
|
|
|
1147
1244
|
ReadRecordsRequest(
|
|
1148
1245
|
recordType = ExerciseSessionRecord::class,
|
|
1149
1246
|
timeRangeFilter = timeRange,
|
|
1150
|
-
|
|
1247
|
+
dataOriginFilter = emptySet(),
|
|
1151
1248
|
ascendingOrder = true,
|
|
1152
1249
|
pageSize = 1000
|
|
1153
1250
|
)
|
|
@@ -1162,15 +1259,56 @@ class HealthPlugin : Plugin() {
|
|
|
1162
1259
|
session.segments.sumOf { it.endTime.epochSecond - it.startTime.epochSecond }
|
|
1163
1260
|
}
|
|
1164
1261
|
}
|
|
1262
|
+
val start = localDate.atStartOfDay()
|
|
1165
1263
|
AggregatedSample(
|
|
1166
|
-
|
|
1167
|
-
|
|
1264
|
+
start,
|
|
1265
|
+
start.plusDays(1),
|
|
1168
1266
|
totalSeconds / 60.0
|
|
1169
1267
|
)
|
|
1170
1268
|
}
|
|
1171
1269
|
.sortedBy { it.startDate }
|
|
1172
1270
|
}
|
|
1173
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
|
+
|
|
1174
1312
|
private suspend fun aggregateSleepSessions(
|
|
1175
1313
|
timeRange: TimeRangeFilter,
|
|
1176
1314
|
period: Period
|
|
@@ -1192,9 +1330,10 @@ class HealthPlugin : Plugin() {
|
|
|
1192
1330
|
.groupBy { it.startTime.atZone(ZoneId.systemDefault()).toLocalDate() }
|
|
1193
1331
|
.map { (localDate, sessions) ->
|
|
1194
1332
|
val totalSeconds = sessions.sumOf { it.endTime.epochSecond - it.startTime.epochSecond }
|
|
1333
|
+
val start = localDate.atStartOfDay()
|
|
1195
1334
|
AggregatedSample(
|
|
1196
|
-
|
|
1197
|
-
|
|
1335
|
+
start,
|
|
1336
|
+
start.plusDays(1),
|
|
1198
1337
|
totalSeconds.toDouble()
|
|
1199
1338
|
)
|
|
1200
1339
|
}
|
|
@@ -1225,7 +1364,13 @@ class HealthPlugin : Plugin() {
|
|
|
1225
1364
|
|
|
1226
1365
|
val timeRange = TimeRangeFilter.between(startDateTime, endDateTime)
|
|
1227
1366
|
val request =
|
|
1228
|
-
ReadRecordsRequest(
|
|
1367
|
+
ReadRecordsRequest(
|
|
1368
|
+
recordType = ExerciseSessionRecord::class,
|
|
1369
|
+
timeRangeFilter = timeRange,
|
|
1370
|
+
dataOriginFilter = emptySet(),
|
|
1371
|
+
ascendingOrder = true,
|
|
1372
|
+
pageSize = 1000
|
|
1373
|
+
)
|
|
1229
1374
|
|
|
1230
1375
|
CoroutineScope(Dispatchers.IO).launch {
|
|
1231
1376
|
try {
|
|
@@ -1318,7 +1463,7 @@ class HealthPlugin : Plugin() {
|
|
|
1318
1463
|
try {
|
|
1319
1464
|
val request = AggregateRequest(
|
|
1320
1465
|
setOf(metricAndMapper.metric),
|
|
1321
|
-
TimeRangeFilter.
|
|
1466
|
+
TimeRangeFilter.between(workout.startTime, workout.endTime),
|
|
1322
1467
|
emptySet()
|
|
1323
1468
|
)
|
|
1324
1469
|
val aggregation = healthConnectClient.aggregate(request)
|