@flomentumsolutions/capacitor-health-extended 0.1.0 → 0.4.1

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