@flomentumsolutions/capacitor-health-extended 0.6.4 → 0.7.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/README.md
CHANGED
|
@@ -27,6 +27,7 @@ Thanks [@mley](https://github.com/mley) for the ground work. The goal of this fo
|
|
|
27
27
|
- Request and verify health permissions
|
|
28
28
|
- Query aggregated data like steps or calories
|
|
29
29
|
- Retrieve workout sessions with optional route and heart rate data
|
|
30
|
+
- Create workout sessions (e.g., rock climbing) with totals, optional routes, and heart-rate samples (write APIs)
|
|
30
31
|
- Fetch the latest samples for steps, distance (incl. cycling), calories (active/total/basal), heart‑rate, resting HR, HRV, respiratory rate, blood pressure, oxygen saturation, blood glucose, body temperature (basal + core), body fat, height, weight, flights climbed, sleep (incl. REM duration), and exercise time.
|
|
31
32
|
- Read profile characteristics on iOS: biological sex, blood type, date of birth, Fitzpatrick skin type, wheelchair use.
|
|
32
33
|
|
|
@@ -57,6 +58,7 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
|
|
|
57
58
|
* Make sure your app id has the 'HealthKit' entitlement when this plugin is installed (see iOS dev center).
|
|
58
59
|
* Also, make sure your app and App Store description comply with the Apple review guidelines.
|
|
59
60
|
* There are two keys to be added to the info.plist file: NSHealthShareUsageDescription and NSHealthUpdateUsageDescription.
|
|
61
|
+
* Request WRITE_* permissions with `requestHealthPermissions` to enable saving workouts/energy/distance/routes/heart-rate samples to HealthKit.
|
|
60
62
|
|
|
61
63
|
### Android
|
|
62
64
|
|
|
@@ -68,6 +70,7 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
|
|
|
68
70
|
</queries>
|
|
69
71
|
|
|
70
72
|
<!-- Declare permissions you’ll request -->
|
|
73
|
+
<!-- READ permissions -->
|
|
71
74
|
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
|
72
75
|
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
|
|
73
76
|
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
|
|
@@ -90,13 +93,23 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
|
|
|
90
93
|
<uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED" />
|
|
91
94
|
<uses-permission android:name="android.permission.health.READ_BASAL_METABOLIC_RATE" />
|
|
92
95
|
<uses-permission android:name="android.permission.health.READ_SLEEP" />
|
|
96
|
+
<!-- WRITE permissions -->
|
|
97
|
+
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
|
|
98
|
+
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED" />
|
|
99
|
+
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED" />
|
|
100
|
+
<uses-permission android:name="android.permission.health.WRITE_DISTANCE" />
|
|
101
|
+
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
|
|
102
|
+
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
|
|
93
103
|
```
|
|
94
104
|
|
|
105
|
+
Include the WRITE_* entries when you call `saveWorkout` to insert exercise sessions, energy, distance, routes, or heart rate samples.
|
|
106
|
+
|
|
95
107
|
* Android Manifest in application tag
|
|
96
108
|
```xml
|
|
97
109
|
<!-- Handle Health Connect rationale (Android 13-) -->
|
|
110
|
+
<!-- REPLACE com.my.app with your details -->
|
|
98
111
|
<activity
|
|
99
|
-
android:name=".PermissionsRationaleActivity"
|
|
112
|
+
android:name="com.my.app.PermissionsRationaleActivity"
|
|
100
113
|
android:exported="true">
|
|
101
114
|
<intent-filter>
|
|
102
115
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
|
|
@@ -105,10 +118,11 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
|
|
|
105
118
|
</activity>
|
|
106
119
|
|
|
107
120
|
<!-- Handle Android 14+ alias -->
|
|
121
|
+
<!-- REPLACE com.my.app with your details -->
|
|
108
122
|
<activity-alias
|
|
109
123
|
android:name="ViewPermissionUsageActivity"
|
|
110
124
|
android:exported="true"
|
|
111
|
-
android:targetActivity=".PermissionsRationaleActivity"
|
|
125
|
+
android:targetActivity="com.my.app.PermissionsRationaleActivity"
|
|
112
126
|
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
|
113
127
|
<intent-filter>
|
|
114
128
|
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
|
|
@@ -127,8 +141,9 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
|
|
|
127
141
|
</application>
|
|
128
142
|
```
|
|
129
143
|
|
|
130
|
-
* Create `com.my.app.PermissionsRationaleActivity.kt` with:
|
|
144
|
+
* Create `com.my.app.PermissionsRationaleActivity.kt` with (REPLACE com.my.app with your details):
|
|
131
145
|
```xml
|
|
146
|
+
<!-- REPLACE com.my.app with your details -->
|
|
132
147
|
package com.my.app
|
|
133
148
|
|
|
134
149
|
import android.os.Bundle
|
|
@@ -223,6 +238,7 @@ This setup ensures your WebView will load HTTPS content securely and complies wi
|
|
|
223
238
|
* [`queryHeight()`](#queryheight)
|
|
224
239
|
* [`queryHeartRate()`](#queryheartrate)
|
|
225
240
|
* [`querySteps()`](#querysteps)
|
|
241
|
+
* [`saveWorkout(...)`](#saveworkout)
|
|
226
242
|
* [Interfaces](#interfaces)
|
|
227
243
|
* [Type Aliases](#type-aliases)
|
|
228
244
|
|
|
@@ -447,6 +463,26 @@ Query latest steps sample
|
|
|
447
463
|
--------------------
|
|
448
464
|
|
|
449
465
|
|
|
466
|
+
### saveWorkout(...)
|
|
467
|
+
|
|
468
|
+
```typescript
|
|
469
|
+
saveWorkout(request: SaveWorkoutRequest) => Promise<SaveWorkoutResponse>
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Create a workout session with optional totals and route/heart-rate samples.
|
|
473
|
+
- iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.
|
|
474
|
+
- Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.
|
|
475
|
+
- Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).
|
|
476
|
+
|
|
477
|
+
| Param | Type |
|
|
478
|
+
| ------------- | ----------------------------------------------------------------- |
|
|
479
|
+
| **`request`** | <code><a href="#saveworkoutrequest">SaveWorkoutRequest</a></code> |
|
|
480
|
+
|
|
481
|
+
**Returns:** <code>Promise<<a href="#saveworkoutresponse">SaveWorkoutResponse</a>></code>
|
|
482
|
+
|
|
483
|
+
--------------------
|
|
484
|
+
|
|
485
|
+
|
|
450
486
|
### Interfaces
|
|
451
487
|
|
|
452
488
|
|
|
@@ -573,6 +609,28 @@ Query latest steps sample
|
|
|
573
609
|
| **`metadata`** | <code><a href="#record">Record</a><string, unknown></code> |
|
|
574
610
|
|
|
575
611
|
|
|
612
|
+
#### SaveWorkoutResponse
|
|
613
|
+
|
|
614
|
+
| Prop | Type |
|
|
615
|
+
| ------------- | -------------------- |
|
|
616
|
+
| **`success`** | <code>boolean</code> |
|
|
617
|
+
| **`id`** | <code>string</code> |
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
#### SaveWorkoutRequest
|
|
621
|
+
|
|
622
|
+
| Prop | Type |
|
|
623
|
+
| ---------------------- | ------------------------------------------------------------------- |
|
|
624
|
+
| **`activityType`** | <code><a href="#workoutactivitytype">WorkoutActivityType</a></code> |
|
|
625
|
+
| **`startDate`** | <code>string</code> |
|
|
626
|
+
| **`endDate`** | <code>string</code> |
|
|
627
|
+
| **`calories`** | <code>number</code> |
|
|
628
|
+
| **`distance`** | <code>number</code> |
|
|
629
|
+
| **`metadata`** | <code><a href="#record">Record</a><string, any></code> |
|
|
630
|
+
| **`route`** | <code>RouteSample[]</code> |
|
|
631
|
+
| **`heartRateSamples`** | <code>HeartRateSample[]</code> |
|
|
632
|
+
|
|
633
|
+
|
|
576
634
|
### Type Aliases
|
|
577
635
|
|
|
578
636
|
|
|
@@ -580,12 +638,14 @@ Query latest steps sample
|
|
|
580
638
|
|
|
581
639
|
Construct a type with a set of properties K of type T
|
|
582
640
|
|
|
583
|
-
<code>{
|
|
584
641
|
[P in K]: T;
|
|
585
642
|
}</code>
|
|
643
|
+
<code>{
|
|
644
|
+
[P in K]: T;
|
|
645
|
+
}</code>
|
|
586
646
|
|
|
587
647
|
|
|
588
648
|
#### HealthPermission
|
|
589
649
|
|
|
590
|
-
<code>'READ_STEPS' | 'READ_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE'</code>
|
|
650
|
+
<code>'READ_STEPS' | 'READ_WORKOUTS' | 'WRITE_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'WRITE_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'WRITE_TOTAL_CALORIES' | 'READ_DISTANCE' | 'WRITE_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'WRITE_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'WRITE_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE'</code>
|
|
591
651
|
|
|
592
652
|
|
|
593
653
|
#### HealthBiologicalSex
|
|
@@ -612,4 +672,9 @@ Construct a type with a set of properties K of type T
|
|
|
612
672
|
|
|
613
673
|
<code>'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'sleep-rem' | 'hrv' | 'blood-pressure'</code>
|
|
614
674
|
|
|
675
|
+
|
|
676
|
+
#### WorkoutActivityType
|
|
677
|
+
|
|
678
|
+
<code>'rock-climbing' | 'climbing' | 'hiking' | 'running' | 'walking' | 'cycling' | 'biking' | 'strength-training' | 'yoga' | 'other'</code>
|
|
679
|
+
|
|
615
680
|
</docgen-api>
|
|
@@ -10,11 +10,14 @@ import androidx.health.connect.client.aggregate.AggregateMetric
|
|
|
10
10
|
import androidx.health.connect.client.aggregate.AggregationResult
|
|
11
11
|
import androidx.health.connect.client.aggregate.AggregationResultGroupedByPeriod
|
|
12
12
|
import androidx.health.connect.client.records.*
|
|
13
|
+
import androidx.health.connect.client.records.metadata.Metadata
|
|
13
14
|
import androidx.health.connect.client.request.AggregateGroupByPeriodRequest
|
|
14
15
|
import androidx.health.connect.client.request.AggregateRequest
|
|
15
16
|
import androidx.health.connect.client.request.ReadRecordsRequest
|
|
16
17
|
import androidx.health.connect.client.time.TimeRangeFilter
|
|
17
18
|
import androidx.health.connect.client.records.Record
|
|
19
|
+
import androidx.health.connect.client.units.kilocalories
|
|
20
|
+
import androidx.health.connect.client.units.meters
|
|
18
21
|
import com.getcapacitor.JSArray
|
|
19
22
|
import com.getcapacitor.JSObject
|
|
20
23
|
import com.getcapacitor.Plugin
|
|
@@ -57,7 +60,13 @@ enum class CapHealthPermission {
|
|
|
57
60
|
READ_BODY_FAT,
|
|
58
61
|
READ_FLOORS_CLIMBED,
|
|
59
62
|
READ_BASAL_CALORIES,
|
|
60
|
-
READ_SLEEP
|
|
63
|
+
READ_SLEEP,
|
|
64
|
+
WRITE_WORKOUTS,
|
|
65
|
+
WRITE_ACTIVE_CALORIES,
|
|
66
|
+
WRITE_TOTAL_CALORIES,
|
|
67
|
+
WRITE_DISTANCE,
|
|
68
|
+
WRITE_HEART_RATE,
|
|
69
|
+
WRITE_ROUTE;
|
|
61
70
|
|
|
62
71
|
companion object {
|
|
63
72
|
fun from(s: String): CapHealthPermission? {
|
|
@@ -95,7 +104,13 @@ enum class CapHealthPermission {
|
|
|
95
104
|
Permission(alias = "READ_BODY_FAT", strings = ["android.permission.health.READ_BODY_FAT"]),
|
|
96
105
|
Permission(alias = "READ_FLOORS_CLIMBED", strings = ["android.permission.health.READ_FLOORS_CLIMBED"]),
|
|
97
106
|
Permission(alias = "READ_BASAL_CALORIES", strings = ["android.permission.health.READ_BASAL_METABOLIC_RATE"]),
|
|
98
|
-
Permission(alias = "READ_SLEEP", strings = ["android.permission.health.READ_SLEEP"])
|
|
107
|
+
Permission(alias = "READ_SLEEP", strings = ["android.permission.health.READ_SLEEP"]),
|
|
108
|
+
Permission(alias = "WRITE_WORKOUTS", strings = ["android.permission.health.WRITE_EXERCISE"]),
|
|
109
|
+
Permission(alias = "WRITE_ACTIVE_CALORIES", strings = ["android.permission.health.WRITE_ACTIVE_CALORIES_BURNED"]),
|
|
110
|
+
Permission(alias = "WRITE_TOTAL_CALORIES", strings = ["android.permission.health.WRITE_TOTAL_CALORIES_BURNED"]),
|
|
111
|
+
Permission(alias = "WRITE_DISTANCE", strings = ["android.permission.health.WRITE_DISTANCE"]),
|
|
112
|
+
Permission(alias = "WRITE_HEART_RATE", strings = ["android.permission.health.WRITE_HEART_RATE"]),
|
|
113
|
+
Permission(alias = "WRITE_ROUTE", strings = ["android.permission.health.WRITE_EXERCISE_ROUTE"])
|
|
99
114
|
]
|
|
100
115
|
)
|
|
101
116
|
|
|
@@ -132,7 +147,13 @@ class HealthPlugin : Plugin() {
|
|
|
132
147
|
CapHealthPermission.READ_BODY_FAT to HealthPermission.getReadPermission(BodyFatRecord::class),
|
|
133
148
|
CapHealthPermission.READ_FLOORS_CLIMBED to HealthPermission.getReadPermission(FloorsClimbedRecord::class),
|
|
134
149
|
CapHealthPermission.READ_BASAL_CALORIES to HealthPermission.getReadPermission(BasalMetabolicRateRecord::class),
|
|
135
|
-
CapHealthPermission.READ_SLEEP to HealthPermission.getReadPermission(SleepSessionRecord::class)
|
|
150
|
+
CapHealthPermission.READ_SLEEP to HealthPermission.getReadPermission(SleepSessionRecord::class),
|
|
151
|
+
CapHealthPermission.WRITE_WORKOUTS to HealthPermission.getWritePermission(ExerciseSessionRecord::class),
|
|
152
|
+
CapHealthPermission.WRITE_ACTIVE_CALORIES to HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class),
|
|
153
|
+
CapHealthPermission.WRITE_TOTAL_CALORIES to HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class),
|
|
154
|
+
CapHealthPermission.WRITE_DISTANCE to HealthPermission.getWritePermission(DistanceRecord::class),
|
|
155
|
+
CapHealthPermission.WRITE_HEART_RATE to HealthPermission.getWritePermission(HeartRateRecord::class),
|
|
156
|
+
CapHealthPermission.WRITE_ROUTE to HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE
|
|
136
157
|
)
|
|
137
158
|
|
|
138
159
|
override fun load() {
|
|
@@ -1498,6 +1519,151 @@ class HealthPlugin : Plugin() {
|
|
|
1498
1519
|
return granted.contains(targetPermission)
|
|
1499
1520
|
}
|
|
1500
1521
|
|
|
1522
|
+
@PluginMethod
|
|
1523
|
+
fun saveWorkout(call: PluginCall) {
|
|
1524
|
+
if (!ensureClientInitialized(call)) return
|
|
1525
|
+
|
|
1526
|
+
val activityTypeRaw = call.getString("activityType")
|
|
1527
|
+
val startDate = call.getString("startDate")
|
|
1528
|
+
val endDate = call.getString("endDate")
|
|
1529
|
+
val calories = call.getDouble("calories")
|
|
1530
|
+
val distance = call.getDouble("distance")
|
|
1531
|
+
val metadata = call.getObject("metadata")
|
|
1532
|
+
val routeArray = call.getArray("route")
|
|
1533
|
+
val heartRateArray = call.getArray("heartRateSamples")
|
|
1534
|
+
|
|
1535
|
+
if (activityTypeRaw == null || startDate == null || endDate == null) {
|
|
1536
|
+
call.reject("Missing required parameters: activityType, startDate, endDate")
|
|
1537
|
+
return
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
val startInstant = parseInstant(startDate)
|
|
1541
|
+
val endInstant = parseInstant(endDate)
|
|
1542
|
+
if (startInstant == null || endInstant == null) {
|
|
1543
|
+
call.reject("Invalid startDate or endDate; must be ISO-8601 strings.")
|
|
1544
|
+
return
|
|
1545
|
+
}
|
|
1546
|
+
if (!startInstant.isBefore(endInstant)) {
|
|
1547
|
+
call.reject("startDate must be before endDate")
|
|
1548
|
+
return
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
1552
|
+
try {
|
|
1553
|
+
val granted = healthConnectClient.permissionController.getGrantedPermissions()
|
|
1554
|
+
fun ensurePermission(cap: CapHealthPermission, name: String): Boolean {
|
|
1555
|
+
val hc = permissionMapping[cap]
|
|
1556
|
+
val ok = hc != null && granted.contains(hc)
|
|
1557
|
+
if (!ok) {
|
|
1558
|
+
call.reject("Missing $name permission")
|
|
1559
|
+
}
|
|
1560
|
+
return ok
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if (!ensurePermission(CapHealthPermission.WRITE_WORKOUTS, "WRITE_WORKOUTS")) return@launch
|
|
1564
|
+
|
|
1565
|
+
val startZoneOffset = startInstant.atZone(ZoneId.systemDefault()).offset
|
|
1566
|
+
val endZoneOffset = endInstant.atZone(ZoneId.systemDefault()).offset
|
|
1567
|
+
|
|
1568
|
+
val exerciseType = resolveExerciseType(activityTypeRaw)
|
|
1569
|
+
val title = metadata?.optString("title")?.takeIf { it.isNotBlank() }
|
|
1570
|
+
val notes = metadata?.optString("notes")?.takeIf { it.isNotBlank() }
|
|
1571
|
+
?: metadata?.optString("description")?.takeIf { it.isNotBlank() }
|
|
1572
|
+
|
|
1573
|
+
val route = buildExerciseRoute(routeArray, startInstant, endInstant)
|
|
1574
|
+
if (route != null && !granted.contains(HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE)) {
|
|
1575
|
+
call.reject("Missing WRITE_ROUTE permission")
|
|
1576
|
+
return@launch
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
val records = mutableListOf<Record>()
|
|
1580
|
+
records.add(
|
|
1581
|
+
ExerciseSessionRecord(
|
|
1582
|
+
startTime = startInstant,
|
|
1583
|
+
startZoneOffset = startZoneOffset,
|
|
1584
|
+
endTime = endInstant,
|
|
1585
|
+
endZoneOffset = endZoneOffset,
|
|
1586
|
+
metadata = Metadata.manualEntry(),
|
|
1587
|
+
exerciseType = exerciseType,
|
|
1588
|
+
title = title,
|
|
1589
|
+
notes = notes,
|
|
1590
|
+
exerciseRoute = route
|
|
1591
|
+
)
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
if (calories != null) {
|
|
1595
|
+
when {
|
|
1596
|
+
granted.contains(permissionMapping[CapHealthPermission.WRITE_ACTIVE_CALORIES]) -> {
|
|
1597
|
+
records.add(
|
|
1598
|
+
ActiveCaloriesBurnedRecord(
|
|
1599
|
+
startTime = startInstant,
|
|
1600
|
+
startZoneOffset = startZoneOffset,
|
|
1601
|
+
endTime = endInstant,
|
|
1602
|
+
endZoneOffset = endZoneOffset,
|
|
1603
|
+
energy = kilocalories(calories),
|
|
1604
|
+
metadata = Metadata.manualEntry()
|
|
1605
|
+
)
|
|
1606
|
+
)
|
|
1607
|
+
}
|
|
1608
|
+
granted.contains(permissionMapping[CapHealthPermission.WRITE_TOTAL_CALORIES]) -> {
|
|
1609
|
+
records.add(
|
|
1610
|
+
TotalCaloriesBurnedRecord(
|
|
1611
|
+
startTime = startInstant,
|
|
1612
|
+
startZoneOffset = startZoneOffset,
|
|
1613
|
+
endTime = endInstant,
|
|
1614
|
+
endZoneOffset = endZoneOffset,
|
|
1615
|
+
energy = kilocalories(calories),
|
|
1616
|
+
metadata = Metadata.manualEntry()
|
|
1617
|
+
)
|
|
1618
|
+
)
|
|
1619
|
+
}
|
|
1620
|
+
else -> {
|
|
1621
|
+
call.reject("Missing WRITE_ACTIVE_CALORIES or WRITE_TOTAL_CALORIES permission")
|
|
1622
|
+
return@launch
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (distance != null) {
|
|
1628
|
+
if (!ensurePermission(CapHealthPermission.WRITE_DISTANCE, "WRITE_DISTANCE")) return@launch
|
|
1629
|
+
records.add(
|
|
1630
|
+
DistanceRecord(
|
|
1631
|
+
startTime = startInstant,
|
|
1632
|
+
startZoneOffset = startZoneOffset,
|
|
1633
|
+
endTime = endInstant,
|
|
1634
|
+
endZoneOffset = endZoneOffset,
|
|
1635
|
+
distance = meters(distance),
|
|
1636
|
+
metadata = Metadata.manualEntry()
|
|
1637
|
+
)
|
|
1638
|
+
)
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
val heartRateSamples = buildHeartRateSamples(heartRateArray, startInstant, endInstant)
|
|
1642
|
+
if (heartRateSamples.isNotEmpty()) {
|
|
1643
|
+
if (!ensurePermission(CapHealthPermission.WRITE_HEART_RATE, "WRITE_HEART_RATE")) return@launch
|
|
1644
|
+
records.add(
|
|
1645
|
+
HeartRateRecord(
|
|
1646
|
+
startTime = heartRateSamples.first().time,
|
|
1647
|
+
startZoneOffset = heartRateSamples.first().time.atZone(ZoneId.systemDefault()).offset,
|
|
1648
|
+
endTime = heartRateSamples.last().time,
|
|
1649
|
+
endZoneOffset = heartRateSamples.last().time.atZone(ZoneId.systemDefault()).offset,
|
|
1650
|
+
samples = heartRateSamples,
|
|
1651
|
+
metadata = Metadata.manualEntry()
|
|
1652
|
+
)
|
|
1653
|
+
)
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
val response = healthConnectClient.insertRecords(records)
|
|
1657
|
+
val result = JSObject()
|
|
1658
|
+
result.put("success", true)
|
|
1659
|
+
response.recordIdsList.firstOrNull()?.let { result.put("id", it) }
|
|
1660
|
+
call.resolve(result)
|
|
1661
|
+
} catch (e: Exception) {
|
|
1662
|
+
call.reject("Failed to save workout: ${e.message}")
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1501
1667
|
@PluginMethod
|
|
1502
1668
|
fun queryWorkouts(call: PluginCall) {
|
|
1503
1669
|
if (!ensureClientInitialized(call)) return
|
|
@@ -1689,6 +1855,71 @@ class HealthPlugin : Plugin() {
|
|
|
1689
1855
|
return routeArray
|
|
1690
1856
|
}
|
|
1691
1857
|
|
|
1858
|
+
private fun parseInstant(value: Any?): Instant? {
|
|
1859
|
+
return when (value) {
|
|
1860
|
+
is String -> {
|
|
1861
|
+
try {
|
|
1862
|
+
Instant.parse(value)
|
|
1863
|
+
} catch (_: Exception) {
|
|
1864
|
+
value.toDoubleOrNull()?.let { Instant.ofEpochMilli(it.toLong()) }
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
is Number -> Instant.ofEpochMilli(value.toLong())
|
|
1868
|
+
else -> null
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
private fun resolveExerciseType(activityTypeRaw: String): Int {
|
|
1873
|
+
val normalized = activityTypeRaw.lowercase().replace("-", "_")
|
|
1874
|
+
return ExerciseSessionRecord.EXERCISE_TYPE_STRING_TO_INT_MAP[normalized]
|
|
1875
|
+
?: exerciseTypeMapping.entries.firstOrNull { it.value.equals(activityTypeRaw, ignoreCase = true) }?.key?.toInt()
|
|
1876
|
+
?: ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
private fun buildExerciseRoute(routeArray: JSArray?, startTime: Instant, endTime: Instant): ExerciseRoute? {
|
|
1880
|
+
val locations = routeArray
|
|
1881
|
+
?.toList<JSObject>()
|
|
1882
|
+
?.mapNotNull { point ->
|
|
1883
|
+
val lat = point.optDouble("lat", Double.NaN)
|
|
1884
|
+
val lng = point.optDouble("lng", Double.NaN)
|
|
1885
|
+
val ts = parseInstant(point.opt("timestamp")) ?: return@mapNotNull null
|
|
1886
|
+
if (lat.isNaN() || lng.isNaN()) return@mapNotNull null
|
|
1887
|
+
if (ts.isBefore(startTime) || ts.isAfter(endTime)) return@mapNotNull null
|
|
1888
|
+
val altitudeValue = point.optDouble("alt", Double.NaN)
|
|
1889
|
+
ExerciseRoute.Location(
|
|
1890
|
+
time = ts,
|
|
1891
|
+
latitude = lat,
|
|
1892
|
+
longitude = lng,
|
|
1893
|
+
altitude = if (altitudeValue.isNaN()) null else meters(altitudeValue)
|
|
1894
|
+
)
|
|
1895
|
+
}
|
|
1896
|
+
?.sortedBy { it.time }
|
|
1897
|
+
?.toList()
|
|
1898
|
+
|
|
1899
|
+
if (locations.isNullOrEmpty()) {
|
|
1900
|
+
return null
|
|
1901
|
+
}
|
|
1902
|
+
return ExerciseRoute(locations)
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
private fun buildHeartRateSamples(array: JSArray?, startTime: Instant, endTime: Instant): List<HeartRateRecord.Sample> {
|
|
1906
|
+
return array
|
|
1907
|
+
?.toList<JSObject>()
|
|
1908
|
+
?.mapNotNull { point ->
|
|
1909
|
+
val bpm = point.optDouble("bpm", Double.NaN)
|
|
1910
|
+
val ts = parseInstant(point.opt("timestamp")) ?: return@mapNotNull null
|
|
1911
|
+
if (bpm.isNaN()) return@mapNotNull null
|
|
1912
|
+
val clampedBpm = bpm.toLong().coerceIn(1, 300)
|
|
1913
|
+
HeartRateRecord.Sample(
|
|
1914
|
+
time = ts,
|
|
1915
|
+
beatsPerMinute = clampedBpm
|
|
1916
|
+
)
|
|
1917
|
+
}
|
|
1918
|
+
?.filter { !it.time.isBefore(startTime) && !it.time.isAfter(endTime) }
|
|
1919
|
+
?.sortedBy { it.time }
|
|
1920
|
+
?: emptyList()
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1692
1923
|
|
|
1693
1924
|
private val exerciseTypeMapping = mapOf(
|
|
1694
1925
|
0 to "OTHER",
|
|
@@ -86,8 +86,15 @@ export interface HealthPlugin {
|
|
|
86
86
|
* Query latest steps sample
|
|
87
87
|
*/
|
|
88
88
|
querySteps(): Promise<QueryLatestSampleResponse>;
|
|
89
|
+
/**
|
|
90
|
+
* Create a workout session with optional totals and route/heart-rate samples.
|
|
91
|
+
* - iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.
|
|
92
|
+
* - Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.
|
|
93
|
+
* - Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).
|
|
94
|
+
*/
|
|
95
|
+
saveWorkout(request: SaveWorkoutRequest): Promise<SaveWorkoutResponse>;
|
|
89
96
|
}
|
|
90
|
-
export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE';
|
|
97
|
+
export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'WRITE_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'WRITE_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'WRITE_TOTAL_CALORIES' | 'READ_DISTANCE' | 'WRITE_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'WRITE_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'WRITE_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE';
|
|
91
98
|
export type LatestDataType = 'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'sleep-rem' | 'hrv' | 'blood-pressure';
|
|
92
99
|
export interface PermissionsRequest {
|
|
93
100
|
permissions: HealthPermission[];
|
|
@@ -115,6 +122,21 @@ export interface RouteSample {
|
|
|
115
122
|
export interface QueryWorkoutResponse {
|
|
116
123
|
workouts: Workout[];
|
|
117
124
|
}
|
|
125
|
+
export type WorkoutActivityType = 'rock-climbing' | 'climbing' | 'hiking' | 'running' | 'walking' | 'cycling' | 'biking' | 'strength-training' | 'yoga' | 'other';
|
|
126
|
+
export interface SaveWorkoutRequest {
|
|
127
|
+
activityType: WorkoutActivityType;
|
|
128
|
+
startDate: string;
|
|
129
|
+
endDate: string;
|
|
130
|
+
calories?: number;
|
|
131
|
+
distance?: number;
|
|
132
|
+
metadata?: Record<string, any>;
|
|
133
|
+
route?: RouteSample[];
|
|
134
|
+
heartRateSamples?: HeartRateSample[];
|
|
135
|
+
}
|
|
136
|
+
export interface SaveWorkoutResponse {
|
|
137
|
+
success: boolean;
|
|
138
|
+
id?: string;
|
|
139
|
+
}
|
|
118
140
|
export interface Workout {
|
|
119
141
|
startDate: string;
|
|
120
142
|
endDate: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * iOS only: Reads user characteristics such as biological sex, blood type, date of birth, Fitzpatrick skin type, and wheelchair use.\n * Values are null when unavailable or permission was not granted. Android does not expose these characteristics; it returns `platformSupported: false` and a `platformMessage` for UI hints without emitting null values.\n */\n getCharacteristics(): Promise<CharacteristicsResponse>;\n\n /**\n * Query aggregated data\n * - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.\n * - `total-calories` is derived as active + basal energy on both iOS and Android for latest samples, aggregated queries, and workouts. We fall back to the platform's total‑calories metric (or active calories) when basal data isn't available or permission is missing. Request both `READ_ACTIVE_CALORIES` and `READ_BASAL_CALORIES` for full totals.\n * - Weight/height aggregation returns the latest sample per day (no averaging).\n * - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.\n * - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).\n * - Daily `bucket: \"day\"` queries use calendar-day boundaries in the device time zone (start-of-day through the next start-of-day) instead of a trailing 24-hour window. For “today,” send `startDate` at today’s start-of-day and `endDate` at now or tomorrow’s start-of-day.\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * - Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.\n * - `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.\n * @param request\n */\n queryLatestSample(request: { dataType: LatestDataType }): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest weight sample\n */\n queryWeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest height sample\n */\n queryHeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest heart rate sample\n */\n queryHeartRate(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest steps sample\n */\n querySteps(): Promise<QueryLatestSampleResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE'\n | 'READ_BASAL_CALORIES'\n | 'READ_RESPIRATORY_RATE'\n | 'READ_OXYGEN_SATURATION'\n | 'READ_BLOOD_GLUCOSE'\n | 'READ_BODY_TEMPERATURE'\n | 'READ_BASAL_BODY_TEMPERATURE'\n | 'READ_BODY_FAT'\n | 'READ_FLOORS_CLIMBED'\n | 'READ_SLEEP'\n | 'READ_EXERCISE_TIME'\n | 'READ_BIOLOGICAL_SEX'\n | 'READ_BLOOD_TYPE'\n | 'READ_DATE_OF_BIRTH'\n | 'READ_FITZPATRICK_SKIN_TYPE'\n | 'READ_WHEELCHAIR_USE';\n\nexport type LatestDataType =\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'mindfulness'\n | 'sleep'\n | 'sleep-rem'\n | 'hrv'\n | 'blood-pressure';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: Record<HealthPermission, boolean>;\n}\n\nexport interface QueryWorkoutRequest {\n startDate: string;\n endDate: string;\n includeHeartRate: boolean;\n includeRoute: boolean;\n includeSteps: boolean;\n}\n\nexport interface HeartRateSample {\n timestamp: string;\n bpm: number;\n}\n\nexport interface RouteSample {\n timestamp: string;\n lat: number;\n lng: number;\n alt?: number;\n}\n\nexport interface QueryWorkoutResponse {\n workouts: Workout[];\n}\n\nexport interface Workout {\n startDate: string;\n endDate: string;\n workoutType: string;\n sourceName: string;\n id?: string;\n duration: number;\n distance?: number;\n steps?: number;\n calories: number;\n sourceBundleId: string;\n route?: RouteSample[];\n heartRate?: HeartRateSample[];\n}\n\nexport interface QueryAggregatedRequest {\n startDate: string;\n endDate: string;\n dataType:\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'sleep'\n | 'mindfulness'\n | 'hrv'\n | 'blood-pressure';\n bucket: string;\n}\n\nexport interface QueryAggregatedResponse {\n aggregatedData: AggregatedSample[];\n}\n\nexport interface AggregatedSample {\n startDate: string;\n endDate: string;\n value: number;\n systolic?: number;\n diastolic?: number;\n unit?: string;\n}\n\nexport interface QueryLatestSampleResponse {\n value?: number;\n systolic?: number;\n diastolic?: number;\n timestamp: number;\n endTimestamp?: number;\n unit: string;\n metadata?: Record<string, unknown>;\n}\n\nexport interface CharacteristicsResponse {\n biologicalSex?: HealthBiologicalSex | null;\n bloodType?: HealthBloodType | null;\n dateOfBirth?: string | null;\n fitzpatrickSkinType?: HealthFitzpatrickSkinType | null;\n wheelchairUse?: HealthWheelchairUse | null;\n /**\n * Indicates whether the platform exposes these characteristics via the plugin (true on iOS, false on Android).\n */\n platformSupported?: boolean;\n /**\n * Optional platform-specific message; on Android we return a user-facing note explaining that values remain empty unless synced from iOS.\n */\n platformMessage?: string;\n}\n\nexport type HealthBiologicalSex = 'female' | 'male' | 'other' | 'not_set' | 'unknown';\n\nexport type HealthBloodType =\n | 'a-positive'\n | 'a-negative'\n | 'b-positive'\n | 'b-negative'\n | 'ab-positive'\n | 'ab-negative'\n | 'o-positive'\n | 'o-negative'\n | 'not_set'\n | 'unknown';\n\nexport type HealthFitzpatrickSkinType = 'type1' | 'type2' | 'type3' | 'type4' | 'type5' | 'type6' | 'not_set' | 'unknown';\n\nexport type HealthWheelchairUse = 'wheelchair_user' | 'not_wheelchair_user' | 'not_set' | 'unknown';\n"]}
|
|
1
|
+
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * iOS only: Reads user characteristics such as biological sex, blood type, date of birth, Fitzpatrick skin type, and wheelchair use.\n * Values are null when unavailable or permission was not granted. Android does not expose these characteristics; it returns `platformSupported: false` and a `platformMessage` for UI hints without emitting null values.\n */\n getCharacteristics(): Promise<CharacteristicsResponse>;\n\n /**\n * Query aggregated data\n * - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.\n * - `total-calories` is derived as active + basal energy on both iOS and Android for latest samples, aggregated queries, and workouts. We fall back to the platform's total‑calories metric (or active calories) when basal data isn't available or permission is missing. Request both `READ_ACTIVE_CALORIES` and `READ_BASAL_CALORIES` for full totals.\n * - Weight/height aggregation returns the latest sample per day (no averaging).\n * - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.\n * - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).\n * - Daily `bucket: \"day\"` queries use calendar-day boundaries in the device time zone (start-of-day through the next start-of-day) instead of a trailing 24-hour window. For “today,” send `startDate` at today’s start-of-day and `endDate` at now or tomorrow’s start-of-day.\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * - Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.\n * - `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.\n * @param request\n */\n queryLatestSample(request: { dataType: LatestDataType }): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest weight sample\n */\n queryWeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest height sample\n */\n queryHeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest heart rate sample\n */\n queryHeartRate(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest steps sample\n */\n querySteps(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Create a workout session with optional totals and route/heart-rate samples.\n * - iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.\n * - Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.\n * - Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).\n */\n saveWorkout(request: SaveWorkoutRequest): Promise<SaveWorkoutResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'WRITE_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'WRITE_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'WRITE_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'WRITE_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'WRITE_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'WRITE_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE'\n | 'READ_BASAL_CALORIES'\n | 'READ_RESPIRATORY_RATE'\n | 'READ_OXYGEN_SATURATION'\n | 'READ_BLOOD_GLUCOSE'\n | 'READ_BODY_TEMPERATURE'\n | 'READ_BASAL_BODY_TEMPERATURE'\n | 'READ_BODY_FAT'\n | 'READ_FLOORS_CLIMBED'\n | 'READ_SLEEP'\n | 'READ_EXERCISE_TIME'\n | 'READ_BIOLOGICAL_SEX'\n | 'READ_BLOOD_TYPE'\n | 'READ_DATE_OF_BIRTH'\n | 'READ_FITZPATRICK_SKIN_TYPE'\n | 'READ_WHEELCHAIR_USE';\n\nexport type LatestDataType =\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'mindfulness'\n | 'sleep'\n | 'sleep-rem'\n | 'hrv'\n | 'blood-pressure';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: Record<HealthPermission, boolean>;\n}\n\nexport interface QueryWorkoutRequest {\n startDate: string;\n endDate: string;\n includeHeartRate: boolean;\n includeRoute: boolean;\n includeSteps: boolean;\n}\n\nexport interface HeartRateSample {\n timestamp: string;\n bpm: number;\n}\n\nexport interface RouteSample {\n timestamp: string;\n lat: number;\n lng: number;\n alt?: number;\n}\n\nexport interface QueryWorkoutResponse {\n workouts: Workout[];\n}\n\nexport type WorkoutActivityType =\n | 'rock-climbing'\n | 'climbing'\n | 'hiking'\n | 'running'\n | 'walking'\n | 'cycling'\n | 'biking'\n | 'strength-training'\n | 'yoga'\n | 'other';\n\nexport interface SaveWorkoutRequest {\n activityType: WorkoutActivityType;\n startDate: string;\n endDate: string;\n calories?: number;\n distance?: number;\n metadata?: Record<string, any>;\n route?: RouteSample[];\n heartRateSamples?: HeartRateSample[];\n}\n\nexport interface SaveWorkoutResponse {\n success: boolean;\n id?: string;\n}\n\nexport interface Workout {\n startDate: string;\n endDate: string;\n workoutType: string;\n sourceName: string;\n id?: string;\n duration: number;\n distance?: number;\n steps?: number;\n calories: number;\n sourceBundleId: string;\n route?: RouteSample[];\n heartRate?: HeartRateSample[];\n}\n\nexport interface QueryAggregatedRequest {\n startDate: string;\n endDate: string;\n dataType:\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'sleep'\n | 'mindfulness'\n | 'hrv'\n | 'blood-pressure';\n bucket: string;\n}\n\nexport interface QueryAggregatedResponse {\n aggregatedData: AggregatedSample[];\n}\n\nexport interface AggregatedSample {\n startDate: string;\n endDate: string;\n value: number;\n systolic?: number;\n diastolic?: number;\n unit?: string;\n}\n\nexport interface QueryLatestSampleResponse {\n value?: number;\n systolic?: number;\n diastolic?: number;\n timestamp: number;\n endTimestamp?: number;\n unit: string;\n metadata?: Record<string, unknown>;\n}\n\nexport interface CharacteristicsResponse {\n biologicalSex?: HealthBiologicalSex | null;\n bloodType?: HealthBloodType | null;\n dateOfBirth?: string | null;\n fitzpatrickSkinType?: HealthFitzpatrickSkinType | null;\n wheelchairUse?: HealthWheelchairUse | null;\n /**\n * Indicates whether the platform exposes these characteristics via the plugin (true on iOS, false on Android).\n */\n platformSupported?: boolean;\n /**\n * Optional platform-specific message; on Android we return a user-facing note explaining that values remain empty unless synced from iOS.\n */\n platformMessage?: string;\n}\n\nexport type HealthBiologicalSex = 'female' | 'male' | 'other' | 'not_set' | 'unknown';\n\nexport type HealthBloodType =\n | 'a-positive'\n | 'a-negative'\n | 'b-positive'\n | 'b-negative'\n | 'ab-positive'\n | 'ab-negative'\n | 'o-positive'\n | 'o-negative'\n | 'not_set'\n | 'unknown';\n\nexport type HealthFitzpatrickSkinType = 'type1' | 'type2' | 'type3' | 'type4' | 'type5' | 'type6' | 'not_set' | 'unknown';\n\nexport type HealthWheelchairUse = 'wheelchair_user' | 'not_wheelchair_user' | 'not_set' | 'unknown';\n"]}
|
|
@@ -2,6 +2,7 @@ import Foundation
|
|
|
2
2
|
import UIKit
|
|
3
3
|
import Capacitor
|
|
4
4
|
import HealthKit
|
|
5
|
+
import CoreLocation
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Please read the Capacitor iOS Plugin Development Guide
|
|
@@ -19,7 +20,8 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
19
20
|
CAPPluginMethod(name: "queryAggregated", returnType: CAPPluginReturnPromise),
|
|
20
21
|
CAPPluginMethod(name: "queryWorkouts", returnType: CAPPluginReturnPromise),
|
|
21
22
|
CAPPluginMethod(name: "queryLatestSample", returnType: CAPPluginReturnPromise),
|
|
22
|
-
CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise)
|
|
23
|
+
CAPPluginMethod(name: "getCharacteristics", returnType: CAPPluginReturnPromise),
|
|
24
|
+
CAPPluginMethod(name: "saveWorkout", returnType: CAPPluginReturnPromise)
|
|
23
25
|
]
|
|
24
26
|
|
|
25
27
|
let healthStore = HKHealthStore()
|
|
@@ -41,7 +43,7 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
41
43
|
var result: [String: String] = [:]
|
|
42
44
|
|
|
43
45
|
for permission in permissions {
|
|
44
|
-
let hkTypes = permissionToHKObjectType(permission)
|
|
46
|
+
let hkTypes = permissionToHKObjectType(permission) + permissionToHKSampleType(permission)
|
|
45
47
|
for type in hkTypes {
|
|
46
48
|
let status = healthStore.authorizationStatus(for: type)
|
|
47
49
|
|
|
@@ -69,19 +71,20 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
69
71
|
|
|
70
72
|
print("⚡️ [HealthPlugin] Requesting permissions: \(permissions)")
|
|
71
73
|
|
|
72
|
-
let
|
|
74
|
+
let readTypes: [HKObjectType] = permissions.flatMap { permissionToHKObjectType($0) }
|
|
75
|
+
let shareTypes: [HKSampleType] = permissions.flatMap { permissionToHKSampleType($0) }
|
|
73
76
|
|
|
74
|
-
print("⚡️ [HealthPlugin] Mapped to \(
|
|
77
|
+
print("⚡️ [HealthPlugin] Mapped to \(readTypes.count) HKObjectTypes (read) and \(shareTypes.count) HKSampleTypes (share)")
|
|
75
78
|
|
|
76
79
|
// Validate that we have at least one valid permission type
|
|
77
|
-
guard !
|
|
80
|
+
guard !readTypes.isEmpty || !shareTypes.isEmpty else {
|
|
78
81
|
let invalidPermissions = permissions.filter { permissionToHKObjectType($0).isEmpty }
|
|
79
82
|
call.reject("No valid permission types found. Invalid permissions: \(invalidPermissions)")
|
|
80
83
|
return
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
DispatchQueue.main.async {
|
|
84
|
-
self.healthStore.requestAuthorization(toShare:
|
|
87
|
+
self.healthStore.requestAuthorization(toShare: Set(shareTypes), read: Set(readTypes)) { success, error in
|
|
85
88
|
DispatchQueue.main.async {
|
|
86
89
|
if success {
|
|
87
90
|
//we don't know which actual permissions were granted, so we assume all
|
|
@@ -634,16 +637,29 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
634
637
|
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
635
638
|
HKObjectType.quantityType(forIdentifier: .basalEnergyBurned) // iOS 16+
|
|
636
639
|
].compactMap { $0 }
|
|
640
|
+
case "WRITE_TOTAL_CALORIES":
|
|
641
|
+
return [
|
|
642
|
+
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
643
|
+
HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
|
|
644
|
+
].compactMap { $0 }
|
|
637
645
|
case "READ_ACTIVE_CALORIES":
|
|
638
646
|
return [HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)].compactMap{$0}
|
|
647
|
+
case "WRITE_ACTIVE_CALORIES":
|
|
648
|
+
return [HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)].compactMap{$0}
|
|
639
649
|
case "READ_WORKOUTS":
|
|
640
650
|
return [HKObjectType.workoutType()].compactMap{$0}
|
|
651
|
+
case "WRITE_WORKOUTS":
|
|
652
|
+
return [HKObjectType.workoutType()].compactMap{$0}
|
|
641
653
|
case "READ_HEART_RATE":
|
|
642
654
|
return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
|
|
655
|
+
case "WRITE_HEART_RATE":
|
|
656
|
+
return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap{$0}
|
|
643
657
|
case "READ_RESTING_HEART_RATE":
|
|
644
658
|
return [HKObjectType.quantityType(forIdentifier: .restingHeartRate)].compactMap { $0 }
|
|
645
659
|
case "READ_ROUTE":
|
|
646
660
|
return [HKSeriesType.workoutRoute()].compactMap{$0}
|
|
661
|
+
case "WRITE_ROUTE":
|
|
662
|
+
return [HKSeriesType.workoutRoute()].compactMap{$0}
|
|
647
663
|
case "READ_DISTANCE":
|
|
648
664
|
return [
|
|
649
665
|
HKObjectType.quantityType(forIdentifier: .distanceCycling),
|
|
@@ -651,6 +667,13 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
651
667
|
HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
652
668
|
HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
|
|
653
669
|
].compactMap{$0}
|
|
670
|
+
case "WRITE_DISTANCE":
|
|
671
|
+
return [
|
|
672
|
+
HKObjectType.quantityType(forIdentifier: .distanceCycling),
|
|
673
|
+
HKObjectType.quantityType(forIdentifier: .distanceSwimming),
|
|
674
|
+
HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
675
|
+
HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
|
|
676
|
+
].compactMap{$0}
|
|
654
677
|
case "READ_MINDFULNESS":
|
|
655
678
|
return [HKObjectType.categoryType(forIdentifier: .mindfulSession)!].compactMap{$0}
|
|
656
679
|
case "READ_HRV":
|
|
@@ -754,6 +777,34 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
754
777
|
}
|
|
755
778
|
}
|
|
756
779
|
|
|
780
|
+
func permissionToHKSampleType(_ permission: String) -> [HKSampleType] {
|
|
781
|
+
switch permission {
|
|
782
|
+
case "WRITE_WORKOUTS":
|
|
783
|
+
return [HKObjectType.workoutType()].compactMap { $0 as? HKSampleType }
|
|
784
|
+
case "WRITE_ACTIVE_CALORIES":
|
|
785
|
+
fallthrough
|
|
786
|
+
case "WRITE_TOTAL_CALORIES":
|
|
787
|
+
return [
|
|
788
|
+
HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
789
|
+
HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
|
|
790
|
+
].compactMap { $0 as? HKSampleType }
|
|
791
|
+
case "WRITE_DISTANCE":
|
|
792
|
+
return [
|
|
793
|
+
HKObjectType.quantityType(forIdentifier: .distanceCycling),
|
|
794
|
+
HKObjectType.quantityType(forIdentifier: .distanceSwimming),
|
|
795
|
+
HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
796
|
+
HKObjectType.quantityType(forIdentifier: .distanceDownhillSnowSports)
|
|
797
|
+
].compactMap { $0 as? HKSampleType }
|
|
798
|
+
case "WRITE_HEART_RATE":
|
|
799
|
+
return [HKObjectType.quantityType(forIdentifier: .heartRate)].compactMap { $0 as? HKSampleType }
|
|
800
|
+
case "WRITE_ROUTE":
|
|
801
|
+
return [HKSeriesType.workoutRoute()].compactMap { $0 as? HKSampleType }
|
|
802
|
+
default:
|
|
803
|
+
// For convenience, allow read permissions that are sample types to be added to the share set when requested.
|
|
804
|
+
return permissionToHKObjectType(permission).compactMap { $0 as? HKSampleType }
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
757
808
|
private func mapBiologicalSex(_ biologicalSex: HKBiologicalSex) -> String {
|
|
758
809
|
switch biologicalSex {
|
|
759
810
|
case .female:
|
|
@@ -1398,8 +1449,224 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1398
1449
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
1399
1450
|
return f
|
|
1400
1451
|
}()
|
|
1452
|
+
|
|
1453
|
+
private func parseDate(_ value: Any?) -> Date? {
|
|
1454
|
+
if let date = value as? Date {
|
|
1455
|
+
return date
|
|
1456
|
+
}
|
|
1457
|
+
if let str = value as? String {
|
|
1458
|
+
if let parsed = isoDateFormatter.date(from: str) {
|
|
1459
|
+
return parsed
|
|
1460
|
+
}
|
|
1461
|
+
if let millis = Double(str) {
|
|
1462
|
+
return Date(timeIntervalSince1970: millis / 1000)
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
if let number = value as? NSNumber {
|
|
1466
|
+
return Date(timeIntervalSince1970: number.doubleValue / 1000)
|
|
1467
|
+
}
|
|
1468
|
+
return nil
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
private func workoutActivityType(from value: String) -> HKWorkoutActivityType? {
|
|
1472
|
+
let normalized = value.lowercased()
|
|
1473
|
+
let mapping: [String: UInt] = [
|
|
1474
|
+
"rock-climbing": 9,
|
|
1475
|
+
"climbing": 9,
|
|
1476
|
+
"hiking": 24,
|
|
1477
|
+
"running": 37,
|
|
1478
|
+
"walking": 52,
|
|
1479
|
+
"cycling": 13,
|
|
1480
|
+
"biking": 13,
|
|
1481
|
+
"strength-training": 50,
|
|
1482
|
+
"strength_training": 50,
|
|
1483
|
+
"traditional-strength-training": 50,
|
|
1484
|
+
"yoga": 57,
|
|
1485
|
+
"other": 3000
|
|
1486
|
+
]
|
|
1487
|
+
|
|
1488
|
+
if let raw = mapping[normalized] {
|
|
1489
|
+
return HKWorkoutActivityType(rawValue: raw)
|
|
1490
|
+
}
|
|
1491
|
+
return HKWorkoutActivityType.other
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
private func sanitizeMetadata(_ metadata: JSObject?) -> [String: Any]? {
|
|
1495
|
+
guard let metadata = metadata else { return nil }
|
|
1496
|
+
var cleaned: [String: Any] = [:]
|
|
1497
|
+
for (key, value) in metadata {
|
|
1498
|
+
switch value {
|
|
1499
|
+
case let number as NSNumber:
|
|
1500
|
+
cleaned[key] = number
|
|
1501
|
+
case let string as NSString:
|
|
1502
|
+
cleaned[key] = string
|
|
1503
|
+
case let date as Date:
|
|
1504
|
+
cleaned[key] = date
|
|
1505
|
+
case let string as String:
|
|
1506
|
+
cleaned[key] = string
|
|
1507
|
+
default:
|
|
1508
|
+
continue
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return cleaned.isEmpty ? nil : cleaned
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
private func buildRouteLocations(from value: Any?, defaultDate: Date) -> [CLLocation] {
|
|
1515
|
+
guard let routeItems = value as? [Any] else { return [] }
|
|
1516
|
+
var locations: [CLLocation] = []
|
|
1517
|
+
|
|
1518
|
+
for case let point as JSObject in routeItems {
|
|
1519
|
+
guard let lat = point["lat"] as? CLLocationDegrees,
|
|
1520
|
+
let lng = point["lng"] as? CLLocationDegrees else { continue }
|
|
1521
|
+
let alt = (point["alt"] as? NSNumber)?.doubleValue ?? 0
|
|
1522
|
+
let timestamp = parseDate(point["timestamp"]) ?? defaultDate
|
|
1523
|
+
let location = CLLocation(
|
|
1524
|
+
coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng),
|
|
1525
|
+
altitude: alt,
|
|
1526
|
+
horizontalAccuracy: kCLLocationAccuracyBest,
|
|
1527
|
+
verticalAccuracy: kCLLocationAccuracyBest,
|
|
1528
|
+
timestamp: timestamp
|
|
1529
|
+
)
|
|
1530
|
+
locations.append(location)
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
return locations.sorted { $0.timestamp < $1.timestamp }
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
private func buildHeartRateSamples(from value: Any?, defaultStart: Date, defaultEnd: Date) -> [HKQuantitySample] {
|
|
1537
|
+
guard let arr = value as? [Any],
|
|
1538
|
+
let type = HKObjectType.quantityType(forIdentifier: .heartRate) else { return [] }
|
|
1539
|
+
|
|
1540
|
+
let unit = HKUnit.count().unitDivided(by: HKUnit.minute())
|
|
1541
|
+
var samples: [HKQuantitySample] = []
|
|
1542
|
+
for case let point as JSObject in arr {
|
|
1543
|
+
guard let bpm = point["bpm"] as? NSNumber else { continue }
|
|
1544
|
+
let timestamp = parseDate(point["timestamp"]) ?? defaultStart
|
|
1545
|
+
let quantity = HKQuantity(unit: unit, doubleValue: bpm.doubleValue)
|
|
1546
|
+
let sample = HKQuantitySample(type: type, quantity: quantity, start: timestamp, end: timestamp)
|
|
1547
|
+
samples.append(sample)
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Ensure samples fall within the workout window to avoid HK errors.
|
|
1551
|
+
return samples
|
|
1552
|
+
.filter { $0.startDate >= defaultStart && $0.endDate <= defaultEnd }
|
|
1553
|
+
.sorted { $0.startDate < $1.startDate }
|
|
1554
|
+
}
|
|
1401
1555
|
|
|
1402
1556
|
|
|
1557
|
+
@objc func saveWorkout(_ call: CAPPluginCall) {
|
|
1558
|
+
guard HKHealthStore.isHealthDataAvailable() else {
|
|
1559
|
+
call.reject("Health data is unavailable on this device.")
|
|
1560
|
+
return
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
guard let activityTypeString = call.getString("activityType"),
|
|
1564
|
+
let startDateString = call.getString("startDate"),
|
|
1565
|
+
let endDateString = call.getString("endDate"),
|
|
1566
|
+
let startDate = parseDate(startDateString),
|
|
1567
|
+
let endDate = parseDate(endDateString) else {
|
|
1568
|
+
call.reject("Missing or invalid parameters")
|
|
1569
|
+
return
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
guard let activityType = workoutActivityType(from: activityTypeString) else {
|
|
1573
|
+
call.reject("Unsupported activityType: \(activityTypeString)")
|
|
1574
|
+
return
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
guard startDate < endDate else {
|
|
1578
|
+
call.reject("startDate must be before endDate")
|
|
1579
|
+
return
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
let energyQuantity: HKQuantity? = {
|
|
1583
|
+
if let calories = call.getDouble("calories") {
|
|
1584
|
+
return HKQuantity(unit: HKUnit.kilocalorie(), doubleValue: calories)
|
|
1585
|
+
}
|
|
1586
|
+
return nil
|
|
1587
|
+
}()
|
|
1588
|
+
let distanceQuantity: HKQuantity? = {
|
|
1589
|
+
if let distance = call.getDouble("distance") {
|
|
1590
|
+
return HKQuantity(unit: HKUnit.meter(), doubleValue: distance)
|
|
1591
|
+
}
|
|
1592
|
+
return nil
|
|
1593
|
+
}()
|
|
1594
|
+
|
|
1595
|
+
let workout = HKWorkout(
|
|
1596
|
+
activityType: activityType,
|
|
1597
|
+
start: startDate,
|
|
1598
|
+
end: endDate,
|
|
1599
|
+
workoutEvents: nil,
|
|
1600
|
+
totalEnergyBurned: energyQuantity,
|
|
1601
|
+
totalDistance: distanceQuantity,
|
|
1602
|
+
metadata: sanitizeMetadata(call.getObject("metadata"))
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1605
|
+
let routeLocations = buildRouteLocations(from: call.getArray("route"), defaultDate: startDate)
|
|
1606
|
+
let heartRateSamples = buildHeartRateSamples(from: call.getArray("heartRateSamples"), defaultStart: startDate, defaultEnd: endDate)
|
|
1607
|
+
|
|
1608
|
+
healthStore.save(workout) { success, error in
|
|
1609
|
+
DispatchQueue.main.async {
|
|
1610
|
+
if let error = error {
|
|
1611
|
+
call.reject("Failed to save workout: \(error.localizedDescription)")
|
|
1612
|
+
return
|
|
1613
|
+
}
|
|
1614
|
+
guard success else {
|
|
1615
|
+
call.reject("Failed to save workout")
|
|
1616
|
+
return
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if routeLocations.isEmpty && heartRateSamples.isEmpty {
|
|
1620
|
+
call.resolve(["success": true, "id": workout.uuid.uuidString])
|
|
1621
|
+
return
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
let group = DispatchGroup()
|
|
1625
|
+
var saveError: String?
|
|
1626
|
+
|
|
1627
|
+
if !routeLocations.isEmpty {
|
|
1628
|
+
group.enter()
|
|
1629
|
+
let routeBuilder = HKWorkoutRouteBuilder(healthStore: self.healthStore, device: nil)
|
|
1630
|
+
routeBuilder.insertRouteData(routeLocations) { inserted, insertError in
|
|
1631
|
+
if let insertError = insertError {
|
|
1632
|
+
saveError = "Failed to insert route: \(insertError.localizedDescription)"
|
|
1633
|
+
group.leave()
|
|
1634
|
+
return
|
|
1635
|
+
}
|
|
1636
|
+
routeBuilder.finishRoute(with: workout, metadata: nil) { _, finishError in
|
|
1637
|
+
if let finishError = finishError {
|
|
1638
|
+
saveError = "Failed to finish route: \(finishError.localizedDescription)"
|
|
1639
|
+
} else if !inserted {
|
|
1640
|
+
saveError = "Failed to insert route"
|
|
1641
|
+
}
|
|
1642
|
+
group.leave()
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
if !heartRateSamples.isEmpty {
|
|
1648
|
+
group.enter()
|
|
1649
|
+
self.healthStore.add(heartRateSamples, to: workout) { hrSuccess, hrError in
|
|
1650
|
+
if let hrError = hrError {
|
|
1651
|
+
saveError = "Failed to save heart rate samples: \(hrError.localizedDescription)"
|
|
1652
|
+
} else if !hrSuccess {
|
|
1653
|
+
saveError = "Failed to save heart rate samples"
|
|
1654
|
+
}
|
|
1655
|
+
group.leave()
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
group.notify(queue: .main) {
|
|
1660
|
+
if let saveError = saveError {
|
|
1661
|
+
call.reject(saveError)
|
|
1662
|
+
} else {
|
|
1663
|
+
call.resolve(["success": true, "id": workout.uuid.uuidString])
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1403
1670
|
@objc func queryWorkouts(_ call: CAPPluginCall) {
|
|
1404
1671
|
guard let startDateString = call.getString("startDate"),
|
|
1405
1672
|
let endDateString = call.getString("endDate"),
|
package/package.json
CHANGED