@flomentumsolutions/capacitor-health-extended 0.0.14 → 0.0.16

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.
@@ -7,14 +7,14 @@ ext {
7
7
 
8
8
  buildscript {
9
9
  ext {
10
- kotlin_version = '2.0.20'
10
+ kotlin_version = '2.2.0'
11
11
  }
12
12
  repositories {
13
13
  google()
14
14
  mavenCentral()
15
15
  }
16
16
  dependencies {
17
- classpath 'com.android.tools.build:gradle:8.10.1'
17
+ classpath 'com.android.tools.build:gradle:8.11.1'
18
18
  classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
19
19
  }
20
20
  }
@@ -26,7 +26,7 @@ android {
26
26
  namespace "com.flomentum.health.capacitor"
27
27
  compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
28
28
  defaultConfig {
29
- minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 26
29
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 36
30
30
  targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
31
31
  versionCode 1
32
32
  versionName "1.0"
@@ -61,7 +61,7 @@ dependencies {
61
61
  implementation project(':capacitor-android')
62
62
  implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
63
63
 
64
- implementation 'androidx.health.connect:connect-client:1.1.0-rc02'
64
+ implementation 'androidx.health.connect:connect-client:1.1.0-rc03'
65
65
  implementation 'androidx.core:core-ktx:1.13.1'
66
66
 
67
67
  testImplementation "junit:junit:$junitVersion"
@@ -83,7 +83,7 @@ class HealthPlugin : Plugin() {
83
83
  CapHealthPermission.READ_TOTAL_CALORIES to HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
84
84
  CapHealthPermission.READ_DISTANCE to HealthPermission.getReadPermission(DistanceRecord::class),
85
85
  CapHealthPermission.READ_WORKOUTS to HealthPermission.getReadPermission(ExerciseSessionRecord::class),
86
- CapHealthPermission.READ_HRV to HealthPermission.getReadPermission(HeartRateVariabilitySdnnRecord::class),
86
+ CapHealthPermission.READ_HRV to HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class),
87
87
  CapHealthPermission.READ_BLOOD_PRESSURE to HealthPermission.getReadPermission(BloodPressureRecord::class),
88
88
  CapHealthPermission.READ_ROUTE to HealthPermission.getReadPermission(ExerciseSessionRecord::class),
89
89
  CapHealthPermission.READ_MINDFULNESS to HealthPermission.getReadPermission(SleepSessionRecord::class)
@@ -275,7 +275,6 @@ class HealthPlugin : Plugin() {
275
275
  TotalCaloriesBurnedRecord.ENERGY_TOTAL
276
276
  ) { it?.inKilocalories }
277
277
  "distance" -> metricAndMapper("distance", CapHealthPermission.READ_DISTANCE, DistanceRecord.DISTANCE_TOTAL) { it?.inMeters }
278
- "hrv" -> metricAndMapper("hrv", CapHealthPermission.READ_HRV, HeartRateVariabilitySdnnRecord.SDNN_AVG) { it }
279
278
  else -> throw RuntimeException("Unsupported dataType: $dataType")
280
279
  }
281
280
  }
@@ -288,7 +287,11 @@ class HealthPlugin : Plugin() {
288
287
  call.reject("Missing required parameter: dataType")
289
288
  return
290
289
  }
290
+ queryLatestSampleInternal(call, dataType)
291
+ }
291
292
 
293
+ private fun queryLatestSampleInternal(call: PluginCall, dataType: String) {
294
+ if (!ensureClientInitialized(call)) return
292
295
  CoroutineScope(Dispatchers.IO).launch {
293
296
  try {
294
297
  val result = when (dataType) {
@@ -301,14 +304,11 @@ class HealthPlugin : Plugin() {
301
304
  "distance" -> readLatestDistance()
302
305
  "active-calories" -> readLatestActiveCalories()
303
306
  "total-calories" -> readLatestTotalCalories()
304
- else -> {
305
- call.reject("Unsupported data type: $dataType")
306
- return@launch
307
- }
307
+ else -> throw IllegalArgumentException("Unsupported data type: $dataType")
308
308
  }
309
309
  call.resolve(result)
310
310
  } catch (e: Exception) {
311
- Log.e(tag, "queryLatestSample: Error fetching latest $dataType", e)
311
+ Log.e(tag, "queryLatestSampleInternal: Error fetching latest $dataType", e)
312
312
  call.reject("Error fetching latest $dataType: ${e.message}")
313
313
  }
314
314
  }
@@ -317,26 +317,22 @@ class HealthPlugin : Plugin() {
317
317
  // Convenience methods for specific data types
318
318
  @PluginMethod
319
319
  fun queryWeight(call: PluginCall) {
320
- call.put("dataType", "weight")
321
- queryLatestSample(call)
320
+ queryLatestSampleInternal(call, "weight")
322
321
  }
323
322
 
324
323
  @PluginMethod
325
324
  fun queryHeight(call: PluginCall) {
326
- call.put("dataType", "height")
327
- queryLatestSample(call)
325
+ queryLatestSampleInternal(call, "height")
328
326
  }
329
327
 
330
328
  @PluginMethod
331
329
  fun queryHeartRate(call: PluginCall) {
332
- call.put("dataType", "heart-rate")
333
- queryLatestSample(call)
330
+ queryLatestSampleInternal(call, "heart-rate")
334
331
  }
335
332
 
336
333
  @PluginMethod
337
334
  fun querySteps(call: PluginCall) {
338
- call.put("dataType", "steps")
339
- queryLatestSample(call)
335
+ queryLatestSampleInternal(call, "steps")
340
336
  }
341
337
 
342
338
  private suspend fun readLatestHeartRate(): JSObject {
@@ -400,14 +396,14 @@ class HealthPlugin : Plugin() {
400
396
  throw Exception("Permission for HRV not granted")
401
397
  }
402
398
  val request = ReadRecordsRequest(
403
- recordType = HeartRateVariabilitySdnnRecord::class,
399
+ recordType = HeartRateVariabilityRmssdRecord::class,
404
400
  timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
405
401
  pageSize = 1
406
402
  )
407
403
  val result = healthConnectClient.readRecords(request)
408
404
  val record = result.records.firstOrNull() ?: throw Exception("No HRV data found")
409
405
  return JSObject().apply {
410
- put("value", record.sdnnMillis)
406
+ put("value", record.heartRateVariabilityMillis)
411
407
  put("timestamp", record.time.epochSecond * 1000) // Convert to milliseconds like iOS
412
408
  put("unit", "ms")
413
409
  }
@@ -463,7 +459,7 @@ class HealthPlugin : Plugin() {
463
459
  val record = result.records.firstOrNull() ?: throw Exception("No distance data found")
464
460
  return JSObject().apply {
465
461
  put("value", record.distance.inMeters)
466
- put("timestamp", record.time.epochSecond * 1000) // Convert to milliseconds like iOS
462
+ put("timestamp", record.endTime.epochSecond * 1000) // Convert to milliseconds like iOS
467
463
  put("unit", "m")
468
464
  }
469
465
  }
@@ -480,8 +476,8 @@ class HealthPlugin : Plugin() {
480
476
  val result = healthConnectClient.readRecords(request)
481
477
  val record = result.records.firstOrNull() ?: throw Exception("No active calories data found")
482
478
  return JSObject().apply {
483
- put("value", record.activeCalories.inKilocalories)
484
- put("timestamp", record.time.epochSecond * 1000) // Convert to milliseconds like iOS
479
+ put("value", record.energy.inKilocalories)
480
+ put("timestamp", record.endTime.epochSecond * 1000) // Convert to milliseconds like iOS
485
481
  put("unit", "kcal")
486
482
  }
487
483
  }
@@ -499,7 +495,7 @@ class HealthPlugin : Plugin() {
499
495
  val record = result.records.firstOrNull() ?: throw Exception("No total calories data found")
500
496
  return JSObject().apply {
501
497
  put("value", record.energy.inKilocalories)
502
- put("timestamp", record.time.epochSecond * 1000) // Convert to milliseconds like iOS
498
+ put("timestamp", record.endTime.epochSecond * 1000) // Convert to milliseconds like iOS
503
499
  put("unit", "kcal")
504
500
  }
505
501
  }
@@ -521,16 +517,41 @@ class HealthPlugin : Plugin() {
521
517
  val startDateTime = Instant.parse(startDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
522
518
  val endDateTime = Instant.parse(endDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
523
519
 
524
- val metricAndMapper = getMetricAndMapper(dataType)
525
-
526
520
  val period = when (bucket) {
527
521
  "day" -> Period.ofDays(1)
528
522
  else -> throw RuntimeException("Unsupported bucket: $bucket")
529
523
  }
530
524
 
525
+ // Special handling for HRV (RMSSD) because aggregate metrics were
526
+ // removed in Health Connect 1.1‑rc03. We calculate the daily average
527
+ // from raw samples instead.
528
+ if (dataType == "hrv") {
529
+ CoroutineScope(Dispatchers.IO).launch {
530
+ try {
531
+ val hrvSamples = aggregateHrvByPeriod(
532
+ TimeRangeFilter.between(startDateTime, endDateTime),
533
+ period
534
+ )
535
+ val aggregatedList = JSArray()
536
+ hrvSamples.forEach { aggregatedList.put(it.toJs()) }
537
+ val finalResult = JSObject()
538
+ finalResult.put("aggregatedData", aggregatedList)
539
+ call.resolve(finalResult)
540
+ } catch (e: Exception) {
541
+ call.reject("Error querying aggregated HRV data: ${e.message}")
542
+ }
543
+ }
544
+ return // skip the normal aggregate path
545
+ }
546
+
531
547
  CoroutineScope(Dispatchers.IO).launch {
532
548
  try {
533
- val r = queryAggregatedMetric(metricAndMapper, TimeRangeFilter.between(startDateTime, endDateTime), period)
549
+ val metricAndMapper = getMetricAndMapper(dataType)
550
+ val r = queryAggregatedMetric(
551
+ metricAndMapper,
552
+ TimeRangeFilter.between(startDateTime, endDateTime),
553
+ period
554
+ )
534
555
  val aggregatedList = JSArray()
535
556
  r.forEach { aggregatedList.put(it.toJs()) }
536
557
  val finalResult = JSObject()
@@ -602,13 +623,46 @@ class HealthPlugin : Plugin() {
602
623
 
603
624
  }
604
625
 
626
+ private suspend fun aggregateHrvByPeriod(
627
+ timeRange: TimeRangeFilter,
628
+ period: Period
629
+ ): List<AggregatedSample> {
630
+ if (!hasPermission(CapHealthPermission.READ_HRV)) {
631
+ return emptyList()
632
+ }
633
+
634
+ // Currently only daily buckets are supported.
635
+ if (period != Period.ofDays(1)) {
636
+ throw RuntimeException("Unsupported bucket for HRV aggregation")
637
+ }
638
+
639
+ val response = healthConnectClient.readRecords(
640
+ ReadRecordsRequest(
641
+ recordType = HeartRateVariabilityRmssdRecord::class,
642
+ timeRangeFilter = timeRange
643
+ )
644
+ )
645
+
646
+ // Group raw RMSSD samples by local date and compute the arithmetic mean.
647
+ return response.records
648
+ .groupBy { it.time.atZone(ZoneId.systemDefault()).toLocalDate() }
649
+ .map { (localDate, recs) ->
650
+ val avg = recs.map { it.heartRateVariabilityMillis }.average()
651
+ AggregatedSample(
652
+ localDate.atStartOfDay(),
653
+ localDate.plusDays(1).atStartOfDay(),
654
+ if (avg.isNaN()) null else avg
655
+ )
656
+ }
657
+ .sortedBy { it.startDate }
658
+ }
659
+
605
660
  private suspend fun hasPermission(p: CapHealthPermission): Boolean {
606
661
  val granted = healthConnectClient.permissionController.getGrantedPermissions()
607
662
  val targetPermission = permissionMapping[p]
608
663
  return granted.contains(targetPermission)
609
664
  }
610
665
 
611
-
612
666
  @PluginMethod
613
667
  fun queryWorkouts(call: PluginCall) {
614
668
  if (!ensureClientInitialized(call)) return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flomentumsolutions/capacitor-health-extended",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Capacitor plugin for Apple HealthKit and Google Health Connect Platform",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",