@flomentumsolutions/capacitor-health-extended 0.6.4 → 0.7.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.
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
|
|
|
@@ -90,8 +92,16 @@ you can keep using the CocoaPods spec `FlomentumSolutionsCapacitorHealthExtended
|
|
|
90
92
|
<uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED" />
|
|
91
93
|
<uses-permission android:name="android.permission.health.READ_BASAL_METABOLIC_RATE" />
|
|
92
94
|
<uses-permission android:name="android.permission.health.READ_SLEEP" />
|
|
95
|
+
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
|
|
96
|
+
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED" />
|
|
97
|
+
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED" />
|
|
98
|
+
<uses-permission android:name="android.permission.health.WRITE_DISTANCE" />
|
|
99
|
+
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
|
|
100
|
+
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
|
|
93
101
|
```
|
|
94
102
|
|
|
103
|
+
Include the WRITE_* entries when you call `saveWorkout` to insert exercise sessions, energy, distance, routes, or heart rate samples.
|
|
104
|
+
|
|
95
105
|
* Android Manifest in application tag
|
|
96
106
|
```xml
|
|
97
107
|
<!-- Handle Health Connect rationale (Android 13-) -->
|
|
@@ -223,6 +233,7 @@ This setup ensures your WebView will load HTTPS content securely and complies wi
|
|
|
223
233
|
* [`queryHeight()`](#queryheight)
|
|
224
234
|
* [`queryHeartRate()`](#queryheartrate)
|
|
225
235
|
* [`querySteps()`](#querysteps)
|
|
236
|
+
* [`saveWorkout(...)`](#saveworkout)
|
|
226
237
|
* [Interfaces](#interfaces)
|
|
227
238
|
* [Type Aliases](#type-aliases)
|
|
228
239
|
|
|
@@ -447,6 +458,26 @@ Query latest steps sample
|
|
|
447
458
|
--------------------
|
|
448
459
|
|
|
449
460
|
|
|
461
|
+
### saveWorkout(...)
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
saveWorkout(request: SaveWorkoutRequest) => Promise<SaveWorkoutResponse>
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Create a workout session with optional totals and route/heart-rate samples.
|
|
468
|
+
- iOS stores an `HKWorkout` (activityType mapped from `activityType`) with total energy/distance and optional metadata/route/heart-rate samples.
|
|
469
|
+
- Android stores an `ExerciseSessionRecord` plus `ActiveCaloriesBurnedRecord`, `DistanceRecord`, and `HeartRateRecord` when provided. Routes are attached via `ExerciseRoute`.
|
|
470
|
+
- Requires matching WRITE_* permissions for the values you include (e.g., WRITE_WORKOUTS + WRITE_ACTIVE_CALORIES + WRITE_DISTANCE + WRITE_HEART_RATE + WRITE_ROUTE).
|
|
471
|
+
|
|
472
|
+
| Param | Type |
|
|
473
|
+
| ------------- | ----------------------------------------------------------------- |
|
|
474
|
+
| **`request`** | <code><a href="#saveworkoutrequest">SaveWorkoutRequest</a></code> |
|
|
475
|
+
|
|
476
|
+
**Returns:** <code>Promise<<a href="#saveworkoutresponse">SaveWorkoutResponse</a>></code>
|
|
477
|
+
|
|
478
|
+
--------------------
|
|
479
|
+
|
|
480
|
+
|
|
450
481
|
### Interfaces
|
|
451
482
|
|
|
452
483
|
|
|
@@ -573,6 +604,28 @@ Query latest steps sample
|
|
|
573
604
|
| **`metadata`** | <code><a href="#record">Record</a><string, unknown></code> |
|
|
574
605
|
|
|
575
606
|
|
|
607
|
+
#### SaveWorkoutResponse
|
|
608
|
+
|
|
609
|
+
| Prop | Type |
|
|
610
|
+
| ------------- | -------------------- |
|
|
611
|
+
| **`success`** | <code>boolean</code> |
|
|
612
|
+
| **`id`** | <code>string</code> |
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
#### SaveWorkoutRequest
|
|
616
|
+
|
|
617
|
+
| Prop | Type |
|
|
618
|
+
| ---------------------- | ------------------------------------------------------------------- |
|
|
619
|
+
| **`activityType`** | <code><a href="#workoutactivitytype">WorkoutActivityType</a></code> |
|
|
620
|
+
| **`startDate`** | <code>string</code> |
|
|
621
|
+
| **`endDate`** | <code>string</code> |
|
|
622
|
+
| **`calories`** | <code>number</code> |
|
|
623
|
+
| **`distance`** | <code>number</code> |
|
|
624
|
+
| **`metadata`** | <code><a href="#record">Record</a><string, any></code> |
|
|
625
|
+
| **`route`** | <code>RouteSample[]</code> |
|
|
626
|
+
| **`heartRateSamples`** | <code>HeartRateSample[]</code> |
|
|
627
|
+
|
|
628
|
+
|
|
576
629
|
### Type Aliases
|
|
577
630
|
|
|
578
631
|
|
|
@@ -585,7 +638,7 @@ Construct a type with a set of properties K of type T
|
|
|
585
638
|
|
|
586
639
|
#### HealthPermission
|
|
587
640
|
|
|
588
|
-
<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>
|
|
641
|
+
<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>
|
|
589
642
|
|
|
590
643
|
|
|
591
644
|
#### HealthBiologicalSex
|
|
@@ -612,4 +665,9 @@ Construct a type with a set of properties K of type T
|
|
|
612
665
|
|
|
613
666
|
<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
667
|
|
|
668
|
+
|
|
669
|
+
#### WorkoutActivityType
|
|
670
|
+
|
|
671
|
+
<code>'rock-climbing' | 'climbing' | 'hiking' | 'running' | 'walking' | 'cycling' | 'biking' | 'strength-training' | 'yoga' | 'other'</code>
|
|
672
|
+
|
|
615
673
|
</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