@flomentumsolutions/capacitor-health-extended 0.0.15 → 0.0.17

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"
@@ -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)
@@ -144,13 +144,19 @@ class HealthPlugin : Plugin() {
144
144
  call.resolve(result)
145
145
  }
146
146
 
147
- // Helper to ensure client is initialized
147
+ // Helper to ensure HealthConnectClient is ready; attempts init lazily
148
148
  private fun ensureClientInitialized(call: PluginCall): Boolean {
149
- if (!available) {
150
- call.reject("Health Connect client not initialized. Call isHealthAvailable() first.")
151
- return false
149
+ if (available) return true
150
+
151
+ return try {
152
+ healthConnectClient = HealthConnectClient.getOrCreate(context)
153
+ available = true
154
+ true
155
+ } catch (e: Exception) {
156
+ Log.e(tag, "Failed to initialise HealthConnectClient", e)
157
+ call.reject("Health Connect is not available on this device.")
158
+ false
152
159
  }
153
- return true
154
160
  }
155
161
 
156
162
  // Check if a set of permissions are granted
@@ -275,7 +281,6 @@ class HealthPlugin : Plugin() {
275
281
  TotalCaloriesBurnedRecord.ENERGY_TOTAL
276
282
  ) { it?.inKilocalories }
277
283
  "distance" -> metricAndMapper("distance", CapHealthPermission.READ_DISTANCE, DistanceRecord.DISTANCE_TOTAL) { it?.inMeters }
278
- "hrv" -> metricAndMapper("hrv", CapHealthPermission.READ_HRV, HeartRateVariabilitySdnnRecord.SDNN_AVG) { it }
279
284
  else -> throw RuntimeException("Unsupported dataType: $dataType")
280
285
  }
281
286
  }
@@ -288,7 +293,11 @@ class HealthPlugin : Plugin() {
288
293
  call.reject("Missing required parameter: dataType")
289
294
  return
290
295
  }
296
+ queryLatestSampleInternal(call, dataType)
297
+ }
291
298
 
