@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.
@@ -1,4 +1,4 @@
1
- package com.flomentum.health.capacitor
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, READ_WORKOUTS, READ_HEART_RATE, READ_ACTIVE_CALORIES, READ_TOTAL_CALORIES, READ_DISTANCE, READ_WEIGHT
36
- , READ_HRV, READ_BLOOD_PRESSURE, READ_HEIGHT, READ_ROUTE, READ_MINDFULNESS;
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.READ_SLEEP"])
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(SleepSessionRecord::class)
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
+ }