@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.
- package/CapacitorHealthExtended.podspec +17 -0
- package/LICENSE +21 -0
- package/Package.swift +28 -0
- package/README.md +343 -0
- package/android/build.gradle +70 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/flomentum/health/capacitor/HealthPlugin.kt +677 -0
- package/android/src/main/java/com/flomentum/health/capacitor/PermissionsRationaleActivity.kt +86 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/esm/definitions.d.ts +110 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/plugin.cjs.js +10 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +13 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/HealthPluginPlugin/HealthPlugin.swift +671 -0
- package/package.json +85 -0
|
@@ -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
|
+
}
|