@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.
package/android/build.gradle
CHANGED
|
@@ -7,14 +7,14 @@ ext {
|
|
|
7
7
|
|
|
8
8
|
buildscript {
|
|
9
9
|
ext {
|
|
10
|
-
kotlin_version = '2.0
|
|
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.
|
|
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 :
|
|
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(
|
|
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
|
|
147
|
+
// Helper to ensure HealthConnectClient is ready; attempts init lazily
|
|
148
148
|
private fun ensureClientInitialized(call: PluginCall): Boolean {
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
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, "
|
|
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
|
|
321
|
-
queryLatestSample(call)
|
|
326
|
+
queryLatestSampleInternal(call, "weight")
|
|
322
327
|
}
|
|
323
328
|
|
|
324
329
|
@PluginMethod
|
|
325
330
|
fun queryHeight(call: PluginCall) {
|
|
326
|
-
call
|
|
327
|
-
queryLatestSample(call)
|
|
331
|
+
queryLatestSampleInternal(call, "height")
|
|
328
332
|
}
|
|
329
333
|
|
|
330
334
|
@PluginMethod
|
|
331
335
|
fun queryHeartRate(call: PluginCall) {
|
|
332
|
-
call
|
|
333
|
-
queryLatestSample(call)
|
|
336
|
+
queryLatestSampleInternal(call, "heart-rate")
|
|
334
337
|
}
|
|
335
338
|
|
|
336
339
|
@PluginMethod
|
|
337
340
|
fun querySteps(call: PluginCall) {
|
|
338
|
-
call
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
484
|
-
put("timestamp", record.
|
|
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.
|
|
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
|
|
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