@flomentumsolutions/capacitor-health-extended 0.0.1

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.
@@ -0,0 +1,677 @@
1
+ package com.flomentum.health.capacitor
2
+
3
+ import android.content.Intent
4
+ import android.util.Log
5
+ import androidx.activity.result.ActivityResultLauncher
6
+ import androidx.health.connect.client.HealthConnectClient
7
+ import androidx.health.connect.client.permission.HealthPermission
8
+ import androidx.health.connect.client.PermissionController
9
+ import androidx.health.connect.client.aggregate.AggregateMetric
10
+ import androidx.health.connect.client.aggregate.AggregationResult
11
+ import androidx.health.connect.client.aggregate.AggregationResultGroupedByPeriod
12
+ import androidx.health.connect.client.records.*
13
+ import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
14
+ import androidx.health.connect.client.request.AggregateRequest
15
+ import androidx.health.connect.client.request.ReadRecordsRequest
16
+ import androidx.health.connect.client.time.TimeRangeFilter
17
+ import com.getcapacitor.JSArray
18
+ import com.getcapacitor.JSObject
19
+ import com.getcapacitor.Plugin
20
+ import com.getcapacitor.PluginCall
21
+ import com.getcapacitor.PluginMethod
22
+ import com.getcapacitor.annotation.CapacitorPlugin
23
+ import com.getcapacitor.annotation.Permission
24
+ import kotlinx.coroutines.CoroutineScope
25
+ import kotlinx.coroutines.Dispatchers
26
+ import kotlinx.coroutines.launch
27
+ import java.time.Instant
28
+ import java.time.LocalDateTime
29
+ import java.time.Period
30
+ import java.time.ZoneId
31
+ import java.util.concurrent.atomic.AtomicReference
32
+ import androidx.core.net.toUri
33
+
34
+ enum class CapHealthPermission {
35
+ READ_STEPS, READ_WORKOUTS, READ_HEART_RATE, READ_ACTIVE_CALORIES, READ_TOTAL_CALORIES, READ_DISTANCE, READ_WEIGHT;
36
+
37
+ companion object {
38
+ fun from(s: String): CapHealthPermission? {
39
+ return try {
40
+ CapHealthPermission.valueOf(s)
41
+ } catch (e: Exception) {
42
+ null
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ @CapacitorPlugin(
49
+ name = "HealthPlugin",
50
+ permissions = [
51
+ Permission(alias = "READ_STEPS", strings = ["android.permission.health.READ_STEPS"]),
52
+ Permission(alias = "READ_WEIGHT", strings = ["android.permission.health.READ_WEIGHT"]),
53
+ Permission(alias = "READ_WORKOUTS", strings = ["android.permission.health.READ_EXERCISE"]),
54
+ Permission(alias = "READ_DISTANCE", strings = ["android.permission.health.READ_DISTANCE"]),
55
+ Permission(alias = "READ_ACTIVE_CALORIES", strings = ["android.permission.health.READ_ACTIVE_CALORIES_BURNED"]),
56
+ Permission(alias = "READ_TOTAL_CALORIES", strings = ["android.permission.health.READ_TOTAL_CALORIES_BURNED"]),
57
+ Permission(alias = "READ_HEART_RATE", strings = ["android.permission.health.READ_HEART_RATE"])
58
+ ]
59
+ )
60
+
61
+ @Suppress("unused", "MemberVisibilityCanBePrivate")
62
+ class HealthPlugin : Plugin() {
63
+
64
+ private val tag = "HealthPlugin"
65
+
66
+ private lateinit var healthConnectClient: HealthConnectClient
67
+ private var available: Boolean = false
68
+
69
+ private lateinit var permissionsLauncher: ActivityResultLauncher<Set<String>>
70
+
71
+ private val permissionMapping: Map<CapHealthPermission, String> = mapOf(
72
+ CapHealthPermission.READ_STEPS to HealthPermission.getReadPermission(StepsRecord::class),
73
+ CapHealthPermission.READ_HEART_RATE to HealthPermission.getReadPermission(HeartRateRecord::class),
74
+ CapHealthPermission.READ_WEIGHT to HealthPermission.getReadPermission(WeightRecord::class),
75
+ CapHealthPermission.READ_ACTIVE_CALORIES to HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
76
+ CapHealthPermission.READ_TOTAL_CALORIES to HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
77
+ CapHealthPermission.READ_DISTANCE to HealthPermission.getReadPermission(DistanceRecord::class),
78
+ CapHealthPermission.READ_WORKOUTS to HealthPermission.getReadPermission(ExerciseSessionRecord::class)
79
+ )
80
+
81
+ override fun load() {
82
+ super.load()
83
+ initializePermissionLauncher()
84
+ }
85
+
86
+ private fun initializePermissionLauncher() {
87
+ permissionsLauncher = bridge.activity.registerForActivityResult(
88
+ PermissionController.createRequestPermissionResultContract()
89
+ ) { grantedPermissions: Set<String> ->
90
+ onPermissionsResult(grantedPermissions)
91
+ }
92
+ }
93
+
94
+ private fun onPermissionsResult(grantedPermissions: Set<String>) {
95
+ Log.i(tag, "Permissions callback: $grantedPermissions")
96
+ val context = requestPermissionContext.getAndSet(null) ?: return
97
+
98
+ val result = buildPermissionsResult(context, grantedPermissions)
99
+ context.pluginCal.resolve(result)
100
+ }
101
+
102
+ private fun buildPermissionsResult(
103
+ context: RequestPermissionContext,
104
+ grantedPermissions: Set<String>
105
+ ): JSObject {
106
+ val perms = JSObject()
107
+ context.requestedPermissions.forEach { cap ->
108
+ val hp = permissionMapping[cap]
109
+ val isGranted = hp != null && grantedPermissions.contains(hp)
110
+ perms.put(cap.name, isGranted)
111
+ }
112
+ return JSObject().apply {
113
+ put("permissions", perms)
114
+ }
115
+ }
116
+
117
+ // Check if Google Health Connect is available. Must be called before anything else
118
+ @PluginMethod
119
+ fun isHealthAvailable(call: PluginCall) {
120
+
121
+ if (!available) {
122
+ try {
123
+ healthConnectClient = HealthConnectClient.getOrCreate(context)
124
+ available = true
125
+ } catch (e: Exception) {
126
+ Log.e(tag, "isHealthAvailable: Failed to initialize HealthConnectClient", e)
127
+ available = false
128
+ }
129
+ }
130
+
131
+ val result = JSObject()
132
+ result.put("available", available)
133
+ call.resolve(result)
134
+ }
135
+
136
+ // Helper to ensure client is initialized
137
+ private fun ensureClientInitialized(call: PluginCall): Boolean {
138
+ if (!available) {
139
+ call.reject("Health Connect client not initialized. Call isHealthAvailable() first.")
140
+ return false
141
+ }
142
+ return true
143
+ }
144
+
145
+ // Check if a set of permissions are granted
146
+ @PluginMethod
147
+ fun checkHealthPermissions(call: PluginCall) {
148
+ if (!ensureClientInitialized(call)) return
149
+ val permissionsToCheck = call.getArray("permissions")
150
+ if (permissionsToCheck == null) {
151
+ call.reject("Must provide permissions to check")
152
+ return
153
+ }
154
+
155
+ val permissions =
156
+ permissionsToCheck.toList<String>().mapNotNull { CapHealthPermission.from(it) }.toSet()
157
+
158
+ CoroutineScope(Dispatchers.IO).launch {
159
+ try {
160
+ val grantedPermissions = healthConnectClient.permissionController.getGrantedPermissions()
161
+ val result = grantedPermissionResult(permissions, grantedPermissions)
162
+ call.resolve(result)
163
+ } catch (e: Exception) {
164
+ call.reject("Checking permissions failed: ${e.message}")
165
+ }
166
+ }
167
+ }
168
+
169
+ private fun grantedPermissionResult(
170
+ requestPermissions: Set<CapHealthPermission>,
171
+ grantedPermissions: Set<String>
172
+ ): JSObject {
173
+ val readPermissions = JSObject()
174
+ for (permission in requestPermissions) {
175
+ val hp = permissionMapping[permission]!!
176
+ // Check by object equality
177
+ readPermissions.put(permission.name, grantedPermissions.contains(hp))
178
+ }
179
+ return JSObject().apply {
180
+ put("permissions", readPermissions)
181
+ }
182
+ }
183
+
184
+ data class RequestPermissionContext(val requestedPermissions: Set<CapHealthPermission>, val pluginCal: PluginCall)
185
+
186
+ private val requestPermissionContext = AtomicReference<RequestPermissionContext>()
187
+
188
+ // Request a set of permissions from the user
189
+ @PluginMethod
190
+ fun requestHealthPermissions(call: PluginCall) {
191
+ if (!ensureClientInitialized(call)) return
192
+ val requestedCaps = call.getArray("permissions")
193
+ ?.toList<String>()
194
+ ?.mapNotNull { CapHealthPermission.from(it) }
195
+ ?.toSet() ?: return call.reject("Provide permissions array.")
196
+
197
+ val hcPermissions: Set<String> = requestedCaps
198
+ .mapNotNull { permissionMapping[it] }
199
+ .toSet()
200
+
201
+ if (hcPermissions.isEmpty()) {
202
+ return call.reject("No valid Health Connect permissions.")
203
+ }
204
+
205
+ requestPermissionContext.set(RequestPermissionContext(requestedCaps, call))
206
+
207
+ // Show rationale if available
208
+ context.packageManager?.let { pm ->
209
+ Intent("androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE").apply {
210
+ setPackage("com.google.android.apps.healthdata")
211
+ }.takeIf { pm.resolveActivity(it, 0) != null }
212
+ ?.also { context.startActivity(it) }
213
+ ?: Log.w(tag, "Health Connect rationale screen not found")
214
+ }
215
+
216
+ CoroutineScope(Dispatchers.Main).launch {
217
+ permissionsLauncher.launch(hcPermissions)
218
+ Log.i(tag, "Launched Health Connect permission request: $hcPermissions")
219
+ }
220
+ }
221
+
222
+ // Open Google Health Connect app settings
223
+ @PluginMethod
224
+ fun openHealthConnectSettings(call: PluginCall) {
225
+ try {
226
+ val intent = Intent().apply {
227
+ action = HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS
228
+ }
229
+ context.startActivity(intent)
230
+ call.resolve()
231
+ } catch(e: Exception) {
232
+ call.reject(e.message)
233
+ }
234
+ }
235
+
236
+ // Open the Google Play Store to install Health Connect
237
+ @PluginMethod
238
+ fun showHealthConnectInPlayStore(call: PluginCall) {
239
+ val uri =
240
+ "https://play.google.com/store/apps/details?id=com.google.android.apps.healthdata".toUri()
241
+ val intent = Intent(Intent.ACTION_VIEW, uri)
242
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
243
+ context.startActivity(intent)
244
+ call.resolve()
245
+ }
246
+
247
+ private fun getMetricAndMapper(dataType: String): MetricAndMapper {
248
+ return when (dataType) {
249
+ "steps" -> metricAndMapper("steps", CapHealthPermission.READ_STEPS, StepsRecord.COUNT_TOTAL) { it?.toDouble() }
250
+ "active-calories" -> metricAndMapper(
251
+ "calories",
252
+ CapHealthPermission.READ_ACTIVE_CALORIES,
253
+ ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL
254
+ ) { it?.inKilocalories }
255
+ "total-calories" -> metricAndMapper(
256
+ "calories",
257
+ CapHealthPermission.READ_TOTAL_CALORIES,
258
+ TotalCaloriesBurnedRecord.ENERGY_TOTAL
259
+ ) { it?.inKilocalories }
260
+ "distance" -> metricAndMapper("distance", CapHealthPermission.READ_DISTANCE, DistanceRecord.DISTANCE_TOTAL) { it?.inMeters }
261
+ else -> throw RuntimeException("Unsupported dataType: $dataType")
262
+ }
263
+ }
264
+
265
+ @PluginMethod
266
+ fun queryLatestSample(call: PluginCall) {
267
+ if (!ensureClientInitialized(call)) return
268
+ val dataType = call.getString("dataType")
269
+ if (dataType == null) {
270
+ call.reject("Missing required parameter: dataType")
271
+ return
272
+ }
273
+
274
+ CoroutineScope(Dispatchers.IO).launch {
275
+ try {
276
+ val result = when (dataType) {
277
+ "heart-rate" -> readLatestHeartRate()
278
+ "weight" -> readLatestWeight()
279
+ "steps" -> readLatestSteps()
280
+ else -> {
281
+ call.reject("Unsupported data type: $dataType")
282
+ return@launch
283
+ }
284
+ }
285
+ call.resolve(result)
286
+ } catch (e: Exception) {
287
+ Log.e(tag, "queryLatestSample: Error fetching latest heart-rate", e)
288
+ call.reject("Error fetching latest $dataType: ${e.message}")
289
+ }
290
+ }
291
+ }
292
+
293
+ private suspend fun readLatestHeartRate(): JSObject {
294
+ if (!hasPermission(CapHealthPermission.READ_HEART_RATE)) {
295
+ throw Exception("Permission for heart rate not granted")
296
+ }
297
+ val request = ReadRecordsRequest(
298
+ recordType = HeartRateRecord::class,
299
+ timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
300
+ pageSize = 1
301
+ )
302
+ val result = healthConnectClient.readRecords(request)
303
+ val record = result.records.firstOrNull() ?: throw Exception("No heart rate data found")
304
+
305
+ val lastSample = record.samples.lastOrNull()
306
+ return JSObject().apply {
307
+ put("timestamp", lastSample?.time?.toString() ?: "")
308
+ put("value", lastSample?.beatsPerMinute ?: 0)
309
+ }
310
+ }
311
+
312
+ private suspend fun readLatestWeight(): JSObject {
313
+ if (!hasPermission(CapHealthPermission.READ_WEIGHT)) {
314
+ throw Exception("Permission for weight not granted")
315
+ }
316
+ val request = ReadRecordsRequest(
317
+ recordType = WeightRecord::class,
318
+ timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
319
+ pageSize = 1
320
+ )
321
+ val result = healthConnectClient.readRecords(request)
322
+ val record = result.records.firstOrNull() ?: throw Exception("No weight data found")
323
+ return JSObject().apply {
324
+ put("timestamp", record.time.toString())
325
+ put("value", record.weight.inKilograms)
326
+ }
327
+ }
328
+
329
+ private suspend fun readLatestSteps(): JSObject {
330
+ if (!hasPermission(CapHealthPermission.READ_STEPS)) {
331
+ throw Exception("Permission for steps not granted")
332
+ }
333
+ val request = ReadRecordsRequest(
334
+ recordType = StepsRecord::class,
335
+ timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
336
+ pageSize = 1
337
+ )
338
+ val result = healthConnectClient.readRecords(request)
339
+ val record = result.records.firstOrNull() ?: throw Exception("No step data found")
340
+ return JSObject().apply {
341
+ put("startDate", record.startTime.toString())
342
+ put("endDate", record.endTime.toString())
343
+ put("value", record.count)
344
+ }
345
+ }
346
+
347
+ @PluginMethod
348
+ fun queryAggregated(call: PluginCall) {
349
+ if (!ensureClientInitialized(call)) return
350
+ try {
351
+ val startDate = call.getString("startDate")
352
+ val endDate = call.getString("endDate")
353
+ val dataType = call.getString("dataType")
354
+ val bucket = call.getString("bucket")
355
+
356
+ if (startDate == null || endDate == null || dataType == null || bucket == null) {
357
+ call.reject("Missing required parameters: startDate, endDate, dataType, or bucket")
358
+ return
359
+ }
360
+
361
+ val startDateTime = Instant.parse(startDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
362
+ val endDateTime = Instant.parse(endDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
363
+
364
+ val metricAndMapper = getMetricAndMapper(dataType)
365
+
366
+ val period = when (bucket) {
367
+ "day" -> Period.ofDays(1)
368
+ else -> throw RuntimeException("Unsupported bucket: $bucket")
369
+ }
370
+
371
+ CoroutineScope(Dispatchers.IO).launch {
372
+ try {
373
+ val r = queryAggregatedMetric(metricAndMapper, TimeRangeFilter.between(startDateTime, endDateTime), period)
374
+ val aggregatedList = JSArray()
375
+ r.forEach { aggregatedList.put(it.toJs()) }
376
+ val finalResult = JSObject()
377
+ finalResult.put("aggregatedData", aggregatedList)
378
+ call.resolve(finalResult)
379
+ } catch (e: Exception) {
380
+ call.reject("Error querying aggregated data: ${e.message}")
381
+ }
382
+ }
383
+ } catch (e: Exception) {
384
+ call.reject(e.message)
385
+ return
386
+ }
387
+ }
388
+
389
+
390
+ private fun <M : Any> metricAndMapper(
391
+ name: String,
392
+ permission: CapHealthPermission,
393
+ metric: AggregateMetric<M>,
394
+ mapper: (M?) -> Double?
395
+ ): MetricAndMapper {
396
+ @Suppress("UNCHECKED_CAST")
397
+ return MetricAndMapper(name, permission, metric, mapper as (Any?) -> Double?)
398
+ }
399
+
400
+ data class MetricAndMapper(
401
+ val name: String,
402
+ val permission: CapHealthPermission,
403
+ val metric: AggregateMetric<Any>,
404
+ val mapper: (Any?) -> Double?
405
+ ) {
406
+ fun getValue(a: AggregationResult): Double? {
407
+ return mapper(a[metric])
408
+ }
409
+ }
410
+
411
+ data class AggregatedSample(val startDate: LocalDateTime, val endDate: LocalDateTime, val value: Double?) {
412
+ fun toJs(): JSObject {
413
+ val o = JSObject()
414
+ o.put("startDate", startDate)
415
+ o.put("endDate", endDate)
416
+ o.put("value", value)
417
+
418
+ return o
419
+
420
+ }
421
+ }
422
+
423
+ private suspend fun queryAggregatedMetric(
424
+ metricAndMapper: MetricAndMapper, timeRange: TimeRangeFilter, period: Period,
425
+ ): List<AggregatedSample> {
426
+ if (!hasPermission(metricAndMapper.permission)) {
427
+ return emptyList()
428
+ }
429
+
430
+ val response: List<AggregationResultGroupedByPeriod> = healthConnectClient.aggregateGroupByPeriod(
431
+ AggregateGroupByPeriodRequest(
432
+ metrics = setOf(metricAndMapper.metric),
433
+ timeRangeFilter = timeRange,
434
+ timeRangeSlicer = period
435
+ )
436
+ )
437
+
438
+ return response.map {
439
+ val mappedValue = metricAndMapper.getValue(it.result)
440
+ AggregatedSample(it.startTime, it.endTime, mappedValue)
441
+ }
442
+
443
+ }
444
+
445
+ private suspend fun hasPermission(p: CapHealthPermission): Boolean {
446
+ val granted = healthConnectClient.permissionController.getGrantedPermissions()
447
+ val targetPermission = permissionMapping[p]
448
+ return granted.contains(targetPermission)
449
+ }
450
+
451
+
452
+ @PluginMethod
453
+ fun queryWorkouts(call: PluginCall) {
454
+ if (!ensureClientInitialized(call)) return
455
+ val startDate = call.getString("startDate")
456
+ val endDate = call.getString("endDate")
457
+ val includeHeartRate: Boolean = call.getBoolean("includeHeartRate", false) == true
458
+ val includeRoute: Boolean = call.getBoolean("includeRoute", false) == true
459
+ val includeSteps: Boolean = call.getBoolean("includeSteps", false) == true
460
+ if (startDate == null || endDate == null) {
461
+ call.reject("Missing required parameters: startDate or endDate")
462
+ return
463
+ }
464
+
465
+ val startDateTime = Instant.parse(startDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
466
+ val endDateTime = Instant.parse(endDate).atZone(ZoneId.systemDefault()).toLocalDateTime()
467
+
468
+ val timeRange = TimeRangeFilter.between(startDateTime, endDateTime)
469
+ val request =
470
+ ReadRecordsRequest(ExerciseSessionRecord::class, timeRange, emptySet(), true, 1000)
471
+
472
+ CoroutineScope(Dispatchers.IO).launch {
473
+ try {
474
+ // Check permission for heart rate before loop
475
+ val hasHeartRatePermission = hasPermission(CapHealthPermission.READ_HEART_RATE)
476
+
477
+ // Log warning if requested data but permission not granted
478
+ if (includeHeartRate && !hasHeartRatePermission) {
479
+ Log.w(tag, "queryWorkouts: Heart rate requested but not permitted")
480
+ }
481
+
482
+ // Query workouts (exercise sessions)
483
+ val response = healthConnectClient.readRecords(request)
484
+
485
+ val workoutsArray = JSArray()
486
+
487
+ for (workout in response.records) {
488
+ val workoutObject = JSObject()
489
+ workoutObject.put("id", workout.metadata.id)
490
+ val sourceModel = workout.metadata.device?.model ?: ""
491
+ workoutObject.put("sourceName", sourceModel)
492
+ workoutObject.put("sourceBundleId", workout.metadata.dataOrigin.packageName)
493
+ workoutObject.put("startDate", workout.startTime.toString())
494
+ workoutObject.put("endDate", workout.endTime.toString())
495
+ workoutObject.put("workoutType", exerciseTypeMapping.getOrDefault(workout.exerciseType, "OTHER"))
496
+ workoutObject.put("title", workout.title)
497
+ val duration = if (workout.segments.isEmpty()) {
498
+ workout.endTime.epochSecond - workout.startTime.epochSecond
499
+ } else {
500
+ workout.segments.map { it.endTime.epochSecond - it.startTime.epochSecond }
501
+ .stream().mapToLong { it }.sum()
502
+ }
503
+ workoutObject.put("duration", duration)
504
+
505
+ if (includeSteps) {
506
+ addWorkoutMetric(workout, workoutObject, getMetricAndMapper("steps"))
507
+ }
508
+
509
+ val readTotalCaloriesResult = addWorkoutMetric(workout, workoutObject, getMetricAndMapper("total-calories"))
510
+ if(!readTotalCaloriesResult) {
511
+ addWorkoutMetric(workout, workoutObject, getMetricAndMapper("active-calories"))
512
+ }
513
+
514
+ addWorkoutMetric(workout, workoutObject, getMetricAndMapper("distance"))
515
+
516
+ if (includeHeartRate && hasHeartRatePermission) {
517
+ // Query and add heart rate data if requested
518
+ val heartRates =
519
+ queryHeartRateForWorkout(workout.startTime, workout.endTime)
520
+ if (heartRates.length() > 0) {
521
+ workoutObject.put("heartRate", heartRates)
522
+ }
523
+ }
524
+
525
+ /* Updated route logic for Health Connect RC02 */
526
+ if (includeRoute) {
527
+ if (!hasPermission(CapHealthPermission.READ_WORKOUTS)) {
528
+ Log.w(tag, "queryWorkouts: Route requested but READ_WORKOUTS permission missing")
529
+ } else if (workout.exerciseRouteResult is ExerciseRouteResult.Data) {
530
+ val data = workout.exerciseRouteResult as ExerciseRouteResult.Data
531
+ val routeJson = queryRouteForWorkout(data)
532
+ if (routeJson.length() > 0) {
533
+ workoutObject.put("route", routeJson)
534
+ }
535
+ }
536
+ }
537
+
538
+ workoutsArray.put(workoutObject)
539
+ }
540
+
541
+ val result = JSObject()
542
+ result.put("workouts", workoutsArray)
543
+ call.resolve(result)
544
+
545
+ } catch (e: Exception) {
546
+ call.reject("Error querying workouts: ${e.message}")
547
+ }
548
+ }
549
+ }
550
+
551
+ private suspend fun addWorkoutMetric(
552
+ workout: ExerciseSessionRecord,
553
+ jsWorkout: JSObject,
554
+ metricAndMapper: MetricAndMapper,
555
+ ): Boolean {
556
+ if (!hasPermission(metricAndMapper.permission)) {
557
+ Log.w(tag, "addWorkoutMetric: Skipped ${metricAndMapper.name} due to missing permission")
558
+ return false
559
+ }
560
+ try {
561
+ val request = AggregateRequest(
562
+ setOf(metricAndMapper.metric),
563
+ TimeRangeFilter.Companion.between(workout.startTime, workout.endTime),
564
+ emptySet()
565
+ )
566
+ val aggregation = healthConnectClient.aggregate(request)
567
+ val value = metricAndMapper.getValue(aggregation)
568
+ if(value != null) {
569
+ jsWorkout.put(metricAndMapper.name, value)
570
+ return true
571
+ }
572
+ } catch (e: Exception) {
573
+ Log.e(tag, "addWorkoutMetric: Failed to aggregate ${metricAndMapper.name}", e)
574
+ }
575
+ return false
576
+ }
577
+
578
+
579
+ private suspend fun queryHeartRateForWorkout(startTime: Instant, endTime: Instant): JSArray {
580
+ if (!hasPermission(CapHealthPermission.READ_HEART_RATE)) {
581
+ return JSArray()
582
+ }
583
+
584
+ val request =
585
+ ReadRecordsRequest(HeartRateRecord::class, TimeRangeFilter.between(startTime, endTime))
586
+ val heartRateRecords = healthConnectClient.readRecords(request)
587
+
588
+ val heartRateArray = JSArray()
589
+ val samples = heartRateRecords.records.flatMap { it.samples }
590
+ for (sample in samples) {
591
+ val heartRateObject = JSObject()
592
+ heartRateObject.put("timestamp", sample.time.toString())
593
+ heartRateObject.put("bpm", sample.beatsPerMinute)
594
+ heartRateArray.put(heartRateObject)
595
+ }
596
+ return heartRateArray
597
+ }
598
+
599
+ private fun queryRouteForWorkout(routeResult: ExerciseRouteResult.Data): JSArray {
600
+
601
+ val routeArray = JSArray()
602
+ for (record in routeResult.exerciseRoute.route) {
603
+ val routeObject = JSObject()
604
+ routeObject.put("timestamp", record.time.toString())
605
+ routeObject.put("lat", record.latitude)
606
+ routeObject.put("lng", record.longitude)
607
+ routeObject.put("alt", record.altitude)
608
+ routeArray.put(routeObject)
609
+ }
610
+ return routeArray
611
+ }
612
+
613
+
614
+ private val exerciseTypeMapping = mapOf(
615
+ 0 to "OTHER",
616
+ 2 to "BADMINTON",
617
+ 4 to "BASEBALL",
618
+ 5 to "BASKETBALL",
619
+ 8 to "BIKING",
620
+ 9 to "BIKING_STATIONARY",
621
+ 10 to "BOOT_CAMP",
622
+ 11 to "BOXING",
623
+ 13 to "CALISTHENICS",
624
+ 14 to "CRICKET",
625
+ 16 to "DANCING",
626
+ 25 to "ELLIPTICAL",
627
+ 26 to "EXERCISE_CLASS",
628
+ 27 to "FENCING",
629
+ 28 to "FOOTBALL_AMERICAN",
630
+ 29 to "FOOTBALL_AUSTRALIAN",
631
+ 31 to "FRISBEE_DISC",
632
+ 32 to "GOLF",
633
+ 33 to "GUIDED_BREATHING",
634
+ 34 to "GYMNASTICS",
635
+ 35 to "HANDBALL",
636
+ 36 to "HIGH_INTENSITY_INTERVAL_TRAINING",
637
+ 37 to "HIKING",
638
+ 38 to "ICE_HOCKEY",
639
+ 39 to "ICE_SKATING",
640
+ 44 to "MARTIAL_ARTS",
641
+ 46 to "PADDLING",
642
+ 47 to "PARAGLIDING",
643
+ 48 to "PILATES",
644
+ 50 to "RACQUETBALL",
645
+ 51 to "ROCK_CLIMBING",
646
+ 52 to "ROLLER_HOCKEY",
647
+ 53 to "ROWING",
648
+ 54 to "ROWING_MACHINE",
649
+ 55 to "RUGBY",
650
+ 56 to "RUNNING",
651
+ 57 to "RUNNING_TREADMILL",
652
+ 58 to "SAILING",
653
+ 59 to "SCUBA_DIVING",
654
+ 60 to "SKATING",
655
+ 61 to "SKIING",
656
+ 62 to "SNOWBOARDING",
657
+ 63 to "SNOWSHOEING",
658
+ 64 to "SOCCER",
659
+ 65 to "SOFTBALL",
660
+ 66 to "SQUASH",
661
+ 68 to "STAIR_CLIMBING",
662
+ 69 to "STAIR_CLIMBING_MACHINE",
663
+ 70 to "STRENGTH_TRAINING",
664
+ 71 to "STRETCHING",
665
+ 72 to "SURFING",
666
+ 73 to "SWIMMING_OPEN_WATER",
667
+ 74 to "SWIMMING_POOL",
668
+ 75 to "TABLE_TENNIS",
669
+ 76 to "TENNIS",
670
+ 78 to "VOLLEYBALL",
671
+ 79 to "WALKING",
672
+ 80 to "WATER_POLO",
673
+ 81 to "WEIGHTLIFTING",
674
+ 82 to "WHEELCHAIR",
675
+ 83 to "YOGA"
676
+ )
677
+ }