@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,393 @@
1
+ package app.capgo.plugin.health
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.net.Uri
6
+ import androidx.activity.result.ActivityResult
7
+ import com.getcapacitor.JSArray
8
+ import com.getcapacitor.JSObject
9
+ import com.getcapacitor.Plugin
10
+ import com.getcapacitor.PluginCall
11
+ import com.getcapacitor.PluginMethod
12
+ import com.getcapacitor.annotation.ActivityCallback
13
+ import com.getcapacitor.annotation.CapacitorPlugin
14
+ import androidx.health.connect.client.HealthConnectClient
15
+ import androidx.health.connect.client.PermissionController
16
+ import java.time.Instant
17
+ import java.time.Duration
18
+ import java.time.format.DateTimeParseException
19
+ import kotlinx.coroutines.CoroutineScope
20
+ import kotlinx.coroutines.Dispatchers
21
+ import kotlinx.coroutines.SupervisorJob
22
+ import kotlinx.coroutines.cancel
23
+ import kotlinx.coroutines.launch
24
+
25
+ @CapacitorPlugin(name = "Health")
26
+ class HealthPlugin : Plugin() {
27
+ private val pluginVersion = "7.2.14"
28
+ private val manager = HealthManager()
29
+ private val pluginScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
30
+ private val permissionContract = PermissionController.createRequestPermissionResultContract()
31
+
32
+ // Store pending request data for callback
33
+ private var pendingReadTypes: List<HealthDataType> = emptyList()
34
+ private var pendingWriteTypes: List<HealthDataType> = emptyList()
35
+ private var pendingIncludeWorkouts: Boolean = false
36
+
37
+ override fun handleOnDestroy() {
38
+ super.handleOnDestroy()
39
+ pluginScope.cancel()
40
+ }
41
+
42
+ @PluginMethod
43
+ fun isAvailable(call: PluginCall) {
44
+ val status = HealthConnectClient.getSdkStatus(context)
45
+ call.resolve(availabilityPayload(status))
46
+ }
47
+
48
+ @PluginMethod
49
+ fun requestAuthorization(call: PluginCall) {
50
+ val (readTypes, includeWorkouts) = try {
51
+ parseTypeListWithWorkouts(call, "read")
52
+ } catch (e: IllegalArgumentException) {
53
+ call.reject(e.message, null, e)
54
+ return
55
+ }
56
+
57
+ val writeTypes = try {
58
+ parseTypeList(call, "write")
59
+ } catch (e: IllegalArgumentException) {
60
+ call.reject(e.message, null, e)
61
+ return
62
+ }
63
+
64
+ pluginScope.launch {
65
+ val client = getClientOrReject(call) ?: return@launch
66
+ val permissions = manager.permissionsFor(readTypes, writeTypes, includeWorkouts)
67
+
68
+ if (permissions.isEmpty()) {
69
+ val status = manager.authorizationStatus(client, readTypes, writeTypes, includeWorkouts)
70
+ call.resolve(status)
71
+ return@launch
72
+ }
73
+
74
+ val granted = client.permissionController.getGrantedPermissions()
75
+ if (granted.containsAll(permissions)) {
76
+ val status = manager.authorizationStatus(client, readTypes, writeTypes, includeWorkouts)
77
+ call.resolve(status)
78
+ return@launch
79
+ }
80
+
81
+ // Store types for callback
82
+ pendingReadTypes = readTypes
83
+ pendingWriteTypes = writeTypes
84
+ pendingIncludeWorkouts = includeWorkouts
85
+
86
+ // Create intent using the Health Connect permission contract
87
+ val intent = permissionContract.createIntent(context, permissions)
88
+
89
+ try {
90
+ startActivityForResult(call, intent, "handlePermissionResult")
91
+ } catch (e: Exception) {
92
+ pendingReadTypes = emptyList()
93
+ pendingWriteTypes = emptyList()
94
+ call.reject("Failed to launch Health Connect permission request.", null, e)
95
+ }
96
+ }
97
+ }
98
+
99
+ @ActivityCallback
100
+ private fun handlePermissionResult(call: PluginCall?, result: ActivityResult) {
101
+ if (call == null) {
102
+ return
103
+ }
104
+
105
+ val readTypes = pendingReadTypes
106
+ val writeTypes = pendingWriteTypes
107
+ val includeWorkouts = pendingIncludeWorkouts
108
+ pendingReadTypes = emptyList()
109
+ pendingWriteTypes = emptyList()
110
+ pendingIncludeWorkouts = false
111
+
112
+ pluginScope.launch {
113
+ val client = getClientOrReject(call) ?: return@launch
114
+ val status = manager.authorizationStatus(client, readTypes, writeTypes, includeWorkouts)
115
+ call.resolve(status)
116
+ }
117
+ }
118
+
119
+ @PluginMethod
120
+ fun checkAuthorization(call: PluginCall) {
121
+ val (readTypes, includeWorkouts) = try {
122
+ parseTypeListWithWorkouts(call, "read")
123
+ } catch (e: IllegalArgumentException) {
124
+ call.reject(e.message, null, e)
125
+ return
126
+ }
127
+
128
+ val writeTypes = try {
129
+ parseTypeList(call, "write")
130
+ } catch (e: IllegalArgumentException) {
131
+ call.reject(e.message, null, e)
132
+ return
133
+ }
134
+
135
+ pluginScope.launch {
136
+ val client = getClientOrReject(call) ?: return@launch
137
+ val status = manager.authorizationStatus(client, readTypes, writeTypes, includeWorkouts)
138
+ call.resolve(status)
139
+ }
140
+ }
141
+
142
+ @PluginMethod
143
+ fun readSamples(call: PluginCall) {
144
+ val identifier = call.getString("dataType")
145
+ if (identifier.isNullOrBlank()) {
146
+ call.reject("dataType is required")
147
+ return
148
+ }
149
+
150
+ val dataType = HealthDataType.from(identifier)
151
+ if (dataType == null) {
152
+ call.reject("Unsupported data type: $identifier")
153
+ return
154
+ }
155
+
156
+ val limit = (call.getInt("limit") ?: DEFAULT_LIMIT).coerceAtLeast(0)
157
+ val ascending = call.getBoolean("ascending") ?: false
158
+
159
+ val startInstant = try {
160
+ manager.parseInstant(call.getString("startDate"), Instant.now().minus(DEFAULT_PAST_DURATION))
161
+ } catch (e: DateTimeParseException) {
162
+ call.reject(e.message, null, e)
163
+ return
164
+ }
165
+
166
+ val endInstant = try {
167
+ manager.parseInstant(call.getString("endDate"), Instant.now())
168
+ } catch (e: DateTimeParseException) {
169
+ call.reject(e.message, null, e)
170
+ return
171
+ }
172
+
173
+ if (endInstant.isBefore(startInstant)) {
174
+ call.reject("endDate must be greater than or equal to startDate")
175
+ return
176
+ }
177
+
178
+ pluginScope.launch {
179
+ val client = getClientOrReject(call) ?: return@launch
180
+ try {
181
+ val samples = manager.readSamples(client, dataType, startInstant, endInstant, limit, ascending)
182
+ val result = JSObject().apply { put("samples", samples) }
183
+ call.resolve(result)
184
+ } catch (e: Exception) {
185
+ call.reject(e.message ?: "Failed to read samples.", null, e)
186
+ }
187
+ }
188
+ }
189
+
190
+ @PluginMethod
191
+ fun saveSample(call: PluginCall) {
192
+ val identifier = call.getString("dataType")
193
+ if (identifier.isNullOrBlank()) {
194
+ call.reject("dataType is required")
195
+ return
196
+ }
197
+
198
+ val dataType = HealthDataType.from(identifier)
199
+ if (dataType == null) {
200
+ call.reject("Unsupported data type: $identifier")
201
+ return
202
+ }
203
+
204
+ val value = call.getDouble("value")
205
+ if (value == null) {
206
+ call.reject("value is required")
207
+ return
208
+ }
209
+
210
+ val unit = call.getString("unit")
211
+ if (unit != null && unit != dataType.unit) {
212
+ call.reject("Unsupported unit $unit for ${dataType.identifier}. Expected ${dataType.unit}.")
213
+ return
214
+ }
215
+
216
+ val startInstant = try {
217
+ manager.parseInstant(call.getString("startDate"), Instant.now())
218
+ } catch (e: DateTimeParseException) {
219
+ call.reject(e.message, null, e)
220
+ return
221
+ }
222
+
223
+ val endInstant = try {
224
+ manager.parseInstant(call.getString("endDate"), startInstant)
225
+ } catch (e: DateTimeParseException) {
226
+ call.reject(e.message, null, e)
227
+ return
228
+ }
229
+
230
+ if (endInstant.isBefore(startInstant)) {
231
+ call.reject("endDate must be greater than or equal to startDate")
232
+ return
233
+ }
234
+
235
+ val metadataObj = call.getObject("metadata")
236
+ val metadata = metadataObj?.let { obj ->
237
+ val iterator = obj.keys()
238
+ val map = mutableMapOf<String, String>()
239
+ while (iterator.hasNext()) {
240
+ val key = iterator.next()
241
+ val rawValue = obj.opt(key)
242
+ if (rawValue is String) {
243
+ map[key] = rawValue
244
+ }
245
+ }
246
+ map.takeIf { it.isNotEmpty() }
247
+ }
248
+
249
+ pluginScope.launch {
250
+ val client = getClientOrReject(call) ?: return@launch
251
+ try {
252
+ manager.saveSample(client, dataType, value, startInstant, endInstant, metadata)
253
+ call.resolve()
254
+ } catch (e: Exception) {
255
+ call.reject(e.message ?: "Failed to save sample.", null, e)
256
+ }
257
+ }
258
+ }
259
+
260
+ private fun parseTypeList(call: PluginCall, key: String): List<HealthDataType> {
261
+ val array = call.getArray(key) ?: JSArray()
262
+ val result = mutableListOf<HealthDataType>()
263
+ for (i in 0 until array.length()) {
264
+ val identifier = array.optString(i, null) ?: continue
265
+ val dataType = HealthDataType.from(identifier)
266
+ ?: throw IllegalArgumentException("Unsupported data type: $identifier")
267
+ result.add(dataType)
268
+ }
269
+ return result
270
+ }
271
+
272
+ private fun parseTypeListWithWorkouts(call: PluginCall, key: String): Pair<List<HealthDataType>, Boolean> {
273
+ val array = call.getArray(key) ?: JSArray()
274
+ val result = mutableListOf<HealthDataType>()
275
+ var includeWorkouts = false
276
+ for (i in 0 until array.length()) {
277
+ val identifier = array.optString(i, null) ?: continue
278
+ if (identifier == "workouts") {
279
+ includeWorkouts = true
280
+ } else {
281
+ val dataType = HealthDataType.from(identifier)
282
+ ?: throw IllegalArgumentException("Unsupported data type: $identifier")
283
+ result.add(dataType)
284
+ }
285
+ }
286
+ return Pair(result, includeWorkouts)
287
+ }
288
+
289
+ private fun getClientOrReject(call: PluginCall): HealthConnectClient? {
290
+ val status = HealthConnectClient.getSdkStatus(context)
291
+ if (status != HealthConnectClient.SDK_AVAILABLE) {
292
+ call.reject(availabilityReason(status))
293
+ return null
294
+ }
295
+ return HealthConnectClient.getOrCreate(context)
296
+ }
297
+
298
+ private fun availabilityPayload(status: Int): JSObject {
299
+ val payload = JSObject()
300
+ payload.put("platform", "android")
301
+ payload.put("available", status == HealthConnectClient.SDK_AVAILABLE)
302
+ if (status != HealthConnectClient.SDK_AVAILABLE) {
303
+ payload.put("reason", availabilityReason(status))
304
+ }
305
+ return payload
306
+ }
307
+
308
+ private fun availabilityReason(status: Int): String {
309
+ return when (status) {
310
+ HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> "Health Connect needs an update."
311
+ HealthConnectClient.SDK_UNAVAILABLE -> "Health Connect is unavailable on this device."
312
+ else -> "Health Connect availability unknown."
313
+ }
314
+ }
315
+
316
+ @PluginMethod
317
+ fun getPluginVersion(call: PluginCall) {
318
+ try {
319
+ val ret = JSObject()
320
+ ret.put("version", pluginVersion)
321
+ call.resolve(ret)
322
+ } catch (e: Exception) {
323
+ call.reject("Could not get plugin version", e)
324
+ }
325
+ }
326
+
327
+ @PluginMethod
328
+ fun openHealthConnectSettings(call: PluginCall) {
329
+ try {
330
+ val intent = Intent(HEALTH_CONNECT_SETTINGS_ACTION)
331
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
332
+ context.startActivity(intent)
333
+ call.resolve()
334
+ } catch (e: Exception) {
335
+ call.reject("Failed to open Health Connect settings", null, e)
336
+ }
337
+ }
338
+
339
+ @PluginMethod
340
+ fun showPrivacyPolicy(call: PluginCall) {
341
+ try {
342
+ val intent = Intent(context, PermissionsRationaleActivity::class.java)
343
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
344
+ context.startActivity(intent)
345
+ call.resolve()
346
+ } catch (e: Exception) {
347
+ call.reject("Failed to show privacy policy", null, e)
348
+ }
349
+ }
350
+
351
+ @PluginMethod
352
+ fun queryWorkouts(call: PluginCall) {
353
+ val workoutType = call.getString("workoutType")
354
+ val limit = (call.getInt("limit") ?: DEFAULT_LIMIT).coerceAtLeast(0)
355
+ val ascending = call.getBoolean("ascending") ?: false
356
+
357
+ val startInstant = try {
358
+ manager.parseInstant(call.getString("startDate"), Instant.now().minus(DEFAULT_PAST_DURATION))
359
+ } catch (e: DateTimeParseException) {
360
+ call.reject(e.message, null, e)
361
+ return
362
+ }
363
+
364
+ val endInstant = try {
365
+ manager.parseInstant(call.getString("endDate"), Instant.now())
366
+ } catch (e: DateTimeParseException) {
367
+ call.reject(e.message, null, e)
368
+ return
369
+ }
370
+
371
+ if (endInstant.isBefore(startInstant)) {
372
+ call.reject("endDate must be greater than or equal to startDate")
373
+ return
374
+ }
375
+
376
+ pluginScope.launch {
377
+ val client = getClientOrReject(call) ?: return@launch
378
+ try {
379
+ val workouts = manager.queryWorkouts(client, workoutType, startInstant, endInstant, limit, ascending)
380
+ val result = JSObject().apply { put("workouts", workouts) }
381
+ call.resolve(result)
382
+ } catch (e: Exception) {
383
+ call.reject(e.message ?: "Failed to query workouts.", null, e)
384
+ }
385
+ }
386
+ }
387
+
388
+ companion object {
389
+ private const val DEFAULT_LIMIT = 100
390
+ private val DEFAULT_PAST_DURATION: Duration = Duration.ofDays(1)
391
+ private const val HEALTH_CONNECT_SETTINGS_ACTION = "androidx.health.ACTION_HEALTH_CONNECT_SETTINGS"
392
+ }
393
+ }
@@ -0,0 +1,57 @@
1
+ package app.capgo.plugin.health
2
+
3
+ import android.app.Activity
4
+ import android.os.Bundle
5
+ import android.webkit.WebView
6
+ import android.webkit.WebViewClient
7
+
8
+ /**
9
+ * Activity that displays the app's privacy policy for Health Connect permissions.
10
+ *
11
+ * This activity is launched by Health Connect when the user wants to see why the app
12
+ * needs health data access. It displays a WebView with the privacy policy.
13
+ *
14
+ * The privacy policy URL can be customized by defining a string resource named
15
+ * "health_connect_privacy_policy_url" in your app's res/values/strings.xml:
16
+ *
17
+ * ```xml
18
+ * <resources>
19
+ * <string name="health_connect_privacy_policy_url">https://yourapp.com/privacy</string>
20
+ * </resources>
21
+ * ```
22
+ *
23
+ * Alternatively, you can place an HTML file at www/privacypolicy.html in your assets.
24
+ */
25
+ class PermissionsRationaleActivity : Activity() {
26
+
27
+ private val defaultUrl = "file:///android_asset/public/privacypolicy.html"
28
+
29
+ override fun onCreate(savedInstanceState: Bundle?) {
30
+ super.onCreate(savedInstanceState)
31
+
32
+ val webView = WebView(applicationContext)
33
+ webView.webViewClient = WebViewClient()
34
+ webView.settings.javaScriptEnabled = false
35
+ setContentView(webView)
36
+
37
+ val url = getPrivacyPolicyUrl()
38
+ webView.loadUrl(url)
39
+ }
40
+
41
+ private fun getPrivacyPolicyUrl(): String {
42
+ return try {
43
+ val resId = resources.getIdentifier(
44
+ "health_connect_privacy_policy_url",
45
+ "string",
46
+ packageName
47
+ )
48
+ if (resId != 0) {
49
+ getString(resId)
50
+ } else {
51
+ defaultUrl
52
+ }
53
+ } catch (e: Exception) {
54
+ defaultUrl
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,64 @@
1
+ package app.capgo.plugin.health
2
+
3
+ import androidx.health.connect.client.records.ExerciseSessionRecord
4
+
5
+ object WorkoutType {
6
+ fun fromString(type: String?): Int? {
7
+ if (type.isNullOrBlank()) return null
8
+
9
+ return when (type) {
10
+ "running" -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING
11
+ "cycling" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
12
+ "walking" -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING
13
+ "swimming" -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL
14
+ "yoga" -> ExerciseSessionRecord.EXERCISE_TYPE_YOGA
15
+ "strengthTraining" -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING
16
+ "hiking" -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING
17
+ "tennis" -> ExerciseSessionRecord.EXERCISE_TYPE_TENNIS
18
+ "basketball" -> ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL
19
+ "soccer" -> ExerciseSessionRecord.EXERCISE_TYPE_SOCCER
20
+ "americanFootball" -> ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AMERICAN
21
+ "baseball" -> ExerciseSessionRecord.EXERCISE_TYPE_BASEBALL
22
+ "crossTraining" -> ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING
23
+ "elliptical" -> ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL
24
+ "rowing" -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING
25
+ "stairClimbing" -> ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING
26
+ "traditionalStrengthTraining" -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING
27
+ "waterFitness" -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL
28
+ "waterPolo" -> ExerciseSessionRecord.EXERCISE_TYPE_WATER_POLO
29
+ "waterSports" -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER
30
+ "wrestling" -> ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS
31
+ "other" -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
32
+ else -> null
33
+ }
34
+ }
35
+
36
+ fun toWorkoutTypeString(exerciseType: Int): String {
37
+ return when (exerciseType) {
38
+ ExerciseSessionRecord.EXERCISE_TYPE_RUNNING -> "running"
39
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "cycling"
40
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY -> "cycling"
41
+ ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "walking"
42
+ ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL -> "swimming"
43
+ ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER -> "swimming"
44
+ ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "yoga"
45
+ ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING -> "strengthTraining"
46
+ ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "hiking"
47
+ ExerciseSessionRecord.EXERCISE_TYPE_TENNIS -> "tennis"
48
+ ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL -> "basketball"
49
+ ExerciseSessionRecord.EXERCISE_TYPE_SOCCER -> "soccer"
50
+ ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AMERICAN -> "americanFootball"
51
+ ExerciseSessionRecord.EXERCISE_TYPE_BASEBALL -> "baseball"
52
+ ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING -> "crossTraining"
53
+ ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL -> "elliptical"
54
+ ExerciseSessionRecord.EXERCISE_TYPE_ROWING -> "rowing"
55
+ ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE -> "rowing"
56
+ ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING -> "stairClimbing"
57
+ ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE -> "stairClimbing"
58
+ ExerciseSessionRecord.EXERCISE_TYPE_WATER_POLO -> "waterPolo"
59
+ ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS -> "wrestling"
60
+ ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT -> "other"
61
+ else -> "other"
62
+ }
63
+ }
64
+ }
File without changes