299
+ private fun queryLatestSampleInternal(call: PluginCall, dataType: String) {
300
+ if (!ensureClientInitialized(call)) return
292
301
  CoroutineScope(Dispatchers.IO).launch {
293
302
  try {
294
303
  val result = when (dataType) {
@@ -301,14 +310,11 @@ class HealthPlugin : Plugin() {
301
310
  "distance" -> readLatestDistance()
302
311
  "active-calories" -> readLatestActiveCalories()
303
312
  "total-calories" -> readLatestTotalCalories()
304
- else -> {
305
- call.reject("Unsupported data type: $dataType")
306
- return@launch
307
- }
313
+ else -> throw IllegalArgumentException("Unsupported data type: $dataType")
308
314
  }
309
315
  call.resolve(result)
310
316
  } catch (e: Exception) {
311
- Log.e(tag, "queryLatestSample: Error fetching latest $dataType", e)
317
+ Log.e(tag, "queryLatestSampleInternal: Error fetching latest $dataType", e)
312
318
  call.reject("Error fetching latest $dataType: ${e.message}")
313
319
  }
314
320
  }
@@ -317,26 +323,22 @@ class HealthPlugin : Plugin() {
317
323
  // Convenience methods for specific data types
318
324
  @PluginMethod
319
325
  fun queryWeight(call: PluginCall) {
320
- call.put("dataType", "weight")
321
- queryLatestSample(call)
326
+ queryLatestSampleInternal(call, "weight")
322
327
  }
323
328
 
324
329
  @PluginMethod
325
330
  fun queryHeight(call: PluginCall) {
326
- call.put("dataType", "height")
327
- queryLatestSample(call)
331
+ queryLatestSampleInternal(call, "height")
328
332
  }
329
333
 
330
334
  @PluginMethod
331
335
  fun queryHeartRate(call: PluginCall) {
332
- call.put("dataType", "heart-rate")
333
- queryLatestSample(call)
336
+ queryLatestSampleInternal(call, "heart-rate")
334
337
  }
335
338
 
336
339
  @PluginMethod
337
340
  fun querySteps(call: PluginCall) {
338
- call.put("dataType", "steps")
339
- queryLatestSample(call)
341
+ queryLatestSampleInternal(call, "steps")
340
342
  }
341
343
 
342
344
  private suspend fun readLatestHeartRate(): JSObject {
@@ -400,14 +402,14 @@ class HealthPlugin : Plugin() {
400
402
  throw Exception("Permission for HRV not granted")
401
403
  }
402
404
  val request = ReadRecordsRequest(
403
- recordType = HeartRateVariabilitySdnnRecord::class,
405
+ recordType = HeartRateVariabilityRmssdRecord::class,
404
406
  timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
405
407
  pageSize = 1
406
408
  )
407
409
  val result = healthConnectClient.readRecords(request)
408
410
  val record = result.records.firstOrNull() ?: throw Exception("No HRV data found")
409
411
  return JSObject().apply {
410
- put("value", record.sdnnMillis)
412
+ put("value", record.heartRateVariabilityMillis)
411
413
  put("timestamp", record.time.epochSecond * 1000) // Convert to milliseconds like iOS
412
414
  put("unit", "ms")
413
415
  }
@@ -463,7 +465,7 @@ class HealthPlugin : Plugin() {
463
465
  val record = result.records.firstOrNull() ?: throw Exception("No distance data found")
464
466
  return JSObject().apply {
465
467
  put("value", record.distance.inMeters)
466
- put("timestamp", record.time.epochSecond * 1000) // Convert to milliseconds like iOS
468
+ put("timestamp", record.endTime.epochSecond * 1000) // Convert to milliseconds like iOS
467
469
  put("unit", "m")
468
470
  }
469
471
  }
@@ -480,8 +482,8 @@ class HealthPlugin : Plugin() {
480
482
  val result = healthConnectClient.readRecords(request)
481
483
  val record = result.records.firstOrNull() ?: throw Exception("No active calories data found")
482
484
  return JSObject().apply {
483
- put("value", record.activeCalories.inKilocalories)
484
- put("timestamp", record.time.epochSecond * 1000) // Convert to milliseconds like iOS
485
+ put("value", record.energy.inKilocalories)
486
+ put("timestamp", record.endTime.epochSecond * 1000) // Convert to milliseconds like iOS
485
487
  put("unit", "kcal")
486
488
  }
487
489
  }
@@ -499,7 +501,7 @@ class HealthPlugin : Plugin() {
499
501
  val record = result.records.firstOrNull() ?: throw Exception("No total calories data found")
500
502
  return JSObject().apply {
501
503
  put("value", record.energy.inKilocalories)
502
- put("timestamp", record.time.epochSecond * 1000) // Convert to milliseconds like iOS
504
+ put("timestamp", record.endTime.epochSecond * 1000) // Convert to milliseconds like iOS
503
505
  put("unit", "kcal")
504
506
  }
505
507
  }
@@ -521,16 +523,41 @@ class HealthPlugin : Plugin() {
521
523
  val startDateTime = Instant.parse(startDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
522
524
  val endDateTime = Instant.parse(endDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
523
525
 
524
- val metricAndMapper = getMetricAndMapper(dataType)
525
-
526
526
  val period = when (bucket) {
527
527
  "day" -> Period.ofDays(1)
528
528
  else -> throw RuntimeException("Unsupported bucket: $bucket")
529
529
  }
530
530
 
531
+ // Special handling for HRV (RMSSD) because aggregate metrics were
532
+ // removed in Health Connect 1.1‑rc03. We calculate the daily average
533
+ // from raw samples instead.
534
+ if (dataType == "hrv") {
535
+ CoroutineScope(Dispatchers.IO).launch {
536
+ try {
537
+ val hrvSamples = aggregateHrvByPeriod(
538
+ TimeRangeFilter.between(startDateTime, endDateTime),
539
+ period
540
+ )
541
+ val aggregatedList = JSArray()
542
+ hrvSamples.forEach { aggregatedList.put(it.toJs()) }
543
+ val finalResult = JSObject()
544
+ finalResult.put("aggregatedData", aggregatedList)
545
+ call.resolve(finalResult)
546
+ } catch (e: Exception) {
547
+ call.reject("Error querying aggregated HRV data: ${e.message}")
548
+ }
549
+ }
550
+ return // skip the normal aggregate path
551
+ }
552
+
531
553
  CoroutineScope(Dispatchers.IO).launch {
532
554
  try {
533
- val r = queryAggregatedMetric(metricAndMapper, TimeRangeFilter.between(startDateTime, endDateTime), period)
555
+ val metricAndMapper = getMetricAndMapper(dataType)
556
+ val r = queryAggregatedMetric(
557
+ metricAndMapper,
558
+ TimeRangeFilter.between(startDateTime, endDateTime),
559
+ period
560
+ )
534
561
  val aggregatedList = JSArray()
535
562
  r.forEach { aggregatedList.put(it.toJs()) }
536
563
  val finalResult = JSObject()
@@ -602,13 +629,46 @@ class HealthPlugin : Plugin() {
602
629
 
603
630
  }
604
631
 
632
+ private suspend fun aggregateHrvByPeriod(
633
+ timeRange: TimeRangeFilter,
634
+ period: Period
635
+ ): List<AggregatedSample> {
636
+ if (!hasPermission(CapHealthPermission.READ_HRV)) {
637
+ return emptyList()
638
+ }
639
+
640
+ // Currently only daily buckets are supported.
641
+ if (period != Period.ofDays(1)) {
642
+ throw RuntimeException("Unsupported bucket for HRV aggregation")
643
+ }
644
+
645
+ val response = healthConnectClient.readRecords(
646
+ ReadRecordsRequest(
647
+ recordType = HeartRateVariabilityRmssdRecord::class,
648
+ timeRangeFilter = timeRange
649
+ )
650
+ )
651
+
652
+ // Group raw RMSSD samples by local date and compute the arithmetic mean.
653
+ return response.records
654
+ .groupBy { it.time.atZone(ZoneId.systemDefault()).toLocalDate() }
655
+ .map { (localDate, recs) ->
656
+ val avg = recs.map { it.heartRateVariabilityMillis }.average()
657
+ AggregatedSample(
658
+ localDate.atStartOfDay(),
659
+ localDate.plusDays(1).atStartOfDay(),
660
+ if (avg.isNaN()) null else avg
661
+ )
662
+ }
663
+ .sortedBy { it.startDate }
664
+ }
665
+
605
666
  private suspend fun hasPermission(p: CapHealthPermission): Boolean {
606
667
  val granted = healthConnectClient.permissionController.getGrantedPermissions()
607
668
  val targetPermission = permissionMapping[p]
608
669
  return granted.contains(targetPermission)
609
670
  }
610
671
 
611
-
612
672
  @PluginMethod
613
673
  fun queryWorkouts(call: PluginCall) {
614
674
  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.15",
3
+ "version": "0.0.17",
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",