@capgo/capacitor-health 7.0.0

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,280 @@
1
+ package app.capgo.plugin.health
2
+
3
+ import androidx.health.connect.client.HealthConnectClient
4
+ import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
5
+ import androidx.health.connect.client.records.DistanceRecord
6
+ import androidx.health.connect.client.records.HeartRateRecord
7
+ import androidx.health.connect.client.records.Record
8
+ import androidx.health.connect.client.records.StepsRecord
9
+ import androidx.health.connect.client.records.WeightRecord
10
+ import androidx.health.connect.client.request.ReadRecordsRequest
11
+ import androidx.health.connect.client.time.TimeRangeFilter
12
+ import androidx.health.connect.client.units.Energy
13
+ import androidx.health.connect.client.units.Length
14
+ import androidx.health.connect.client.units.Mass
15
+ import androidx.health.connect.client.records.metadata.Metadata
16
+ import com.getcapacitor.JSArray
17
+ import com.getcapacitor.JSObject
18
+ import java.time.Instant
19
+ import java.time.ZoneId
20
+ import java.time.ZoneOffset
21
+ import java.time.format.DateTimeFormatter
22
+ import kotlin.math.min
23
+ import kotlin.collections.buildSet
24
+
25
+ class HealthManager {
26
+
27
+ private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT
28
+
29
+ fun permissionsFor(readTypes: Collection<HealthDataType>, writeTypes: Collection<HealthDataType>): Set<String> = buildSet {
30
+ readTypes.forEach { add(it.readPermission) }
31
+ writeTypes.forEach { add(it.writePermission) }
32
+ }
33
+
34
+ suspend fun authorizationStatus(
35
+ client: HealthConnectClient,
36
+ readTypes: Collection<HealthDataType>,
37
+ writeTypes: Collection<HealthDataType>
38
+ ): JSObject {
39
+ val granted = client.permissionController.getGrantedPermissions()
40
+
41
+ val readAuthorized = JSArray()
42
+ val readDenied = JSArray()
43
+ readTypes.forEach { type ->
44
+ if (granted.contains(type.readPermission)) {
45
+ readAuthorized.put(type.identifier)
46
+ } else {
47
+ readDenied.put(type.identifier)
48
+ }
49
+ }
50
+
51
+ val writeAuthorized = JSArray()
52
+ val writeDenied = JSArray()
53
+ writeTypes.forEach { type ->
54
+ if (granted.contains(type.writePermission)) {
55
+ writeAuthorized.put(type.identifier)
56
+ } else {
57
+ writeDenied.put(type.identifier)
58
+ }
59
+ }
60
+
61
+ return JSObject().apply {
62
+ put("readAuthorized", readAuthorized)
63
+ put("readDenied", readDenied)
64
+ put("writeAuthorized", writeAuthorized)
65
+ put("writeDenied", writeDenied)
66
+ }
67
+ }
68
+
69
+ suspend fun readSamples(
70
+ client: HealthConnectClient,
71
+ dataType: HealthDataType,
72
+ startTime: Instant,
73
+ endTime: Instant,
74
+ limit: Int,
75
+ ascending: Boolean
76
+ ): JSArray {
77
+ val samples = mutableListOf<Pair<Instant, JSObject>>()
78
+ when (dataType) {
79
+ HealthDataType.STEPS -> readRecords(client, StepsRecord::class, startTime, endTime, limit) { record ->
80
+ val payload = createSamplePayload(
81
+ dataType,
82
+ record.startTime,
83
+ record.endTime,
84
+ record.count.toDouble(),
85
+ record.metadata
86
+ )
87
+ samples.add(record.startTime to payload)
88
+ }
89
+ HealthDataType.DISTANCE -> readRecords(client, DistanceRecord::class, startTime, endTime, limit) { record ->
90
+ val payload = createSamplePayload(
91
+ dataType,
92
+ record.startTime,
93
+ record.endTime,
94
+ record.distance.inMeters,
95
+ record.metadata
96
+ )
97
+ samples.add(record.startTime to payload)
98
+ }
99
+ HealthDataType.CALORIES -> readRecords(client, ActiveCaloriesBurnedRecord::class, startTime, endTime, limit) { record ->
100
+ val payload = createSamplePayload(
101
+ dataType,
102
+ record.startTime,
103
+ record.endTime,
104
+ record.energy.inKilocalories,
105
+ record.metadata
106
+ )
107
+ samples.add(record.startTime to payload)
108
+ }
109
+ HealthDataType.WEIGHT -> readRecords(client, WeightRecord::class, startTime, endTime, limit) { record ->
110
+ val payload = createSamplePayload(
111
+ dataType,
112
+ record.time,
113
+ record.time,
114
+ record.weight.inKilograms,
115
+ record.metadata
116
+ )
117
+ samples.add(record.time to payload)
118
+ }
119
+ HealthDataType.HEART_RATE -> readRecords(client, HeartRateRecord::class, startTime, endTime, limit) { record ->
120
+ record.samples.forEach { sample ->
121
+ val payload = createSamplePayload(
122
+ dataType,
123
+ sample.time,
124
+ sample.time,
125
+ sample.beatsPerMinute.toDouble(),
126
+ record.metadata
127
+ )
128
+ samples.add(sample.time to payload)
129
+ }
130
+ }
131
+ }
132
+
133
+ val sorted = samples.sortedBy { it.first }
134
+ val ordered = if (ascending) sorted else sorted.asReversed()
135
+ val limited = if (limit > 0) ordered.take(limit) else ordered
136
+
137
+ val array = JSArray()
138
+ limited.forEach { array.put(it.second) }
139
+ return array
140
+ }
141
+
142
+ private suspend fun <T : Record> readRecords(
143
+ client: HealthConnectClient,
144
+ recordClass: kotlin.reflect.KClass<T>,
145
+ startTime: Instant,
146
+ endTime: Instant,
147
+ limit: Int,
148
+ consumer: (record: T) -> Unit
149
+ ) {
150
+ var pageToken: String? = null
151
+ val pageSize = if (limit > 0) min(limit, MAX_PAGE_SIZE) else DEFAULT_PAGE_SIZE
152
+ var fetched = 0
153
+
154
+ do {
155
+ val request = ReadRecordsRequest(
156
+ recordType = recordClass,
157
+ timeRangeFilter = TimeRangeFilter.between(startTime, endTime),
158
+ pageSize = pageSize,
159
+ pageToken = pageToken
160
+ )
161
+ val response = client.readRecords(request)
162
+ response.records.forEach { record ->
163
+ consumer(record)
164
+ }
165
+ fetched += response.records.size
166
+ pageToken = response.pageToken
167
+ } while (pageToken != null && (limit <= 0 || fetched < limit))
168
+ }
169
+
170
+ @Suppress("UNUSED_PARAMETER")
171
+ suspend fun saveSample(
172
+ client: HealthConnectClient,
173
+ dataType: HealthDataType,
174
+ value: Double,
175
+ startTime: Instant,
176
+ endTime: Instant,
177
+ metadata: Map<String, String>?
178
+ ) {
179
+ when (dataType) {
180
+ HealthDataType.STEPS -> {
181
+ val record = StepsRecord(
182
+ startTime = startTime,
183
+ startZoneOffset = zoneOffset(startTime),
184
+ endTime = endTime,
185
+ endZoneOffset = zoneOffset(endTime),
186
+ count = value.toLong().coerceAtLeast(0)
187
+ )
188
+ client.insertRecords(listOf(record))
189
+ }
190
+ HealthDataType.DISTANCE -> {
191
+ val record = DistanceRecord(
192
+ startTime = startTime,
193
+ startZoneOffset = zoneOffset(startTime),
194
+ endTime = endTime,
195
+ endZoneOffset = zoneOffset(endTime),
196
+ distance = Length.meters(value)
197
+ )
198
+ client.insertRecords(listOf(record))
199
+ }
200
+ HealthDataType.CALORIES -> {
201
+ val record = ActiveCaloriesBurnedRecord(
202
+ startTime = startTime,
203
+ startZoneOffset = zoneOffset(startTime),
204
+ endTime = endTime,
205
+ endZoneOffset = zoneOffset(endTime),
206
+ energy = Energy.kilocalories(value)
207
+ )
208
+ client.insertRecords(listOf(record))
209
+ }
210
+ HealthDataType.WEIGHT -> {
211
+ val record = WeightRecord(
212
+ time = startTime,
213
+ zoneOffset = zoneOffset(startTime),
214
+ weight = Mass.kilograms(value)
215
+ )
216
+ client.insertRecords(listOf(record))
217
+ }
218
+ HealthDataType.HEART_RATE -> {
219
+ val samples = listOf(HeartRateRecord.Sample(time = startTime, beatsPerMinute = value.toBpmLong()))
220
+ val record = HeartRateRecord(
221
+ startTime = startTime,
222
+ startZoneOffset = zoneOffset(startTime),
223
+ endTime = endTime,
224
+ endZoneOffset = zoneOffset(endTime),
225
+ samples = samples
226
+ )
227
+ client.insertRecords(listOf(record))
228
+ }
229
+ }
230
+ }
231
+
232
+ fun parseInstant(value: String?, defaultInstant: Instant): Instant {
233
+ if (value.isNullOrBlank()) {
234
+ return defaultInstant
235
+ }
236
+ return Instant.parse(value)
237
+ }
238
+
239
+ private fun createSamplePayload(
240
+ dataType: HealthDataType,
241
+ startTime: Instant,
242
+ endTime: Instant,
243
+ value: Double,
244
+ metadata: Metadata
245
+ ): JSObject {
246
+ val payload = JSObject()
247
+ payload.put("dataType", dataType.identifier)
248
+ payload.put("value", value)
249
+ payload.put("unit", dataType.unit)
250
+ payload.put("startDate", formatter.format(startTime))
251
+ payload.put("endDate", formatter.format(endTime))
252
+
253
+ val dataOrigin = metadata.dataOrigin
254
+ payload.put("sourceId", dataOrigin.packageName)
255
+ payload.put("sourceName", dataOrigin.packageName)
256
+ metadata.device?.let { device ->
257
+ val manufacturer = device.manufacturer?.takeIf { it.isNotBlank() }
258
+ val model = device.model?.takeIf { it.isNotBlank() }
259
+ val label = listOfNotNull(manufacturer, model).joinToString(" ").trim()
260
+ if (label.isNotEmpty()) {
261
+ payload.put("sourceName", label)
262
+ }
263
+ }
264
+
265
+ return payload
266
+ }
267
+
268
+ private fun zoneOffset(instant: Instant): ZoneOffset? {
269
+ return ZoneId.systemDefault().rules.getOffset(instant)
270
+ }
271
+
272
+ private fun Double.toBpmLong(): Long {
273
+ return java.lang.Math.round(this.coerceAtLeast(0.0))
274
+ }
275
+
276
+ companion object {
277
+ private const val DEFAULT_PAGE_SIZE = 100
278
+ private const val MAX_PAGE_SIZE = 500
279
+ }
280
+ }
@@ -0,0 +1,316 @@
1
+ package app.capgo.plugin.health
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import com.getcapacitor.JSArray
6
+ import com.getcapacitor.JSObject
7
+ import com.getcapacitor.Plugin
8
+ import com.getcapacitor.PluginCall
9
+ import com.getcapacitor.PluginMethod
10
+ import com.getcapacitor.annotation.CapacitorPlugin
11
+ import androidx.health.connect.client.HealthConnectClient
12
+ import androidx.health.connect.client.contracts.HealthPermissionsRequestContract
13
+ import java.time.Instant
14
+ import java.time.Duration
15
+ import java.time.format.DateTimeParseException
16
+ import kotlinx.coroutines.CoroutineScope
17
+ import kotlinx.coroutines.Dispatchers
18
+ import kotlinx.coroutines.SupervisorJob
19
+ import kotlinx.coroutines.cancel
20
+ import kotlinx.coroutines.launch
21
+ import kotlinx.coroutines.withContext
22
+
23
+ @CapacitorPlugin(name = "Health")
24
+ class HealthPlugin : Plugin() {
25
+ private val manager = HealthManager()
26
+ private val pluginScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
27
+ private var pendingAuthorization: PendingAuthorization? = null
28
+ private val permissionRequestContract = HealthPermissionsRequestContract()
29
+
30
+ override fun handleOnDestroy() {
31
+ super.handleOnDestroy()
32
+ pluginScope.cancel()
33
+ }
34
+
35
+ @PluginMethod
36
+ fun isAvailable(call: PluginCall) {
37
+ val status = HealthConnectClient.getSdkStatus(context)
38
+ call.resolve(availabilityPayload(status))
39
+ }
40
+
41
+ @PluginMethod
42
+ fun requestAuthorization(call: PluginCall) {
43
+ val readTypes = try {
44
+ parseTypeList(call, "read")
45
+ } catch (e: IllegalArgumentException) {
46
+ call.reject(e.message, null, e)
47
+ return
48
+ }
49
+
50
+ val writeTypes = try {
51
+ parseTypeList(call, "write")
52
+ } catch (e: IllegalArgumentException) {
53
+ call.reject(e.message, null, e)
54
+ return
55
+ }
56
+
57
+ pluginScope.launch {
58
+ val client = getClientOrReject(call) ?: return@launch
59
+ val permissions = manager.permissionsFor(readTypes, writeTypes)
60
+
61
+ if (permissions.isEmpty()) {
62
+ val status = manager.authorizationStatus(client, readTypes, writeTypes)
63
+ call.resolve(status)
64
+ return@launch
65
+ }
66
+
67
+ val granted = client.permissionController.getGrantedPermissions()
68
+ if (granted.containsAll(permissions)) {
69
+ val status = manager.authorizationStatus(client, readTypes, writeTypes)
70
+ call.resolve(status)
71
+ return@launch
72
+ }
73
+
74
+ val activity = activity
75
+ if (activity == null) {
76
+ call.reject("Unable to request authorization without an active Activity.")
77
+ return@launch
78
+ }
79
+
80
+ if (pendingAuthorization != null) {
81
+ call.reject("Another authorization request is already running. Try again later.")
82
+ return@launch
83
+ }
84
+
85
+ val intent = withContext(Dispatchers.IO) {
86
+ permissionRequestContract.createIntent(activity, permissions)
87
+ }
88
+ pendingAuthorization = PendingAuthorization(call, readTypes, writeTypes)
89
+ call.setKeepAlive(true)
90
+
91
+ withContext(Dispatchers.Main) {
92
+ try {
93
+ activity.startActivityForResult(intent, REQUEST_AUTHORIZATION)
94
+ } catch (e: Exception) {
95
+ pendingAuthorization = null
96
+ call.setKeepAlive(false)
97
+ call.reject("Failed to launch Health Connect permission request.", null, e)
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ @PluginMethod
104
+ fun checkAuthorization(call: PluginCall) {
105
+ val readTypes = try {
106
+ parseTypeList(call, "read")
107
+ } catch (e: IllegalArgumentException) {
108
+ call.reject(e.message, null, e)
109
+ return
110
+ }
111
+
112
+ val writeTypes = try {
113
+ parseTypeList(call, "write")
114
+ } catch (e: IllegalArgumentException) {
115
+ call.reject(e.message, null, e)
116
+ return
117
+ }
118
+
119
+ pluginScope.launch {
120
+ val client = getClientOrReject(call) ?: return@launch
121
+ val status = manager.authorizationStatus(client, readTypes, writeTypes)
122
+ call.resolve(status)
123
+ }
124
+ }
125
+
126
+ @PluginMethod
127
+ fun readSamples(call: PluginCall) {
128
+ val identifier = call.getString("dataType")
129
+ if (identifier.isNullOrBlank()) {
130
+ call.reject("dataType is required")
131
+ return
132
+ }
133
+
134
+ val dataType = HealthDataType.from(identifier)
135
+ if (dataType == null) {
136
+ call.reject("Unsupported data type: $identifier")
137
+ return
138
+ }
139
+
140
+ val limit = (call.getInt("limit") ?: DEFAULT_LIMIT).coerceAtLeast(0)
141
+ val ascending = call.getBoolean("ascending") ?: false
142
+
143
+ val startInstant = try {
144
+ manager.parseInstant(call.getString("startDate"), Instant.now().minus(DEFAULT_PAST_DURATION))
145
+ } catch (e: DateTimeParseException) {
146
+ call.reject(e.message, null, e)
147
+ return
148
+ }
149
+
150
+ val endInstant = try {
151
+ manager.parseInstant(call.getString("endDate"), Instant.now())
152
+ } catch (e: DateTimeParseException) {
153
+ call.reject(e.message, null, e)
154
+ return
155
+ }
156
+
157
+ if (endInstant.isBefore(startInstant)) {
158
+ call.reject("endDate must be greater than or equal to startDate")
159
+ return
160
+ }
161
+
162
+ pluginScope.launch {
163
+ val client = getClientOrReject(call) ?: return@launch
164
+ try {
165
+ val samples = manager.readSamples(client, dataType, startInstant, endInstant, limit, ascending)
166
+ val result = JSObject().apply { put("samples", samples) }
167
+ call.resolve(result)
168
+ } catch (e: Exception) {
169
+ call.reject(e.message ?: "Failed to read samples.", null, e)
170
+ }
171
+ }
172
+ }
173
+
174
+ @PluginMethod
175
+ fun saveSample(call: PluginCall) {
176
+ val identifier = call.getString("dataType")
177
+ if (identifier.isNullOrBlank()) {
178
+ call.reject("dataType is required")
179
+ return
180
+ }
181
+
182
+ val dataType = HealthDataType.from(identifier)
183
+ if (dataType == null) {
184
+ call.reject("Unsupported data type: $identifier")
185
+ return
186
+ }
187
+
188
+ val value = call.getDouble("value")
189
+ if (value == null) {
190
+ call.reject("value is required")
191
+ return
192
+ }
193
+
194
+ val unit = call.getString("unit")
195
+ if (unit != null && unit != dataType.unit) {
196
+ call.reject("Unsupported unit $unit for ${dataType.identifier}. Expected ${dataType.unit}.")
197
+ return
198
+ }
199
+
200
+ val startInstant = try {
201
+ manager.parseInstant(call.getString("startDate"), Instant.now())
202
+ } catch (e: DateTimeParseException) {
203
+ call.reject(e.message, null, e)
204
+ return
205
+ }
206
+
207
+ val endInstant = try {
208
+ manager.parseInstant(call.getString("endDate"), startInstant)
209
+ } catch (e: DateTimeParseException) {
210
+ call.reject(e.message, null, e)
211
+ return
212
+ }
213
+
214
+ if (endInstant.isBefore(startInstant)) {
215
+ call.reject("endDate must be greater than or equal to startDate")
216
+ return
217
+ }
218
+
219
+ val metadataObj = call.getObject("metadata")
220
+ val metadata = metadataObj?.let { obj ->
221
+ val iterator = obj.keys()
222
+ val map = mutableMapOf<String, String>()
223
+ while (iterator.hasNext()) {
224
+ val key = iterator.next()
225
+ val rawValue = obj.opt(key)
226
+ if (rawValue is String) {
227
+ map[key] = rawValue
228
+ }
229
+ }
230
+ map.takeIf { it.isNotEmpty() }
231
+ }
232
+
233
+ pluginScope.launch {
234
+ val client = getClientOrReject(call) ?: return@launch
235
+ try {
236
+ manager.saveSample(client, dataType, value, startInstant, endInstant, metadata)
237
+ call.resolve()
238
+ } catch (e: Exception) {
239
+ call.reject(e.message ?: "Failed to save sample.", null, e)
240
+ }
241
+ }
242
+ }
243
+
244
+ override fun handleOnActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
245
+ super.handleOnActivityResult(requestCode, resultCode, data)
246
+ if (requestCode != REQUEST_AUTHORIZATION) {
247
+ return
248
+ }
249
+
250
+ val pending = pendingAuthorization ?: return
251
+ pendingAuthorization = null
252
+ pending.call.setKeepAlive(false)
253
+
254
+ pluginScope.launch {
255
+ val client = getClientOrReject(pending.call) ?: return@launch
256
+ if (resultCode != Activity.RESULT_OK) {
257
+ pending.call.reject("Authorization request was cancelled or denied.")
258
+ return@launch
259
+ }
260
+
261
+ val status = manager.authorizationStatus(client, pending.readTypes, pending.writeTypes)
262
+ pending.call.resolve(status)
263
+ }
264
+ }
265
+
266
+ private fun parseTypeList(call: PluginCall, key: String): List<HealthDataType> {
267
+ val array = call.getArray(key) ?: JSArray()
268
+ val result = mutableListOf<HealthDataType>()
269
+ for (i in 0 until array.length()) {
270
+ val identifier = array.optString(i, null) ?: continue
271
+ val dataType = HealthDataType.from(identifier)
272
+ ?: throw IllegalArgumentException("Unsupported data type: $identifier")
273
+ result.add(dataType)
274
+ }
275
+ return result
276
+ }
277
+
278
+ private fun getClientOrReject(call: PluginCall): HealthConnectClient? {
279
+ val status = HealthConnectClient.getSdkStatus(context)
280
+ if (status != HealthConnectClient.SDK_AVAILABLE) {
281
+ call.reject(availabilityReason(status))
282
+ return null
283
+ }
284
+ return HealthConnectClient.getOrCreate(context)
285
+ }
286
+
287
+ private fun availabilityPayload(status: Int): JSObject {
288
+ val payload = JSObject()
289
+ payload.put("platform", "android")
290
+ payload.put("available", status == HealthConnectClient.SDK_AVAILABLE)
291
+ if (status != HealthConnectClient.SDK_AVAILABLE) {
292
+ payload.put("reason", availabilityReason(status))
293
+ }
294
+ return payload
295
+ }
296
+
297
+ private fun availabilityReason(status: Int): String {
298
+ return when (status) {
299
+ HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> "Health Connect needs an update."
300
+ HealthConnectClient.SDK_UNAVAILABLE -> "Health Connect is unavailable on this device."
301
+ else -> "Health Connect availability unknown."
302
+ }
303
+ }
304
+
305
+ private data class PendingAuthorization(
306
+ val call: PluginCall,
307
+ val readTypes: List<HealthDataType>,
308
+ val writeTypes: List<HealthDataType>
309
+ )
310
+
311
+ companion object {
312
+ private const val REQUEST_AUTHORIZATION = 9501
313
+ private const val DEFAULT_LIMIT = 100
314
+ private val DEFAULT_PAST_DURATION: Duration = Duration.ofDays(1)
315
+ }
316
+ }
File without changes