@capgo/capacitor-health 8.2.6 → 8.2.7
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 +68 -29
- package/android/src/main/java/app/capgo/plugin/health/HealthManager.kt +43 -13
- package/android/src/main/java/app/capgo/plugin/health/HealthPlugin.kt +2 -2
- package/dist/docs.json +14 -0
- package/dist/esm/definitions.d.ts +11 -0
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPlugin/Health.swift +26 -4
- package/ios/Sources/HealthPlugin/HealthPlugin.swift +6 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
# @capgo/capacitor-health
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
<a href="https://capgo.app/">
|
|
4
|
+
<img
|
|
5
|
+
src="https://raw.githubusercontent.com/Cap-go/capgo/main/assets/capgo_banner.png"
|
|
6
|
+
alt="Capgo - Instant updates for capacitor"
|
|
7
|
+
/>
|
|
8
|
+
</a>
|
|
3
9
|
|
|
4
10
|
<div align="center">
|
|
5
|
-
<h2
|
|
6
|
-
|
|
11
|
+
<h2>
|
|
12
|
+
<a href="https://capgo.app/?ref=plugin_health"> ➡️ Get Instant updates for your App with Capgo</a>
|
|
13
|
+
</h2>
|
|
14
|
+
<h2>
|
|
15
|
+
<a href="https://capgo.app/consulting/?ref=plugin_health"> Missing a feature? We’ll build the plugin for you 💪</a>
|
|
16
|
+
</h2>
|
|
7
17
|
</div>
|
|
8
18
|
|
|
9
19
|
Capacitor plugin to read and write health metrics via Apple HealthKit (iOS) and Health Connect (Android). The TypeScript API keeps the same data types and units across platforms so you can build once and deploy everywhere.
|
|
@@ -51,7 +61,7 @@ This plugin now uses [Health Connect](https://developer.android.com/health-and-f
|
|
|
51
61
|
|
|
52
62
|
1. **Min SDK 26+.** Health Connect is only available on Android 8.0 (API 26) and above. The plugin's Gradle setup already targets this level.
|
|
53
63
|
2. **Declare Health permissions.** The plugin manifest ships with the required `<uses-permission>` declarations (`READ_/WRITE_STEPS`, `READ_/WRITE_DISTANCE`, `READ_/WRITE_ACTIVE_CALORIES_BURNED`, `READ_/WRITE_HEART_RATE`, `READ_/WRITE_WEIGHT`). Your app does not need to duplicate them, but you must surface a user-facing rationale because the permissions are considered health sensitive.
|
|
54
|
-
3. **Ensure Health Connect is installed.** Devices on Android 14+ include it by default. For earlier versions the user must install
|
|
64
|
+
3. **Ensure Health Connect is installed.** Devices on Android 14+ include it by default. For earlier versions the user must install _Health Connect by Android_ from the Play Store. The `Health.isAvailable()` helper exposes the current status so you can prompt accordingly.
|
|
55
65
|
4. **Request runtime access.** The plugin opens the Health Connect permission UI when you call `requestAuthorization`. You should still handle denial flows (e.g., show a message if `checkAuthorization` reports missing scopes).
|
|
56
66
|
5. **Provide a Privacy Policy.** Health Connect requires apps to display a privacy policy explaining how health data is used. See the [Privacy Policy Setup](#privacy-policy-setup) section below.
|
|
57
67
|
|
|
@@ -68,17 +78,17 @@ Place an HTML file at `android/app/src/main/assets/public/privacypolicy.html`:
|
|
|
68
78
|
```html
|
|
69
79
|
<!DOCTYPE html>
|
|
70
80
|
<html>
|
|
71
|
-
<head>
|
|
72
|
-
<meta charset="utf-8"
|
|
73
|
-
<meta name="viewport" content="width=device-width, initial-scale=1"
|
|
81
|
+
<head>
|
|
82
|
+
<meta charset="utf-8" />
|
|
83
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
74
84
|
<title>Privacy Policy</title>
|
|
75
|
-
</head>
|
|
76
|
-
<body>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
77
87
|
<h1>Privacy Policy</h1>
|
|
78
88
|
<p>Your privacy policy content here...</p>
|
|
79
89
|
<h2>Health Data</h2>
|
|
80
90
|
<p>Explain how you collect, use, and protect health data...</p>
|
|
81
|
-
</body>
|
|
91
|
+
</body>
|
|
82
92
|
</html>
|
|
83
93
|
```
|
|
84
94
|
|
|
@@ -141,22 +151,49 @@ await Health.saveSample({
|
|
|
141
151
|
|
|
142
152
|
### Supported data types
|
|
143
153
|
|
|
144
|
-
| Identifier
|
|
145
|
-
|
|
|
146
|
-
| `steps`
|
|
147
|
-
| `distance`
|
|
148
|
-
| `calories`
|
|
149
|
-
| `heartRate
|
|
150
|
-
| `weight`
|
|
151
|
-
| `workouts`
|
|
154
|
+
| Identifier | Default unit | Notes |
|
|
155
|
+
| ----------- | ------------- | -------------------------------------------------------- |
|
|
156
|
+
| `steps` | `count` | Step count deltas |
|
|
157
|
+
| `distance` | `meter` | Walking / running distance |
|
|
158
|
+
| `calories` | `kilocalorie` | Active energy burned |
|
|
159
|
+
| `heartRate` | `bpm` | Beats per minute |
|
|
160
|
+
| `weight` | `kilogram` | Body mass |
|
|
161
|
+
| `workouts` | N/A | Workout sessions (read-only, use with `queryWorkouts()`) |
|
|
152
162
|
|
|
153
163
|
All write operations expect the default unit shown above. On Android the `metadata` option is currently ignored by Health Connect.
|
|
154
164
|
|
|
155
165
|
**Note about workouts:** To query workout data using `queryWorkouts()`, you need to explicitly request `workouts` permission:
|
|
166
|
+
|
|
156
167
|
```ts
|
|
157
168
|
await Health.requestAuthorization({
|
|
158
|
-
read: ['steps', 'workouts'],
|
|
169
|
+
read: ['steps', 'workouts'], // Include 'workouts' to access workout sessions
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Pagination example:** Use the `anchor` parameter to paginate through workout results:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
// First page: get the first 10 workouts
|
|
177
|
+
let result = await Health.queryWorkouts({
|
|
178
|
+
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // Last 30 days
|
|
179
|
+
endDate: new Date().toISOString(),
|
|
180
|
+
limit: 10,
|
|
159
181
|
});
|
|
182
|
+
|
|
183
|
+
console.log(`Found ${result.workouts.length} workouts`);
|
|
184
|
+
|
|
185
|
+
// If there are more results, the anchor will be set
|
|
186
|
+
while (result.anchor) {
|
|
187
|
+
// Next page: use the anchor to continue from where we left off
|
|
188
|
+
result = await Health.queryWorkouts({
|
|
189
|
+
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
190
|
+
endDate: new Date().toISOString(),
|
|
191
|
+
limit: 10,
|
|
192
|
+
anchor: result.anchor, // Continue from the last result
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
console.log(`Found ${result.workouts.length} more workouts`);
|
|
196
|
+
}
|
|
160
197
|
```
|
|
161
198
|
|
|
162
199
|
## API
|
|
@@ -399,9 +436,10 @@ Supported on iOS (HealthKit) and Android (Health Connect).
|
|
|
399
436
|
|
|
400
437
|
#### QueryWorkoutsResult
|
|
401
438
|
|
|
402
|
-
| Prop | Type |
|
|
403
|
-
| -------------- | ---------------------- |
|
|
404
|
-
| **`workouts`** | <code>Workout[]</code> |
|
|
439
|
+
| Prop | Type | Description |
|
|
440
|
+
| -------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
441
|
+
| **`workouts`** | <code>Workout[]</code> | |
|
|
442
|
+
| **`anchor`** | <code>string</code> | Anchor for the next page of results. Pass this value as the anchor parameter in the next query to continue pagination. If undefined or null, there are no more results. |
|
|
405
443
|
|
|
406
444
|
|
|
407
445
|
#### Workout
|
|
@@ -421,13 +459,14 @@ Supported on iOS (HealthKit) and Android (Health Connect).
|
|
|
421
459
|
|
|
422
460
|
#### QueryWorkoutsOptions
|
|
423
461
|
|
|
424
|
-
| Prop | Type | Description
|
|
425
|
-
| ----------------- | --------------------------------------------------- |
|
|
426
|
-
| **`workoutType`** | <code><a href="#workouttype">WorkoutType</a></code> | Optional workout type filter. If omitted, all workout types are returned.
|
|
427
|
-
| **`startDate`** | <code>string</code> | Inclusive ISO 8601 start date (defaults to now - 1 day).
|
|
428
|
-
| **`endDate`** | <code>string</code> | Exclusive ISO 8601 end date (defaults to now).
|
|
429
|
-
| **`limit`** | <code>number</code> | Maximum number of workouts to return (defaults to 100).
|
|
430
|
-
| **`ascending`** | <code>boolean</code> | Return results sorted ascending by start date (defaults to false).
|
|
462
|
+
| Prop | Type | Description |
|
|
463
|
+
| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
464
|
+
| **`workoutType`** | <code><a href="#workouttype">WorkoutType</a></code> | Optional workout type filter. If omitted, all workout types are returned. |
|
|
465
|
+
| **`startDate`** | <code>string</code> | Inclusive ISO 8601 start date (defaults to now - 1 day). |
|
|
466
|
+
| **`endDate`** | <code>string</code> | Exclusive ISO 8601 end date (defaults to now). |
|
|
467
|
+
| **`limit`** | <code>number</code> | Maximum number of workouts to return (defaults to 100). |
|
|
468
|
+
| **`ascending`** | <code>boolean</code> | Return results sorted ascending by start date (defaults to false). |
|
|
469
|
+
| **`anchor`** | <code>string</code> | Anchor for pagination. Use the anchor returned from a previous query to continue from that point. On iOS, this uses HKQueryAnchor. On Android, this uses Health Connect's pageToken. Omit this parameter to start from the beginning. |
|
|
431
470
|
|
|
432
471
|
|
|
433
472
|
### Type Aliases
|
|
@@ -25,6 +25,7 @@ import java.time.ZoneOffset
|
|
|
25
25
|
import java.time.format.DateTimeFormatter
|
|
26
26
|
import kotlin.math.min
|
|
27
27
|
import kotlin.collections.buildSet
|
|
28
|
+
import kotlinx.coroutines.CancellationException
|
|
28
29
|
|
|
29
30
|
class HealthManager {
|
|
30
31
|
|
|
@@ -298,11 +299,12 @@ class HealthManager {
|
|
|
298
299
|
startTime: Instant,
|
|
299
300
|
endTime: Instant,
|
|
300
301
|
limit: Int,
|
|
301
|
-
ascending: Boolean
|
|
302
|
-
|
|
302
|
+
ascending: Boolean,
|
|
303
|
+
anchor: String?
|
|
304
|
+
): JSObject {
|
|
303
305
|
val workouts = mutableListOf<Pair<Instant, JSObject>>()
|
|
304
306
|
|
|
305
|
-
var pageToken: String? =
|
|
307
|
+
var pageToken: String? = anchor // Use anchor as initial pageToken (leverages Health Connect's native pagination)
|
|
306
308
|
val pageSize = if (limit > 0) min(limit, MAX_PAGE_SIZE) else DEFAULT_PAGE_SIZE
|
|
307
309
|
var fetched = 0
|
|
308
310
|
|
|
@@ -341,7 +343,15 @@ class HealthManager {
|
|
|
341
343
|
|
|
342
344
|
val array = JSArray()
|
|
343
345
|
limited.forEach { array.put(it.second) }
|
|
344
|
-
|
|
346
|
+
|
|
347
|
+
// Return result with workouts and next anchor (pageToken)
|
|
348
|
+
val result = JSObject()
|
|
349
|
+
result.put("workouts", array)
|
|
350
|
+
// Only include anchor if there might be more results
|
|
351
|
+
if (pageToken != null) {
|
|
352
|
+
result.put("anchor", pageToken)
|
|
353
|
+
}
|
|
354
|
+
return result
|
|
345
355
|
}
|
|
346
356
|
|
|
347
357
|
private suspend fun aggregateWorkoutData(
|
|
@@ -352,27 +362,42 @@ class HealthManager {
|
|
|
352
362
|
// Don't filter by dataOrigin - distance might come from different sources
|
|
353
363
|
// than the workout session itself (e.g., fitness tracker vs workout app)
|
|
354
364
|
|
|
355
|
-
// Aggregate distance
|
|
356
|
-
|
|
365
|
+
// Aggregate distance and calories in a single request for efficiency
|
|
366
|
+
var distanceAggregate: Double? = null
|
|
367
|
+
var caloriesAggregate: Double? = null
|
|
368
|
+
|
|
369
|
+
try {
|
|
357
370
|
val aggregateRequest = AggregateRequest(
|
|
358
|
-
metrics = setOf(
|
|
371
|
+
metrics = setOf(
|
|
372
|
+
DistanceRecord.DISTANCE_TOTAL,
|
|
373
|
+
ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL
|
|
374
|
+
),
|
|
359
375
|
timeRangeFilter = timeRange
|
|
360
|
-
// Removed dataOriginFilter to get
|
|
376
|
+
// Removed dataOriginFilter to get data from all sources during workout time
|
|
361
377
|
)
|
|
362
378
|
val result = client.aggregate(aggregateRequest)
|
|
363
|
-
result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
|
|
379
|
+
distanceAggregate = result[DistanceRecord.DISTANCE_TOTAL]?.inMeters
|
|
380
|
+
caloriesAggregate = result[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories
|
|
381
|
+
} catch (e: CancellationException) {
|
|
382
|
+
// Rethrow cancellation to allow coroutine cancellation to propagate
|
|
383
|
+
throw e
|
|
384
|
+
} catch (e: SecurityException) {
|
|
385
|
+
// Permission not granted for one or both metrics
|
|
386
|
+
android.util.Log.d("HealthManager", "Permission denied for workout data aggregation: ${e.message}", e)
|
|
364
387
|
} catch (e: Exception) {
|
|
365
|
-
|
|
366
|
-
|
|
388
|
+
// Other errors (e.g., no data available)
|
|
389
|
+
android.util.Log.d("HealthManager", "Workout data aggregation failed: ${e.message}", e)
|
|
367
390
|
}
|
|
368
391
|
|
|
369
392
|
return WorkoutAggregatedData(
|
|
370
|
-
totalDistance = distanceAggregate
|
|
393
|
+
totalDistance = distanceAggregate,
|
|
394
|
+
totalEnergyBurned = caloriesAggregate
|
|
371
395
|
)
|
|
372
396
|
}
|
|
373
397
|
|
|
374
398
|
private data class WorkoutAggregatedData(
|
|
375
|
-
val totalDistance: Double
|
|
399
|
+
val totalDistance: Double?,
|
|
400
|
+
val totalEnergyBurned: Double?
|
|
376
401
|
)
|
|
377
402
|
|
|
378
403
|
private fun createWorkoutPayload(session: ExerciseSessionRecord, aggregatedData: WorkoutAggregatedData): JSObject {
|
|
@@ -394,6 +419,11 @@ class HealthManager {
|
|
|
394
419
|
payload.put("totalDistance", distance)
|
|
395
420
|
}
|
|
396
421
|
|
|
422
|
+
// Total energy burned (aggregated from ActiveCaloriesBurnedRecord)
|
|
423
|
+
aggregatedData.totalEnergyBurned?.let { energy ->
|
|
424
|
+
payload.put("totalEnergyBurned", energy)
|
|
425
|
+
}
|
|
426
|
+
|
|
397
427
|
// Source information
|
|
398
428
|
val dataOrigin = session.metadata.dataOrigin
|
|
399
429
|
payload.put("sourceId", dataOrigin.packageName)
|
|
@@ -353,6 +353,7 @@ class HealthPlugin : Plugin() {
|
|
|
353
353
|
val workoutType = call.getString("workoutType")
|
|
354
354
|
val limit = (call.getInt("limit") ?: DEFAULT_LIMIT).coerceAtLeast(0)
|
|
355
355
|
val ascending = call.getBoolean("ascending") ?: false
|
|
356
|
+
val anchor = call.getString("anchor")
|
|
356
357
|
|
|
357
358
|
val startInstant = try {
|
|
358
359
|
manager.parseInstant(call.getString("startDate"), Instant.now().minus(DEFAULT_PAST_DURATION))
|
|
@@ -376,8 +377,7 @@ class HealthPlugin : Plugin() {
|
|
|
376
377
|
pluginScope.launch {
|
|
377
378
|
val client = getClientOrReject(call) ?: return@launch
|
|
378
379
|
try {
|
|
379
|
-
val
|
|
380
|
-
val result = JSObject().apply { put("workouts", workouts) }
|
|
380
|
+
val result = manager.queryWorkouts(client, workoutType, startInstant, endInstant, limit, ascending, anchor)
|
|
381
381
|
call.resolve(result)
|
|
382
382
|
} catch (e: Exception) {
|
|
383
383
|
call.reject(e.message ?: "Failed to query workouts.", null, e)
|
package/dist/docs.json
CHANGED
|
@@ -477,6 +477,13 @@
|
|
|
477
477
|
"Workout"
|
|
478
478
|
],
|
|
479
479
|
"type": "Workout[]"
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
"name": "anchor",
|
|
483
|
+
"tags": [],
|
|
484
|
+
"docs": "Anchor for the next page of results. Pass this value as the anchor parameter in the next query\nto continue pagination. If undefined or null, there are no more results.",
|
|
485
|
+
"complexTypes": [],
|
|
486
|
+
"type": "string | undefined"
|
|
480
487
|
}
|
|
481
488
|
]
|
|
482
489
|
},
|
|
@@ -599,6 +606,13 @@
|
|
|
599
606
|
"docs": "Return results sorted ascending by start date (defaults to false).",
|
|
600
607
|
"complexTypes": [],
|
|
601
608
|
"type": "boolean | undefined"
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
"name": "anchor",
|
|
612
|
+
"tags": [],
|
|
613
|
+
"docs": "Anchor for pagination. Use the anchor returned from a previous query to continue from that point.\nOn iOS, this uses HKQueryAnchor. On Android, this uses Health Connect's pageToken.\nOmit this parameter to start from the beginning.",
|
|
614
|
+
"complexTypes": [],
|
|
615
|
+
"type": "string | undefined"
|
|
602
616
|
}
|
|
603
617
|
]
|
|
604
618
|
}
|
|
@@ -54,6 +54,12 @@ export interface QueryWorkoutsOptions {
|
|
|
54
54
|
limit?: number;
|
|
55
55
|
/** Return results sorted ascending by start date (defaults to false). */
|
|
56
56
|
ascending?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Anchor for pagination. Use the anchor returned from a previous query to continue from that point.
|
|
59
|
+
* On iOS, this uses HKQueryAnchor. On Android, this uses Health Connect's pageToken.
|
|
60
|
+
* Omit this parameter to start from the beginning.
|
|
61
|
+
*/
|
|
62
|
+
anchor?: string;
|
|
57
63
|
}
|
|
58
64
|
export interface Workout {
|
|
59
65
|
/** The type of workout. */
|
|
@@ -77,6 +83,11 @@ export interface Workout {
|
|
|
77
83
|
}
|
|
78
84
|
export interface QueryWorkoutsResult {
|
|
79
85
|
workouts: Workout[];
|
|
86
|
+
/**
|
|
87
|
+
* Anchor for the next page of results. Pass this value as the anchor parameter in the next query
|
|
88
|
+
* to continue pagination. If undefined or null, there are no more results.
|
|
89
|
+
*/
|
|
90
|
+
anchor?: string;
|
|
80
91
|
}
|
|
81
92
|
export interface WriteSampleOptions {
|
|
82
93
|
dataType: HealthDataType;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight';\n\nexport type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram';\n\nexport interface AuthorizationOptions {\n /** Data types that should be readable after authorization. */\n read?: HealthDataType[];\n /** Data types that should be writable after authorization. */\n write?: HealthDataType[];\n}\n\nexport interface AuthorizationStatus {\n readAuthorized: HealthDataType[];\n readDenied: HealthDataType[];\n writeAuthorized: HealthDataType[];\n writeDenied: HealthDataType[];\n}\n\nexport interface AvailabilityResult {\n available: boolean;\n /** Platform specific details (for debugging/diagnostics). */\n platform?: 'ios' | 'android' | 'web';\n reason?: string;\n}\n\nexport interface QueryOptions {\n /** The type of data to retrieve from the health store. */\n dataType: HealthDataType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of samples to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface HealthSample {\n dataType: HealthDataType;\n value: number;\n unit: HealthUnit;\n startDate: string;\n endDate: string;\n sourceName?: string;\n sourceId?: string;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport type WorkoutType =\n | 'running'\n | 'cycling'\n | 'walking'\n | 'swimming'\n | 'yoga'\n | 'strengthTraining'\n | 'hiking'\n | 'tennis'\n | 'basketball'\n | 'soccer'\n | 'americanFootball'\n | 'baseball'\n | 'crossTraining'\n | 'elliptical'\n | 'rowing'\n | 'stairClimbing'\n | 'traditionalStrengthTraining'\n | 'waterFitness'\n | 'waterPolo'\n | 'waterSports'\n | 'wrestling'\n | 'other';\n\nexport interface QueryWorkoutsOptions {\n /** Optional workout type filter. If omitted, all workout types are returned. */\n workoutType?: WorkoutType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of workouts to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface Workout {\n /** The type of workout. */\n workoutType: WorkoutType;\n /** Duration of the workout in seconds. */\n duration: number;\n /** Total energy burned in kilocalories (if available). */\n totalEnergyBurned?: number;\n /** Total distance in meters (if available). */\n totalDistance?: number;\n /** ISO 8601 start date of the workout. */\n startDate: string;\n /** ISO 8601 end date of the workout. */\n endDate: string;\n /** Source name that recorded the workout. */\n sourceName?: string;\n /** Source bundle identifier. */\n sourceId?: string;\n /** Additional metadata (if available). */\n metadata?: Record<string, string>;\n}\n\nexport interface QueryWorkoutsResult {\n workouts: Workout[];\n}\n\nexport interface WriteSampleOptions {\n dataType: HealthDataType;\n value: number;\n /**\n * Optional unit override. If omitted, the default unit for the data type is used\n * (count for `steps`, meter for `distance`, kilocalorie for `calories`, bpm for `heartRate`, kilogram for `weight`).\n */\n unit?: HealthUnit;\n /** ISO 8601 start date for the sample. Defaults to now. */\n startDate?: string;\n /** ISO 8601 end date for the sample. Defaults to startDate. */\n endDate?: string;\n /** Metadata key-value pairs forwarded to the native APIs where supported. */\n metadata?: Record<string, string>;\n}\n\nexport interface HealthPlugin {\n /** Returns whether the current platform supports the native health SDK. */\n isAvailable(): Promise<AvailabilityResult>;\n /** Requests read/write access to the provided data types. */\n requestAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Checks authorization status for the provided data types without prompting the user. */\n checkAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Reads samples for the given data type within the specified time frame. */\n readSamples(options: QueryOptions): Promise<ReadSamplesResult>;\n /** Writes a single sample to the native health store. */\n saveSample(options: WriteSampleOptions): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ version: string }>} a Promise with version for this device\n * @throws An error if something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n\n /**\n * Opens the Health Connect settings screen (Android only).\n * On iOS, this method does nothing.\n *\n * Use this to direct users to manage their Health Connect permissions\n * or to install Health Connect if not available.\n *\n * @throws An error if Health Connect settings cannot be opened\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Shows the app's privacy policy for Health Connect (Android only).\n * On iOS, this method does nothing.\n *\n * This displays the same privacy policy screen that Health Connect shows\n * when the user taps \"Privacy policy\" in the permissions dialog.\n *\n * The privacy policy URL can be configured by adding a string resource\n * named \"health_connect_privacy_policy_url\" in your app's strings.xml,\n * or by placing an HTML file at www/privacypolicy.html in your assets.\n *\n * @throws An error if the privacy policy cannot be displayed\n */\n showPrivacyPolicy(): Promise<void>;\n\n /**\n * Queries workout sessions from the native health store.\n * Supported on iOS (HealthKit) and Android (Health Connect).\n *\n * @param options Query options including optional workout type filter, date range, limit, and sort order\n * @returns A promise that resolves with the workout sessions\n * @throws An error if something went wrong\n */\n queryWorkouts(options: QueryWorkoutsOptions): Promise<QueryWorkoutsResult>;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export type HealthDataType = 'steps' | 'distance' | 'calories' | 'heartRate' | 'weight';\n\nexport type HealthUnit = 'count' | 'meter' | 'kilocalorie' | 'bpm' | 'kilogram';\n\nexport interface AuthorizationOptions {\n /** Data types that should be readable after authorization. */\n read?: HealthDataType[];\n /** Data types that should be writable after authorization. */\n write?: HealthDataType[];\n}\n\nexport interface AuthorizationStatus {\n readAuthorized: HealthDataType[];\n readDenied: HealthDataType[];\n writeAuthorized: HealthDataType[];\n writeDenied: HealthDataType[];\n}\n\nexport interface AvailabilityResult {\n available: boolean;\n /** Platform specific details (for debugging/diagnostics). */\n platform?: 'ios' | 'android' | 'web';\n reason?: string;\n}\n\nexport interface QueryOptions {\n /** The type of data to retrieve from the health store. */\n dataType: HealthDataType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of samples to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n}\n\nexport interface HealthSample {\n dataType: HealthDataType;\n value: number;\n unit: HealthUnit;\n startDate: string;\n endDate: string;\n sourceName?: string;\n sourceId?: string;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport type WorkoutType =\n | 'running'\n | 'cycling'\n | 'walking'\n | 'swimming'\n | 'yoga'\n | 'strengthTraining'\n | 'hiking'\n | 'tennis'\n | 'basketball'\n | 'soccer'\n | 'americanFootball'\n | 'baseball'\n | 'crossTraining'\n | 'elliptical'\n | 'rowing'\n | 'stairClimbing'\n | 'traditionalStrengthTraining'\n | 'waterFitness'\n | 'waterPolo'\n | 'waterSports'\n | 'wrestling'\n | 'other';\n\nexport interface QueryWorkoutsOptions {\n /** Optional workout type filter. If omitted, all workout types are returned. */\n workoutType?: WorkoutType;\n /** Inclusive ISO 8601 start date (defaults to now - 1 day). */\n startDate?: string;\n /** Exclusive ISO 8601 end date (defaults to now). */\n endDate?: string;\n /** Maximum number of workouts to return (defaults to 100). */\n limit?: number;\n /** Return results sorted ascending by start date (defaults to false). */\n ascending?: boolean;\n /**\n * Anchor for pagination. Use the anchor returned from a previous query to continue from that point.\n * On iOS, this uses HKQueryAnchor. On Android, this uses Health Connect's pageToken.\n * Omit this parameter to start from the beginning.\n */\n anchor?: string;\n}\n\nexport interface Workout {\n /** The type of workout. */\n workoutType: WorkoutType;\n /** Duration of the workout in seconds. */\n duration: number;\n /** Total energy burned in kilocalories (if available). */\n totalEnergyBurned?: number;\n /** Total distance in meters (if available). */\n totalDistance?: number;\n /** ISO 8601 start date of the workout. */\n startDate: string;\n /** ISO 8601 end date of the workout. */\n endDate: string;\n /** Source name that recorded the workout. */\n sourceName?: string;\n /** Source bundle identifier. */\n sourceId?: string;\n /** Additional metadata (if available). */\n metadata?: Record<string, string>;\n}\n\nexport interface QueryWorkoutsResult {\n workouts: Workout[];\n /**\n * Anchor for the next page of results. Pass this value as the anchor parameter in the next query\n * to continue pagination. If undefined or null, there are no more results.\n */\n anchor?: string;\n}\n\nexport interface WriteSampleOptions {\n dataType: HealthDataType;\n value: number;\n /**\n * Optional unit override. If omitted, the default unit for the data type is used\n * (count for `steps`, meter for `distance`, kilocalorie for `calories`, bpm for `heartRate`, kilogram for `weight`).\n */\n unit?: HealthUnit;\n /** ISO 8601 start date for the sample. Defaults to now. */\n startDate?: string;\n /** ISO 8601 end date for the sample. Defaults to startDate. */\n endDate?: string;\n /** Metadata key-value pairs forwarded to the native APIs where supported. */\n metadata?: Record<string, string>;\n}\n\nexport interface HealthPlugin {\n /** Returns whether the current platform supports the native health SDK. */\n isAvailable(): Promise<AvailabilityResult>;\n /** Requests read/write access to the provided data types. */\n requestAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Checks authorization status for the provided data types without prompting the user. */\n checkAuthorization(options: AuthorizationOptions): Promise<AuthorizationStatus>;\n /** Reads samples for the given data type within the specified time frame. */\n readSamples(options: QueryOptions): Promise<ReadSamplesResult>;\n /** Writes a single sample to the native health store. */\n saveSample(options: WriteSampleOptions): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ version: string }>} a Promise with version for this device\n * @throws An error if something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n\n /**\n * Opens the Health Connect settings screen (Android only).\n * On iOS, this method does nothing.\n *\n * Use this to direct users to manage their Health Connect permissions\n * or to install Health Connect if not available.\n *\n * @throws An error if Health Connect settings cannot be opened\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Shows the app's privacy policy for Health Connect (Android only).\n * On iOS, this method does nothing.\n *\n * This displays the same privacy policy screen that Health Connect shows\n * when the user taps \"Privacy policy\" in the permissions dialog.\n *\n * The privacy policy URL can be configured by adding a string resource\n * named \"health_connect_privacy_policy_url\" in your app's strings.xml,\n * or by placing an HTML file at www/privacypolicy.html in your assets.\n *\n * @throws An error if the privacy policy cannot be displayed\n */\n showPrivacyPolicy(): Promise<void>;\n\n /**\n * Queries workout sessions from the native health store.\n * Supported on iOS (HealthKit) and Android (Health Connect).\n *\n * @param options Query options including optional workout type filter, date range, limit, and sort order\n * @returns A promise that resolves with the workout sessions\n * @throws An error if something went wrong\n */\n queryWorkouts(options: QueryWorkoutsOptions): Promise<QueryWorkoutsResult>;\n}\n"]}
|
|
@@ -236,6 +236,9 @@ struct AuthorizationStatusPayload {
|
|
|
236
236
|
final class Health {
|
|
237
237
|
private let healthStore = HKHealthStore()
|
|
238
238
|
private let isoFormatter: ISO8601DateFormatter
|
|
239
|
+
|
|
240
|
+
/// Small time offset (in seconds) added to the last workout's end date to avoid duplicate results in pagination
|
|
241
|
+
private let paginationOffsetSeconds: TimeInterval = 0.001
|
|
239
242
|
|
|
240
243
|
init() {
|
|
241
244
|
let formatter = ISO8601DateFormatter()
|
|
@@ -559,7 +562,7 @@ final class Health {
|
|
|
559
562
|
return set
|
|
560
563
|
}
|
|
561
564
|
|
|
562
|
-
func queryWorkouts(workoutTypeString: String?, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, completion: @escaping (Result<[
|
|
565
|
+
func queryWorkouts(workoutTypeString: String?, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, anchorString: String?, completion: @escaping (Result<[String: Any], Error>) -> Void) {
|
|
563
566
|
let startDate = (try? parseDate(startDateString, defaultValue: Date().addingTimeInterval(-86400))) ?? Date().addingTimeInterval(-86400)
|
|
564
567
|
let endDate = (try? parseDate(endDateString, defaultValue: Date())) ?? Date()
|
|
565
568
|
|
|
@@ -568,7 +571,16 @@ final class Health {
|
|
|
568
571
|
return
|
|
569
572
|
}
|
|
570
573
|
|
|
571
|
-
|
|
574
|
+
// If anchor is provided, use it as the continuation point for pagination.
|
|
575
|
+
// The anchor is the ISO 8601 date string of the last workout's end date from the previous query.
|
|
576
|
+
let effectiveStartDate: Date
|
|
577
|
+
if let anchorString = anchorString, let anchorDate = try? parseDate(anchorString, defaultValue: startDate) {
|
|
578
|
+
effectiveStartDate = anchorDate
|
|
579
|
+
} else {
|
|
580
|
+
effectiveStartDate = startDate
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
var predicate = HKQuery.predicateForSamples(withStart: effectiveStartDate, end: endDate, options: [])
|
|
572
584
|
|
|
573
585
|
// Filter by workout type if specified
|
|
574
586
|
if let workoutTypeString = workoutTypeString, let workoutType = WorkoutType(rawValue: workoutTypeString) {
|
|
@@ -594,7 +606,7 @@ final class Health {
|
|
|
594
606
|
}
|
|
595
607
|
|
|
596
608
|
guard let workouts = samples as? [HKWorkout] else {
|
|
597
|
-
completion(.success([]))
|
|
609
|
+
completion(.success(["workouts": []]))
|
|
598
610
|
return
|
|
599
611
|
}
|
|
600
612
|
|
|
@@ -641,7 +653,17 @@ final class Health {
|
|
|
641
653
|
return payload
|
|
642
654
|
}
|
|
643
655
|
|
|
644
|
-
|
|
656
|
+
// Generate next anchor if we have results and reached the limit
|
|
657
|
+
var response: [String: Any] = ["workouts": results]
|
|
658
|
+
if !workouts.isEmpty && workouts.count >= queryLimit {
|
|
659
|
+
// Use the last workout's end date as the anchor for the next page
|
|
660
|
+
let lastWorkout = workouts.last!
|
|
661
|
+
// Add a small offset to avoid getting the same workout again
|
|
662
|
+
let nextAnchorDate = lastWorkout.endDate.addingTimeInterval(self.paginationOffsetSeconds)
|
|
663
|
+
response["anchor"] = self.isoFormatter.string(from: nextAnchorDate)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
completion(.success(response))
|
|
645
667
|
}
|
|
646
668
|
|
|
647
669
|
healthStore.execute(query)
|
|
@@ -3,7 +3,7 @@ import Capacitor
|
|
|
3
3
|
|
|
4
4
|
@objc(HealthPlugin)
|
|
5
5
|
public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
6
|
-
private let pluginVersion: String = "8.2.
|
|
6
|
+
private let pluginVersion: String = "8.2.7"
|
|
7
7
|
public let identifier = "HealthPlugin"
|
|
8
8
|
public let jsName = "Health"
|
|
9
9
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
@@ -153,18 +153,20 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
153
153
|
let endDate = call.getString("endDate")
|
|
154
154
|
let limit = call.getInt("limit")
|
|
155
155
|
let ascending = call.getBool("ascending") ?? false
|
|
156
|
+
let anchor = call.getString("anchor")
|
|
156
157
|
|
|
157
158
|
implementation.queryWorkouts(
|
|
158
159
|
workoutTypeString: workoutType,
|
|
159
160
|
startDateString: startDate,
|
|
160
161
|
endDateString: endDate,
|
|
161
162
|
limit: limit,
|
|
162
|
-
ascending: ascending
|
|
163
|
+
ascending: ascending,
|
|
164
|
+
anchorString: anchor
|
|
163
165
|
) { result in
|
|
164
166
|
DispatchQueue.main.async {
|
|
165
167
|
switch result {
|
|
166
|
-
case let .success(
|
|
167
|
-
call.resolve(
|
|
168
|
+
case let .success(response):
|
|
169
|
+
call.resolve(response)
|
|
168
170
|
case let .failure(error):
|
|
169
171
|
call.reject(error.localizedDescription, nil, error)
|
|
170
172
|
}
|
package/package.json
CHANGED