@capgo/capacitor-health 8.6.1 → 8.6.3
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,8 +1,11 @@
|
|
|
1
1
|
package app.capgo.plugin.health
|
|
2
2
|
|
|
3
3
|
import androidx.health.connect.client.HealthConnectClient
|
|
4
|
+
import androidx.health.connect.client.aggregate.AggregationResult
|
|
4
5
|
import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi
|
|
5
6
|
import androidx.health.connect.client.permission.HealthPermission
|
|
7
|
+
import androidx.health.connect.client.request.AggregateGroupByDurationRequest
|
|
8
|
+
import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
|
|
6
9
|
import androidx.health.connect.client.request.AggregateRequest
|
|
7
10
|
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
|
8
11
|
import androidx.health.connect.client.records.BasalBodyTemperatureRecord
|
|
@@ -39,6 +42,8 @@ import com.getcapacitor.JSArray
|
|
|
39
42
|
import com.getcapacitor.JSObject
|
|
40
43
|
import java.time.Duration
|
|
41
44
|
import java.time.Instant
|
|
45
|
+
import java.time.LocalDateTime
|
|
46
|
+
import java.time.Period
|
|
42
47
|
import java.time.ZoneId
|
|
43
48
|
import java.time.ZoneOffset
|
|
44
49
|
import java.time.format.DateTimeFormatter
|
|
@@ -693,6 +698,59 @@ private fun createSamplePayload(
|
|
|
693
698
|
return java.lang.Math.round(this.coerceAtLeast(0.0))
|
|
694
699
|
}
|
|
695
700
|
|
|
701
|
+
private fun validateAggregation(dataType: HealthDataType, aggregation: String) {
|
|
702
|
+
val supportedAggregations = when (dataType) {
|
|
703
|
+
HealthDataType.STEPS,
|
|
704
|
+
HealthDataType.DISTANCE,
|
|
705
|
+
HealthDataType.CALORIES -> setOf("sum")
|
|
706
|
+
HealthDataType.HEART_RATE,
|
|
707
|
+
HealthDataType.WEIGHT,
|
|
708
|
+
HealthDataType.RESTING_HEART_RATE -> setOf("average", "min", "max")
|
|
709
|
+
else -> emptySet()
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (aggregation !in supportedAggregations) {
|
|
713
|
+
throw IllegalArgumentException("Unsupported aggregation '$aggregation' for ${dataType.identifier}")
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private fun aggregateMetrics(dataType: HealthDataType) = when (dataType) {
|
|
718
|
+
HealthDataType.STEPS -> setOf(StepsRecord.COUNT_TOTAL)
|
|
719
|
+
HealthDataType.DISTANCE -> setOf(DistanceRecord.DISTANCE_TOTAL)
|
|
720
|
+
HealthDataType.CALORIES -> setOf(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)
|
|
721
|
+
HealthDataType.HEART_RATE -> setOf(HeartRateRecord.BPM_AVG, HeartRateRecord.BPM_MAX, HeartRateRecord.BPM_MIN)
|
|
722
|
+
HealthDataType.WEIGHT -> setOf(WeightRecord.WEIGHT_AVG, WeightRecord.WEIGHT_MAX, WeightRecord.WEIGHT_MIN)
|
|
723
|
+
HealthDataType.RESTING_HEART_RATE -> setOf(RestingHeartRateRecord.BPM_AVG, RestingHeartRateRecord.BPM_MAX, RestingHeartRateRecord.BPM_MIN)
|
|
724
|
+
else -> throw IllegalArgumentException("Unsupported data type for aggregation: ${dataType.identifier}")
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private fun aggregateValue(dataType: HealthDataType, aggregation: String, result: AggregationResult): Double? {
|
|
728
|
+
return when (dataType) {
|
|
729
|
+
HealthDataType.STEPS -> result[StepsRecord.COUNT_TOTAL]?.toDouble()
|
|
730
|
+
HealthDataType.DISTANCE -> result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
|
|
731
|
+
HealthDataType.CALORIES -> result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
|
|
732
|
+
HealthDataType.HEART_RATE -> when (aggregation) {
|
|
733
|
+
"average" -> result[HeartRateRecord.BPM_AVG]?.toDouble()
|
|
734
|
+
"max" -> result[HeartRateRecord.BPM_MAX]?.toDouble()
|
|
735
|
+
"min" -> result[HeartRateRecord.BPM_MIN]?.toDouble()
|
|
736
|
+
else -> null
|
|
737
|
+
}
|
|
738
|
+
HealthDataType.WEIGHT -> when (aggregation) {
|
|
739
|
+
"average" -> result[WeightRecord.WEIGHT_AVG]?.inKilograms
|
|
740
|
+
"max" -> result[WeightRecord.WEIGHT_MAX]?.inKilograms
|
|
741
|
+
"min" -> result[WeightRecord.WEIGHT_MIN]?.inKilograms
|
|
742
|
+
else -> null
|
|
743
|
+
}
|
|
744
|
+
HealthDataType.RESTING_HEART_RATE -> when (aggregation) {
|
|
745
|
+
"average" -> result[RestingHeartRateRecord.BPM_AVG]?.toDouble()
|
|
746
|
+
"max" -> result[RestingHeartRateRecord.BPM_MAX]?.toDouble()
|
|
747
|
+
"min" -> result[RestingHeartRateRecord.BPM_MIN]?.toDouble()
|
|
748
|
+
else -> null
|
|
749
|
+
}
|
|
750
|
+
else -> null
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
696
754
|
suspend fun queryAggregated(
|
|
697
755
|
client: HealthConnectClient,
|
|
698
756
|
dataType: HealthDataType,
|
|
@@ -715,96 +773,85 @@ private fun createSamplePayload(
|
|
|
715
773
|
throw IllegalArgumentException("Aggregated queries are not supported for ${dataType.identifier}. Use readSamples instead.")
|
|
716
774
|
}
|
|
717
775
|
|
|
776
|
+
validateAggregation(dataType, aggregation)
|
|
777
|
+
|
|
778
|
+
val metrics = aggregateMetrics(dataType)
|
|
718
779
|
val samples = JSArray()
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
780
|
+
|
|
781
|
+
if (bucket == "month") {
|
|
782
|
+
val zoneId = ZoneId.systemDefault()
|
|
783
|
+
val endDateTime = LocalDateTime.ofInstant(endTime, zoneId)
|
|
784
|
+
var currentStart = LocalDateTime.ofInstant(startTime, zoneId)
|
|
785
|
+
|
|
786
|
+
while (currentStart.isBefore(endDateTime)) {
|
|
787
|
+
val currentEnd = currentStart.plusMonths(MAX_AGGREGATE_GROUP_BUCKETS.toLong()).let {
|
|
788
|
+
if (it.isAfter(endDateTime)) endDateTime else it
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
val aggregateRequest = AggregateGroupByPeriodRequest(
|
|
792
|
+
metrics = metrics,
|
|
793
|
+
timeRangeFilter = TimeRangeFilter.between(currentStart, currentEnd),
|
|
794
|
+
timeRangeSlicer = Period.ofMonths(1)
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
val groupedResults = client.aggregateGroupByPeriod(aggregateRequest)
|
|
798
|
+
|
|
799
|
+
for (groupedResult in groupedResults) {
|
|
800
|
+
val value = aggregateValue(dataType, aggregation, groupedResult.result)
|
|
801
|
+
if (value != null) {
|
|
802
|
+
samples.put(JSObject().apply {
|
|
803
|
+
put("startDate", formatter.format(groupedResult.startTime.atZone(zoneId).toInstant()))
|
|
804
|
+
put("endDate", formatter.format(groupedResult.endTime.atZone(zoneId).toInstant()))
|
|
805
|
+
put("value", value)
|
|
806
|
+
put("unit", dataType.unit)
|
|
807
|
+
})
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
currentStart = currentEnd
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return JSObject().apply {
|
|
815
|
+
put("samples", samples)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
724
819
|
val bucketDuration = when (bucket) {
|
|
725
820
|
"hour" -> Duration.ofHours(1)
|
|
726
821
|
"day" -> Duration.ofDays(1)
|
|
727
822
|
"week" -> Duration.ofDays(7)
|
|
728
|
-
|
|
729
|
-
else -> Duration.ofDays(1)
|
|
823
|
+
else -> throw IllegalArgumentException("Unsupported bucket: $bucket")
|
|
730
824
|
}
|
|
825
|
+
|
|
826
|
+
// Health Connect rejects grouped aggregation requests with more than 5000 buckets.
|
|
827
|
+
val maxChunkDuration = bucketDuration.multipliedBy(MAX_AGGREGATE_GROUP_BUCKETS.toLong())
|
|
731
828
|
|
|
732
|
-
// Create time buckets
|
|
733
829
|
var currentStart = startTime
|
|
734
830
|
while (currentStart.isBefore(endTime)) {
|
|
735
|
-
val currentEnd = currentStart.plus(
|
|
831
|
+
val currentEnd = currentStart.plus(maxChunkDuration).let {
|
|
736
832
|
if (it.isAfter(endTime)) endTime else it
|
|
737
833
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
val aggregateRequest = AggregateRequest(
|
|
751
|
-
metrics = metrics,
|
|
752
|
-
timeRangeFilter = TimeRangeFilter.between(currentStart, currentEnd)
|
|
753
|
-
)
|
|
754
|
-
|
|
755
|
-
val result = client.aggregate(aggregateRequest)
|
|
756
|
-
|
|
757
|
-
// Extract the appropriate aggregated value based on the aggregation type and data type
|
|
758
|
-
val value: Double? = when (dataType) {
|
|
759
|
-
HealthDataType.STEPS -> when (aggregation) {
|
|
760
|
-
"sum" -> result[StepsRecord.COUNT_TOTAL]?.toDouble()
|
|
761
|
-
else -> result[StepsRecord.COUNT_TOTAL]?.toDouble()
|
|
762
|
-
}
|
|
763
|
-
HealthDataType.DISTANCE -> when (aggregation) {
|
|
764
|
-
"sum" -> result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
|
|
765
|
-
else -> result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
|
|
766
|
-
}
|
|
767
|
-
HealthDataType.CALORIES -> when (aggregation) {
|
|
768
|
-
"sum" -> result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
|
|
769
|
-
else -> result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
|
|
770
|
-
}
|
|
771
|
-
HealthDataType.HEART_RATE -> when (aggregation) {
|
|
772
|
-
"average" -> result[HeartRateRecord.BPM_AVG]?.toDouble()
|
|
773
|
-
"max" -> result[HeartRateRecord.BPM_MAX]?.toDouble()
|
|
774
|
-
"min" -> result[HeartRateRecord.BPM_MIN]?.toDouble()
|
|
775
|
-
else -> result[HeartRateRecord.BPM_AVG]?.toDouble()
|
|
776
|
-
}
|
|
777
|
-
HealthDataType.WEIGHT -> when (aggregation) {
|
|
778
|
-
"average" -> result[WeightRecord.WEIGHT_AVG]?.inKilograms
|
|
779
|
-
"max" -> result[WeightRecord.WEIGHT_MAX]?.inKilograms
|
|
780
|
-
"min" -> result[WeightRecord.WEIGHT_MIN]?.inKilograms
|
|
781
|
-
else -> result[WeightRecord.WEIGHT_AVG]?.inKilograms
|
|
782
|
-
}
|
|
783
|
-
HealthDataType.RESTING_HEART_RATE -> when (aggregation) {
|
|
784
|
-
"average" -> result[RestingHeartRateRecord.BPM_AVG]?.toDouble()
|
|
785
|
-
"max" -> result[RestingHeartRateRecord.BPM_MAX]?.toDouble()
|
|
786
|
-
"min" -> result[RestingHeartRateRecord.BPM_MIN]?.toDouble()
|
|
787
|
-
else -> result[RestingHeartRateRecord.BPM_AVG]?.toDouble()
|
|
788
|
-
}
|
|
789
|
-
else -> null
|
|
790
|
-
}
|
|
791
|
-
|
|
834
|
+
|
|
835
|
+
val aggregateRequest = AggregateGroupByDurationRequest(
|
|
836
|
+
metrics = metrics,
|
|
837
|
+
timeRangeFilter = TimeRangeFilter.between(currentStart, currentEnd),
|
|
838
|
+
timeRangeSlicer = bucketDuration
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
val groupedResults = client.aggregateGroupByDuration(aggregateRequest)
|
|
842
|
+
|
|
843
|
+
for (groupedResult in groupedResults) {
|
|
844
|
+
val value = aggregateValue(dataType, aggregation, groupedResult.result)
|
|
792
845
|
// Only add the sample if we have a value
|
|
793
846
|
if (value != null) {
|
|
794
847
|
val sample = JSObject().apply {
|
|
795
|
-
put("startDate", formatter.format(
|
|
796
|
-
put("endDate", formatter.format(
|
|
848
|
+
put("startDate", formatter.format(groupedResult.startTime))
|
|
849
|
+
put("endDate", formatter.format(groupedResult.endTime))
|
|
797
850
|
put("value", value)
|
|
798
851
|
put("unit", dataType.unit)
|
|
799
852
|
}
|
|
800
853
|
samples.put(sample)
|
|
801
854
|
}
|
|
802
|
-
} catch (e: CancellationException) {
|
|
803
|
-
throw e
|
|
804
|
-
} catch (e: SecurityException) {
|
|
805
|
-
android.util.Log.d("HealthManager", "Permission denied for aggregation: ${e.message}", e)
|
|
806
|
-
} catch (e: Exception) {
|
|
807
|
-
android.util.Log.d("HealthManager", "Aggregation failed for bucket: ${e.message}", e)
|
|
808
855
|
}
|
|
809
856
|
|
|
810
857
|
currentStart = currentEnd
|
|
@@ -987,5 +1034,6 @@ private fun createSamplePayload(
|
|
|
987
1034
|
companion object {
|
|
988
1035
|
private const val DEFAULT_PAGE_SIZE = 100
|
|
989
1036
|
private const val MAX_PAGE_SIZE = 500
|
|
1037
|
+
private const val MAX_AGGREGATE_GROUP_BUCKETS = 5000
|
|
990
1038
|
}
|
|
991
1039
|
}
|
|
@@ -3,6 +3,7 @@ package app.capgo.plugin.health
|
|
|
3
3
|
import android.app.Activity
|
|
4
4
|
import android.content.Intent
|
|
5
5
|
import android.net.Uri
|
|
6
|
+
import android.util.Log
|
|
6
7
|
import androidx.activity.result.ActivityResult
|
|
7
8
|
import com.getcapacitor.JSArray
|
|
8
9
|
import com.getcapacitor.JSObject
|
|
@@ -16,6 +17,7 @@ import androidx.health.connect.client.PermissionController
|
|
|
16
17
|
import java.time.Instant
|
|
17
18
|
import java.time.Duration
|
|
18
19
|
import java.time.format.DateTimeParseException
|
|
20
|
+
import kotlinx.coroutines.CancellationException
|
|
19
21
|
import kotlinx.coroutines.CoroutineScope
|
|
20
22
|
import kotlinx.coroutines.Dispatchers
|
|
21
23
|
import kotlinx.coroutines.SupervisorJob
|
|
@@ -403,7 +405,15 @@ class HealthPlugin : Plugin() {
|
|
|
403
405
|
}
|
|
404
406
|
|
|
405
407
|
val bucket = call.getString("bucket") ?: "day"
|
|
406
|
-
val aggregation = call.getString("aggregation") ?:
|
|
408
|
+
val aggregation = call.getString("aggregation") ?: when (dataType) {
|
|
409
|
+
HealthDataType.STEPS,
|
|
410
|
+
HealthDataType.DISTANCE,
|
|
411
|
+
HealthDataType.CALORIES -> "sum"
|
|
412
|
+
HealthDataType.HEART_RATE,
|
|
413
|
+
HealthDataType.WEIGHT,
|
|
414
|
+
HealthDataType.RESTING_HEART_RATE -> "average"
|
|
415
|
+
else -> "sum"
|
|
416
|
+
}
|
|
407
417
|
|
|
408
418
|
val startInstant = try {
|
|
409
419
|
manager.parseInstant(call.getString("startDate"), Instant.now().minus(DEFAULT_PAST_DURATION))
|
|
@@ -429,10 +439,16 @@ class HealthPlugin : Plugin() {
|
|
|
429
439
|
try {
|
|
430
440
|
val result = manager.queryAggregated(client, dataType, startInstant, endInstant, bucket, aggregation)
|
|
431
441
|
call.resolve(result)
|
|
442
|
+
} catch (e: CancellationException) {
|
|
443
|
+
throw e
|
|
444
|
+
} catch (e: SecurityException) {
|
|
445
|
+
Log.w("HealthPlugin", "Permission denied for aggregation: ${e.message}", e)
|
|
446
|
+
call.reject(e.message ?: "Permission denied for aggregated data.", "permission-denied", e)
|
|
432
447
|
} catch (e: IllegalArgumentException) {
|
|
433
448
|
call.reject(e.message ?: "Unsupported aggregation.", null, e)
|
|
434
449
|
} catch (e: Exception) {
|
|
435
|
-
|
|
450
|
+
Log.w("HealthPlugin", "Aggregation failed: ${e.message}", e)
|
|
451
|
+
call.reject(e.message ?: "Failed to query aggregated data.", "query-aggregated-failed", e)
|
|
436
452
|
}
|
|
437
453
|
}
|
|
438
454
|
}
|
|
@@ -3,7 +3,7 @@ import Capacitor
|
|
|
3
3
|
|
|
4
4
|
@objc(HealthPlugin)
|
|
5
5
|
public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
6
|
-
private let pluginVersion: String = "8.6.
|
|
6
|
+
private let pluginVersion: String = "8.6.3"
|
|
7
7
|
public let identifier = "HealthPlugin"
|
|
8
8
|
public let jsName = "Health"
|
|
9
9
|
public let pluginMethods: [CAPPluginMethod] = [
|
package/package.json
CHANGED