@asiriindatissa/capacitor-health 8.2.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,420 @@
1
+ package app.capgo.plugin.health
2
+
3
+ import androidx.health.connect.client.HealthConnectClient
4
+ import androidx.health.connect.client.permission.HealthPermission
5
+ import androidx.health.connect.client.request.AggregateRequest
6
+ import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
7
+ import androidx.health.connect.client.records.DistanceRecord
8
+ import androidx.health.connect.client.records.ExerciseSessionRecord
9
+ import androidx.health.connect.client.records.HeartRateRecord
10
+ import androidx.health.connect.client.records.Record
11
+ import androidx.health.connect.client.records.StepsRecord
12
+ import androidx.health.connect.client.records.WeightRecord
13
+ import androidx.health.connect.client.request.ReadRecordsRequest
14
+ import androidx.health.connect.client.time.TimeRangeFilter
15
+ import androidx.health.connect.client.units.Energy
16
+ import androidx.health.connect.client.units.Length
17
+ import androidx.health.connect.client.units.Mass
18
+ import androidx.health.connect.client.records.metadata.Metadata
19
+ import java.time.Duration
20
+ import com.getcapacitor.JSArray
21
+ import com.getcapacitor.JSObject
22
+ import java.time.Instant
23
+ import java.time.ZoneId
24
+ import java.time.ZoneOffset
25
+ import java.time.format.DateTimeFormatter
26
+ import kotlin.math.min
27
+ import kotlin.collections.buildSet
28
+
29
+ class HealthManager {
30
+
31
+ private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT
32
+
33
+ fun permissionsFor(readTypes: Collection<HealthDataType>, writeTypes: Collection<HealthDataType>, includeWorkouts: Boolean = false): Set<String> = buildSet {
34
+ readTypes.forEach { add(it.readPermission) }
35
+ writeTypes.forEach { add(it.writePermission) }
36
+ // Include workout read permission if explicitly requested
37
+ if (includeWorkouts) {
38
+ add(HealthPermission.getReadPermission(ExerciseSessionRecord::class))
39
+ }
40
+ }
41
+
42
+ suspend fun authorizationStatus(
43
+ client: HealthConnectClient,
44
+ readTypes: Collection<HealthDataType>,
45
+ writeTypes: Collection<HealthDataType>,
46
+ includeWorkouts: Boolean = false
47
+ ): JSObject {
48
+ val granted = client.permissionController.getGrantedPermissions()
49
+
50
+ val readAuthorized = JSArray()
51
+ val readDenied = JSArray()
52
+ readTypes.forEach { type ->
53
+ if (granted.contains(type.readPermission)) {
54
+ readAuthorized.put(type.identifier)
55
+ } else {
56
+ readDenied.put(type.identifier)
57
+ }
58
+ }
59
+
60
+ // Check workout permission if requested
61
+ if (includeWorkouts) {
62
+ val workoutPermission = HealthPermission.getReadPermission(ExerciseSessionRecord::class)
63
+ if (granted.contains(workoutPermission)) {
64
+ readAuthorized.put("workouts")
65
+ } else {
66
+ readDenied.put("workouts")
67
+ }
68
+ }
69
+
70
+ val writeAuthorized = JSArray()
71
+ val writeDenied = JSArray()
72
+ writeTypes.forEach { type ->
73
+ if (granted.contains(type.writePermission)) {
74
+ writeAuthorized.put(type.identifier)
75
+ } else {
76
+ writeDenied.put(type.identifier)
77
+ }
78
+ }
79
+
80
+ return JSObject().apply {
81
+ put("readAuthorized", readAuthorized)
82
+ put("readDenied", readDenied)
83
+ put("writeAuthorized", writeAuthorized)
84
+ put("writeDenied", writeDenied)
85
+ }
86
+ }
87
+
88
+ suspend fun readSamples(
89
+ client: HealthConnectClient,
90
+ dataType: HealthDataType,
91
+ startTime: Instant,
92
+ endTime: Instant,
93
+ limit: Int,
94
+ ascending: Boolean
95
+ ): JSArray {
96
+ val samples = mutableListOf<Pair<Instant, JSObject>>()
97
+ when (dataType) {
98
+ HealthDataType.STEPS -> readRecords(client, StepsRecord::class, startTime, endTime, limit) { record ->
99
+ val payload = createSamplePayload(
100
+ dataType,
101
+ record.startTime,
102
+ record.endTime,
103
+ record.count.toDouble(),
104
+ record.metadata
105
+ )
106
+ samples.add(record.startTime to payload)
107
+ }
108
+ HealthDataType.DISTANCE -> readRecords(client, DistanceRecord::class, startTime, endTime, limit) { record ->
109
+ val payload = createSamplePayload(
110
+ dataType,
111
+ record.startTime,
112
+ record.endTime,
113
+ record.distance.inMeters,
114
+ record.metadata
115
+ )
116
+ samples.add(record.startTime to payload)
117
+ }
118
+ HealthDataType.CALORIES -> readRecords(client, ActiveCaloriesBurnedRecord::class, startTime, endTime, limit) { record ->
119
+ val payload = createSamplePayload(
120
+ dataType,
121
+ record.startTime,
122
+ record.endTime,
123
+ record.energy.inKilocalories,
124
+ record.metadata
125
+ )
126
+ samples.add(record.startTime to payload)
127
+ }
128
+ HealthDataType.WEIGHT -> readRecords(client, WeightRecord::class, startTime, endTime, limit) { record ->
129
+ val payload = createSamplePayload(
130
+ dataType,
131
+ record.time,
132
+ record.time,
133
+ record.weight.inKilograms,
134
+ record.metadata
135
+ )
136
+ samples.add(record.time to payload)
137
+ }
138
+ HealthDataType.HEART_RATE -> readRecords(client, HeartRateRecord::class, startTime, endTime, limit) { record ->
139
+ record.samples.forEach { sample ->
140
+ val payload = createSamplePayload(
141
+ dataType,
142
+ sample.time,
143
+ sample.time,
144
+ sample.beatsPerMinute.toDouble(),
145
+ record.metadata
146
+ )
147
+ samples.add(sample.time to payload)
148
+ }
149
+ }
150
+ }
151
+
152
+ val sorted = samples.sortedBy { it.first }
153
+ val ordered = if (ascending) sorted else sorted.asReversed()
154
+ val limited = if (limit > 0) ordered.take(limit) else ordered
155
+
156
+ val array = JSArray()
157
+ limited.forEach { array.put(it.second) }
158
+ return array
159
+ }
160
+
161
+ private suspend fun <T : Record> readRecords(
162
+ client: HealthConnectClient,
163
+ recordClass: kotlin.reflect.KClass<T>,
164
+ startTime: Instant,
165
+ endTime: Instant,
166
+ limit: Int,
167
+ consumer: (record: T) -> Unit
168
+ ) {
169
+ var pageToken: String? = null
170
+ val pageSize = if (limit > 0) min(limit, MAX_PAGE_SIZE) else DEFAULT_PAGE_SIZE
171
+ var fetched = 0
172
+
173
+ do {
174
+ val request = ReadRecordsRequest(
175
+ recordType = recordClass,
176
+ timeRangeFilter = TimeRangeFilter.between(startTime, endTime),
177
+ pageSize = pageSize,
178
+ pageToken = pageToken
179
+ )
180
+ val response = client.readRecords(request)
181
+ response.records.forEach { record ->
182
+ consumer(record)
183
+ }
184
+ fetched += response.records.size
185
+ pageToken = response.pageToken
186
+ } while (pageToken != null && (limit <= 0 || fetched < limit))
187
+ }
188
+
189
+ @Suppress("UNUSED_PARAMETER")
190
+ suspend fun saveSample(
191
+ client: HealthConnectClient,
192
+ dataType: HealthDataType,
193
+ value: Double,
194
+ startTime: Instant,
195
+ endTime: Instant,
196
+ metadata: Map<String, String>?
197
+ ) {
198
+ when (dataType) {
199
+ HealthDataType.STEPS -> {
200
+ val record = StepsRecord(
201
+ startTime = startTime,
202
+ startZoneOffset = zoneOffset(startTime),
203
+ endTime = endTime,
204
+ endZoneOffset = zoneOffset(endTime),
205
+ count = value.toLong().coerceAtLeast(0)
206
+ )
207
+ client.insertRecords(listOf(record))
208
+ }
209
+ HealthDataType.DISTANCE -> {
210
+ val record = DistanceRecord(
211
+ startTime = startTime,
212
+ startZoneOffset = zoneOffset(startTime),
213
+ endTime = endTime,
214
+ endZoneOffset = zoneOffset(endTime),
215
+ distance = Length.meters(value)
216
+ )
217
+ client.insertRecords(listOf(record))
218
+ }
219
+ HealthDataType.CALORIES -> {
220
+ val record = ActiveCaloriesBurnedRecord(
221
+ startTime = startTime,
222
+ startZoneOffset = zoneOffset(startTime),
223
+ endTime = endTime,
224
+ endZoneOffset = zoneOffset(endTime),
225
+ energy = Energy.kilocalories(value)
226
+ )
227
+ client.insertRecords(listOf(record))
228
+ }
229
+ HealthDataType.WEIGHT -> {
230
+ val record = WeightRecord(
231
+ time = startTime,
232
+ zoneOffset = zoneOffset(startTime),
233
+ weight = Mass.kilograms(value)
234
+ )
235
+ client.insertRecords(listOf(record))
236
+ }
237
+ HealthDataType.HEART_RATE -> {
238
+ val samples = listOf(HeartRateRecord.Sample(time = startTime, beatsPerMinute = value.toBpmLong()))
239
+ val record = HeartRateRecord(
240
+ startTime = startTime,
241
+ startZoneOffset = zoneOffset(startTime),
242
+ endTime = endTime,
243
+ endZoneOffset = zoneOffset(endTime),
244
+ samples = samples
245
+ )
246
+ client.insertRecords(listOf(record))
247
+ }
248
+ }
249
+ }
250
+
251
+ fun parseInstant(value: String?, defaultInstant: Instant): Instant {
252
+ if (value.isNullOrBlank()) {
253
+ return defaultInstant
254
+ }
255
+ return Instant.parse(value)
256
+ }
257
+
258
+ private fun createSamplePayload(
259
+ dataType: HealthDataType,
260
+ startTime: Instant,
261
+ endTime: Instant,
262
+ value: Double,
263
+ metadata: Metadata
264
+ ): JSObject {
265
+ val payload = JSObject()
266
+ payload.put("dataType", dataType.identifier)
267
+ payload.put("value", value)
268
+ payload.put("unit", dataType.unit)
269
+ payload.put("startDate", formatter.format(startTime))
270
+ payload.put("endDate", formatter.format(endTime))
271
+
272
+ val dataOrigin = metadata.dataOrigin
273
+ payload.put("sourceId", dataOrigin.packageName)
274
+ payload.put("sourceName", dataOrigin.packageName)
275
+ metadata.device?.let { device ->
276
+ val manufacturer = device.manufacturer?.takeIf { it.isNotBlank() }
277
+ val model = device.model?.takeIf { it.isNotBlank() }
278
+ val label = listOfNotNull(manufacturer, model).joinToString(" ").trim()
279
+ if (label.isNotEmpty()) {
280
+ payload.put("sourceName", label)
281
+ }
282
+ }
283
+
284
+ return payload
285
+ }
286
+
287
+ private fun zoneOffset(instant: Instant): ZoneOffset? {
288
+ return ZoneId.systemDefault().rules.getOffset(instant)
289
+ }
290
+
291
+ private fun Double.toBpmLong(): Long {
292
+ return java.lang.Math.round(this.coerceAtLeast(0.0))
293
+ }
294
+
295
+ suspend fun queryWorkouts(
296
+ client: HealthConnectClient,
297
+ workoutType: String?,
298
+ startTime: Instant,
299
+ endTime: Instant,
300
+ limit: Int,
301
+ ascending: Boolean
302
+ ): JSArray {
303
+ val workouts = mutableListOf<Pair<Instant, JSObject>>()
304
+
305
+ var pageToken: String? = null
306
+ val pageSize = if (limit > 0) min(limit, MAX_PAGE_SIZE) else DEFAULT_PAGE_SIZE
307
+ var fetched = 0
308
+
309
+ val exerciseTypeFilter = WorkoutType.fromString(workoutType)
310
+
311
+ do {
312
+ val request = ReadRecordsRequest(
313
+ recordType = ExerciseSessionRecord::class,
314
+ timeRangeFilter = TimeRangeFilter.between(startTime, endTime),
315
+ pageSize = pageSize,
316
+ pageToken = pageToken
317
+ )
318
+ val response = client.readRecords(request)
319
+
320
+ response.records.forEach { record ->
321
+ val session = record as ExerciseSessionRecord
322
+
323
+ // Filter by exercise type if specified
324
+ if (exerciseTypeFilter != null && session.exerciseType != exerciseTypeFilter) {
325
+ return@forEach
326
+ }
327
+
328
+ // Aggregate calories and distance for this workout session
329
+ val aggregatedData = aggregateWorkoutData(client, session)
330
+ val payload = createWorkoutPayload(session, aggregatedData)
331
+ workouts.add(session.startTime to payload)
332
+ }
333
+
334
+ fetched += response.records.size
335
+ pageToken = response.pageToken
336
+ } while (pageToken != null && (limit <= 0 || fetched < limit))
337
+
338
+ val sorted = workouts.sortedBy { it.first }
339
+ val ordered = if (ascending) sorted else sorted.asReversed()
340
+ val limited = if (limit > 0) ordered.take(limit) else ordered
341
+
342
+ val array = JSArray()
343
+ limited.forEach { array.put(it.second) }
344
+ return array
345
+ }
346
+
347
+ private suspend fun aggregateWorkoutData(
348
+ client: HealthConnectClient,
349
+ session: ExerciseSessionRecord
350
+ ): WorkoutAggregatedData {
351
+ val timeRange = TimeRangeFilter.between(session.startTime, session.endTime)
352
+ // Don't filter by dataOrigin - distance might come from different sources
353
+ // than the workout session itself (e.g., fitness tracker vs workout app)
354
+
355
+ // Aggregate distance
356
+ val distanceAggregate = try {
357
+ val aggregateRequest = AggregateRequest(
358
+ metrics = setOf(DistanceRecord.DISTANCE_TOTAL),
359
+ timeRangeFilter = timeRange
360
+ // Removed dataOriginFilter to get distance from all sources during workout time
361
+ )
362
+ val result = client.aggregate(aggregateRequest)
363
+ result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
364
+ } catch (e: Exception) {
365
+ android.util.Log.d("HealthManager", "Distance aggregation failed for workout: ${e.message}", e)
366
+ null // Permission might not be granted or no data available
367
+ }
368
+
369
+ return WorkoutAggregatedData(
370
+ totalDistance = distanceAggregate
371
+ )
372
+ }
373
+
374
+ private data class WorkoutAggregatedData(
375
+ val totalDistance: Double?
376
+ )
377
+
378
+ private fun createWorkoutPayload(session: ExerciseSessionRecord, aggregatedData: WorkoutAggregatedData): JSObject {
379
+ val payload = JSObject()
380
+
381
+ // Workout type
382
+ payload.put("workoutType", WorkoutType.toWorkoutTypeString(session.exerciseType))
383
+
384
+ // Duration in seconds
385
+ val durationSeconds = Duration.between(session.startTime, session.endTime).seconds.toInt()
386
+ payload.put("duration", durationSeconds)
387
+
388
+ // Start and end dates
389
+ payload.put("startDate", formatter.format(session.startTime))
390
+ payload.put("endDate", formatter.format(session.endTime))
391
+
392
+ // Total distance (aggregated from DistanceRecord)
393
+ aggregatedData.totalDistance?.let { distance ->
394
+ payload.put("totalDistance", distance)
395
+ }
396
+
397
+ // Source information
398
+ val dataOrigin = session.metadata.dataOrigin
399
+ payload.put("sourceId", dataOrigin.packageName)
400
+ payload.put("sourceName", dataOrigin.packageName)
401
+ session.metadata.device?.let { device ->
402
+ val manufacturer = device.manufacturer?.takeIf { it.isNotBlank() }
403
+ val model = device.model?.takeIf { it.isNotBlank() }
404
+ val label = listOfNotNull(manufacturer, model).joinToString(" ").trim()
405
+ if (label.isNotEmpty()) {
406
+ payload.put("sourceName", label)
407
+ }
408
+ }
409
+
410
+ // Note: customMetadata is not available on Metadata in Health Connect
411
+ // Metadata only contains dataOrigin, device, and lastModifiedTime
412
+
413
+ return payload
414
+ }
415
+
416
+ companion object {
417
+ private const val DEFAULT_PAGE_SIZE = 100
418
+ private const val MAX_PAGE_SIZE = 500
419
+ }
420
+ }