@capgo/capacitor-health 8.6.2 → 8.6.4

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
- // Determine bucket size
721
- // Note: Monthly buckets use 30 days as an approximation, which may not align exactly
722
- // with calendar months. This provides consistent bucket sizes but users should be aware
723
- // that "month" buckets don't correspond to actual calendar months (Jan, Feb, etc.).
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
- "month" -> Duration.ofDays(30) // Approximation: not calendar months
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(bucketDuration).let {
831
+ val currentEnd = currentStart.plus(maxChunkDuration).let {
736
832
  if (it.isAfter(endTime)) endTime else it
737
833
  }
738
-
739
- try {
740
- val metrics = when (dataType) {
741
- HealthDataType.STEPS -> setOf(StepsRecord.COUNT_TOTAL)
742
- HealthDataType.DISTANCE -> setOf(DistanceRecord.DISTANCE_TOTAL)
743
- HealthDataType.CALORIES -> setOf(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)
744
- HealthDataType.HEART_RATE -> setOf(HeartRateRecord.BPM_AVG, HeartRateRecord.BPM_MAX, HeartRateRecord.BPM_MIN)
745
- HealthDataType.WEIGHT -> setOf(WeightRecord.WEIGHT_AVG, WeightRecord.WEIGHT_MAX, WeightRecord.WEIGHT_MIN)
746
- HealthDataType.RESTING_HEART_RATE -> setOf(RestingHeartRateRecord.BPM_AVG, RestingHeartRateRecord.BPM_MAX, RestingHeartRateRecord.BPM_MIN)
747
- else -> throw IllegalArgumentException("Unsupported data type for aggregation: ${dataType.identifier}")
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(currentStart))
796
- put("endDate", formatter.format(currentEnd))
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") ?: "sum"
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
- call.reject(e.message ?: "Failed to query aggregated data.", null, e)
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.2"
6
+ private let pluginVersion: String = "8.6.4"
7
7
  public let identifier = "HealthPlugin"
8
8
  public let jsName = "Health"
9
9
  public let pluginMethods: [CAPPluginMethod] = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-health",
3
- "version": "8.6.2",
3
+ "version": "8.6.4",
4
4
  "description": "Capacitor plugin to interact with data from Apple HealthKit and Health Connect",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",
@@ -44,8 +44,11 @@
44
44
  "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
45
45
  "clean": "rimraf ./dist",
46
46
  "watch": "tsc --watch",
47
- "prepublishOnly": "npm run build",
48
- "check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs"
47
+ "prepublishOnly": "bun run build",
48
+ "check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs",
49
+ "example:install": "cd example-app && bun install --frozen-lockfile",
50
+ "example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build",
51
+ "example:capgo:deploy": "bun run example:build && bun scripts/deploy-example-capgo.mjs"
49
52
  },
50
53
  "devDependencies": {
51
54
  "@capacitor/android": "^8.0.0",