@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.
- package/LICENSE +373 -0
- package/README.md +426 -0
- package/android/build.gradle +70 -0
- package/android/src/main/AndroidManifest.xml +41 -0
- package/android/src/main/java/app/capgo/plugin/health/HealthDataType.kt +34 -0
- package/android/src/main/java/app/capgo/plugin/health/HealthManager.kt +420 -0
- package/android/src/main/java/app/capgo/plugin/health/HealthPlugin.kt +393 -0
- package/android/src/main/java/app/capgo/plugin/health/PermissionsRationaleActivity.kt +57 -0
- package/android/src/main/java/app/capgo/plugin/health/WorkoutType.kt +64 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +773 -0
- package/dist/esm/definitions.d.ts +146 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +15 -0
- package/dist/esm/web.js +35 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +49 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +52 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +75 -0
|
@@ -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
|
+
}
|