@flomentumsolutions/capacitor-health-extended 0.3.1 → 0.4.2

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.
@@ -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.units.Energy
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 (e: Exception) {
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?.toDouble() }
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
- ) { (it as Energy?)?.kilocalories }
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
- ) { (it as Long?)?.toDouble() }
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 ?: 0)
457
- put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
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 ?: 0)
529
- put("timestamp", (record?.time?.epochSecond ?: 0) * 1000)
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?.milligramsPerDeciliter ?: 0.0)
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?.celsius ?: 0.0)
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?.celsius ?: 0.0)
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?.kilocaloriesPerDay ?: 0.0)
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 = Instant.parse(startDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
833
- val endDateTime = Instant.parse(endDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
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
- // Special handling for HRV (RMSSD) because aggregate metrics were
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
- "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 }
851
+ CoroutineScope(Dispatchers.IO).launch {
852
+ try {
853
+ val aggregatedList = JSArray()
960
854
 
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 }
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
- "body-fat" -> aggregateInstantAverage(
969
- BodyFatRecord::class,
970
- CapHealthPermission.READ_BODY_FAT,
971
- TimeRangeFilter.between(startDateTime, endDateTime),
954
+ else -> {
955
+ val metricAndMapper = getMetricAndMapper(dataType)
956
+ val r = queryAggregatedMetric(
957
+ metricAndMapper,
958
+ timeRange,
972
959
  period
973
- ) { it.percentage.value }
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
- localDate.atStartOfDay(),
1095
- localDate.plusDays(1).atStartOfDay(),
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 : InstantaneousRecord> aggregateInstantAverage(
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.time.atZone(ZoneId.systemDefault()).toLocalDate() }
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
- localDate.atStartOfDay(),
1129
- localDate.plusDays(1).atStartOfDay(),
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
- dataOriginsFilter = emptySet(),
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
- localDate.atStartOfDay(),
1167
- localDate.plusDays(1).atStartOfDay(),
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
- localDate.atStartOfDay(),
1197
- localDate.plusDays(1).atStartOfDay(),
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(ExerciseSessionRecord::class, timeRange, emptySet(), true, 1000)
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.Companion.between(workout.startTime, workout.endTime),
1466
+ TimeRangeFilter.between(workout.startTime, workout.endTime),
1322
1467
  emptySet()
1323
1468
  )
1324
1469
  val aggregation = healthConnectClient.aggregate(request)