@capgo/capacitor-health 8.2.5 → 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 CHANGED
@@ -1,9 +1,19 @@
1
1
  # @capgo/capacitor-health
2
- <a href="https://capgo.app/"><img src='https://raw.githubusercontent.com/Cap-go/capgo/main/assets/capgo_banner.png' alt='Capgo - Instant updates for capacitor'/></a>
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><a href="https://capgo.app/?ref=plugin_health"> ➡️ Get Instant updates for your App with Capgo</a></h2>
6
- <h2><a href="https://capgo.app/consulting/?ref=plugin_health"> Missing a feature? We’ll build the plugin for you 💪</a></h2>
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 *Health Connect by Android* from the Play Store. The `Health.isAvailable()` helper exposes the current status so you can prompt accordingly.
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,16 +151,51 @@ await Health.saveSample({
141
151
 
142
152
  ### Supported data types
143
153
 
144
- | Identifier | Default unit | Notes |
145
- | ---------- | ------------- | ----- |
146
- | `steps` | `count` | Step count deltas |
147
- | `distance` | `meter` | Walking / running distance |
148
- | `calories` | `kilocalorie` | Active energy burned |
149
- | `heartRate`| `bpm` | Beats per minute |
150
- | `weight` | `kilogram` | Body mass |
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()`) |
151
162
 
152
163
  All write operations expect the default unit shown above. On Android the `metadata` option is currently ignored by Health Connect.
153
164
 
165
+ **Note about workouts:** To query workout data using `queryWorkouts()`, you need to explicitly request `workouts` permission:
166
+
167
+ ```ts
168
+ await Health.requestAuthorization({
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,
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
+ }
197
+ ```
198
+
154
199
  ## API
155
200
 
156
201
  <docgen-index>
@@ -391,9 +436,10 @@ Supported on iOS (HealthKit) and Android (Health Connect).
391
436
 
392
437
  #### QueryWorkoutsResult
393
438
 
394
- | Prop | Type |
395
- | -------------- | ---------------------- |
396
- | **`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. |
397
443
 
398
444
 
399
445
  #### Workout
@@ -413,13 +459,14 @@ Supported on iOS (HealthKit) and Android (Health Connect).
413
459
 
414
460
  #### QueryWorkoutsOptions
415
461
 
416
- | Prop | Type | Description |
417
- | ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------- |
418
- | **`workoutType`** | <code><a href="#workouttype">WorkoutType</a></code> | Optional workout type filter. If omitted, all workout types are returned. |
419
- | **`startDate`** | <code>string</code> | Inclusive ISO 8601 start date (defaults to now - 1 day). |
420
- | **`endDate`** | <code>string</code> | Exclusive ISO 8601 end date (defaults to now). |
421
- | **`limit`** | <code>number</code> | Maximum number of workouts to return (defaults to 100). |
422
- | **`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. |
423
470
 
424
471
 
425
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
- ): JSArray {
302
+ ascending: Boolean,
303
+ anchor: String?
304
+ ): JSObject {
303
305
  val workouts = mutableListOf<Pair<Instant, JSObject>>()
304
306
 
305
- var pageToken: String? = null
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
- return array
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
- val distanceAggregate = try {
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(DistanceRecord.DISTANCE_TOTAL),
371
+ metrics = setOf(
372
+ DistanceRecord.DISTANCE_TOTAL,
373
+ ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL
374
+ ),
359
375
  timeRangeFilter = timeRange
360
- // Removed dataOriginFilter to get distance from all sources during workout time
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
- android.util.Log.d("HealthManager", "Distance aggregation failed for workout: ${e.message}", e)
366
- null // Permission might not be granted or no data available
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 workouts = manager.queryWorkouts(client, workoutType, startInstant, endInstant, limit, ascending)
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()
@@ -266,10 +269,15 @@ final class Health {
266
269
  }
267
270
 
268
271
  do {
269
- let readTypes = try HealthDataType.parseMany(readIdentifiers)
272
+ // Separate "workouts" from regular health data types
273
+ let (readTypes, includeWorkouts) = try parseTypesWithWorkouts(readIdentifiers)
270
274
  let writeTypes = try HealthDataType.parseMany(writeIdentifiers)
271
275
 
272
- let readObjectTypes = try objectTypes(for: readTypes)
276
+ var readObjectTypes = try objectTypes(for: readTypes)
277
+ // Include workout type if explicitly requested
278
+ if includeWorkouts {
279
+ readObjectTypes.insert(HKObjectType.workoutType())
280
+ }
273
281
  let writeSampleTypes = try sampleTypes(for: writeTypes)
274
282
 
275
283
  healthStore.requestAuthorization(toShare: writeSampleTypes, read: readObjectTypes) { [weak self] success, error in
@@ -295,7 +303,7 @@ final class Health {
295
303
 
296
304
  func checkAuthorization(readIdentifiers: [String], writeIdentifiers: [String], completion: @escaping (Result<AuthorizationStatusPayload, Error>) -> Void) {
297
305
  do {
298
- let readTypes = try HealthDataType.parseMany(readIdentifiers)
306
+ let (readTypes, _) = try parseTypesWithWorkouts(readIdentifiers)
299
307
  let writeTypes = try HealthDataType.parseMany(writeIdentifiers)
300
308
 
301
309
  evaluateAuthorizationStatus(readTypes: readTypes, writeTypes: writeTypes) { payload in
@@ -485,6 +493,24 @@ final class Health {
485
493
  return type
486
494
  }
487
495
 
496
+ private func parseTypesWithWorkouts(_ identifiers: [String]) throws -> ([HealthDataType], Bool) {
497
+ var types: [HealthDataType] = []
498
+ var includeWorkouts = false
499
+
500
+ for identifier in identifiers {
501
+ if identifier == "workouts" {
502
+ includeWorkouts = true
503
+ } else {
504
+ guard let type = HealthDataType(rawValue: identifier) else {
505
+ throw HealthManagerError.invalidDataType(identifier)
506
+ }
507
+ types.append(type)
508
+ }
509
+ }
510
+
511
+ return (types, includeWorkouts)
512
+ }
513
+
488
514
  private func parseDate(_ string: String?, defaultValue: Date) throws -> Date {
489
515
  guard let value = string else {
490
516
  return defaultValue
@@ -524,8 +550,6 @@ final class Health {
524
550
  let type = try dataType.sampleType()
525
551
  set.insert(type)
526
552
  }
527
- // Always include workout type for read access to enable workout queries
528
- set.insert(HKObjectType.workoutType())
529
553
  return set
530
554
  }
531
555
 
@@ -538,7 +562,7 @@ final class Health {
538
562
  return set
539
563
  }
540
564
 
541
- func queryWorkouts(workoutTypeString: String?, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, completion: @escaping (Result<[[String: Any]], Error>) -> Void) {
565
+ func queryWorkouts(workoutTypeString: String?, startDateString: String?, endDateString: String?, limit: Int?, ascending: Bool, anchorString: String?, completion: @escaping (Result<[String: Any], Error>) -> Void) {
542
566
  let startDate = (try? parseDate(startDateString, defaultValue: Date().addingTimeInterval(-86400))) ?? Date().addingTimeInterval(-86400)
543
567
  let endDate = (try? parseDate(endDateString, defaultValue: Date())) ?? Date()
544
568
 
@@ -547,7 +571,16 @@ final class Health {
547
571
  return
548
572
  }
549
573
 
550
- var predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [])
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: [])
551
584
 
552
585
  // Filter by workout type if specified
553
586
  if let workoutTypeString = workoutTypeString, let workoutType = WorkoutType(rawValue: workoutTypeString) {
@@ -573,7 +606,7 @@ final class Health {
573
606
  }
574
607
 
575
608
  guard let workouts = samples as? [HKWorkout] else {
576
- completion(.success([]))
609
+ completion(.success(["workouts": []]))
577
610
  return
578
611
  }
579
612
 
@@ -620,7 +653,17 @@ final class Health {
620
653
  return payload
621
654
  }
622
655
 
623
- completion(.success(results))
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))
624
667
  }
625
668
 
626
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.5"
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(workouts):
167
- call.resolve(["workouts": workouts])
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-health",
3
- "version": "8.2.5",
3
+ "version": "8.2.7",
4
4
  "description": "Capacitor plugin to interact with data from Apple HealthKit and Health Connect",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",