@capgo/capacitor-health 8.5.1 → 8.6.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
@@ -553,11 +553,25 @@ Supported on iOS (HealthKit) and Android (Health Connect).
553
553
  | **`sourceId`** | <code>string</code> | |
554
554
  | **`platformId`** | <code>string</code> | Platform-specific unique identifier (HealthKit UUID on iOS, Health Connect metadata ID on Android). |
555
555
  | **`sleepState`** | <code><a href="#sleepstate">SleepState</a></code> | For sleep data, indicates the sleep state (e.g., 'asleep', 'awake', 'rem', 'deep', 'light'). |
556
+ | **`stages`** | <code>SleepStage[]</code> | For sleep data, individual sleep stages when the platform exposes stage-level data. |
557
+ | **`hasStageData`** | <code>boolean</code> | For sleep data, indicates whether stage-level data was emitted. |
556
558
  | **`systolic`** | <code>number</code> | For blood pressure data, the systolic value in mmHg. |
557
559
  | **`diastolic`** | <code>number</code> | For blood pressure data, the diastolic value in mmHg. |
558
560
  | **`measurementMethod`** | <code>number</code> | For VO2 max data on Android, Health Connect's measurement method enum value. |
559
561
 
560
562
 
563
+ #### SleepStage
564
+
565
+ Stage-level sleep segment emitted for sleep samples when platform data is available.
566
+
567
+ | Prop | Type | Description |
568
+ | --------------------- | ------------------------------------------------- | -------------------------------------------- |
569
+ | **`startDate`** | <code>string</code> | Stage segment start date in ISO 8601 format. |
570
+ | **`endDate`** | <code>string</code> | Stage segment end date in ISO 8601 format. |
571
+ | **`stage`** | <code><a href="#sleepstate">SleepState</a></code> | Sleep stage label for this segment. |
572
+ | **`durationMinutes`** | <code>number</code> | Duration of this stage segment in minutes. |
573
+
574
+
561
575
  #### QueryOptions
562
576
 
563
577
  | Prop | Type | Description |
@@ -169,7 +169,6 @@ class HealthManager {
169
169
  }
170
170
  }
171
171
  HealthDataType.SLEEP -> readRecords(client, SleepSessionRecord::class, startTime, endTime, limit) { record ->
172
- // For sleep sessions, calculate duration in minutes
173
172
  val durationMinutes = Duration.between(record.startTime, record.endTime).toMinutes().toDouble()
174
173
  val payload = createSamplePayload(
175
174
  dataType,
@@ -178,10 +177,24 @@ class HealthManager {
178
177
  durationMinutes,
179
178
  record.metadata
180
179
  )
181
- // Add sleep stage if available (map from sleep session stages)
182
- // Note: SleepSessionRecord doesn't have individual stages in the main record
183
- // Individual sleep stages would be in SleepStageRecord, but for simplicity
184
- // we'll just return the session duration
180
+ // Expose individual sleep stages when present (Samsung Health, Mi Health,
181
+ // Apple Health, and other writers typically populate SleepSessionRecord.stages).
182
+ val stagesArray = JSArray()
183
+ record.stages.forEach { stage ->
184
+ val stageString = mapSleepStageToString(stage.stage) ?: return@forEach
185
+ val stageObj = JSObject()
186
+ stageObj.put("startDate", formatter.format(stage.startTime))
187
+ stageObj.put("endDate", formatter.format(stage.endTime))
188
+ stageObj.put("stage", stageString)
189
+ stageObj.put("durationMinutes", Duration.between(stage.startTime, stage.endTime).toMinutes().toDouble())
190
+ stagesArray.put(stageObj)
191
+ }
192
+ val hasNormalizedStages = stagesArray.length() > 0
193
+ if (hasNormalizedStages) {
194
+ payload.put("stages", stagesArray)
195
+ }
196
+ payload.put("hasStageData", hasNormalizedStages)
197
+
185
198
  samples.add(record.startTime to payload)
186
199
  }
187
200
  HealthDataType.RESPIRATORY_RATE -> readRecords(client, RespiratoryRateRecord::class, startTime, endTime, limit) { record ->
@@ -630,8 +643,18 @@ class HealthManager {
630
643
  }
631
644
  return Instant.parse(value)
632
645
  }
633
-
634
- private fun createSamplePayload(
646
+ private fun mapSleepStageToString(stage: Int): String? {
647
+ return when (stage) {
648
+ SleepSessionRecord.STAGE_TYPE_AWAKE -> "awake"
649
+ SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED -> "awake"
650
+ SleepSessionRecord.STAGE_TYPE_SLEEPING -> "asleep"
651
+ SleepSessionRecord.STAGE_TYPE_LIGHT -> "light"
652
+ SleepSessionRecord.STAGE_TYPE_DEEP -> "deep"
653
+ SleepSessionRecord.STAGE_TYPE_REM -> "rem"
654
+ else -> null // OUT_OF_BED, UNKNOWN: filtered out by caller
655
+ }
656
+ }
657
+ private fun createSamplePayload(
635
658
  dataType: HealthDataType,
636
659
  startTime: Instant,
637
660
  endTime: Instant,
package/dist/docs.json CHANGED
@@ -405,6 +405,22 @@
405
405
  ],
406
406
  "type": "SleepState"
407
407
  },
408
+ {
409
+ "name": "stages",
410
+ "tags": [],
411
+ "docs": "For sleep data, individual sleep stages when the platform exposes stage-level data.",
412
+ "complexTypes": [
413
+ "SleepStage"
414
+ ],
415
+ "type": "SleepStage[] | undefined"
416
+ },
417
+ {
418
+ "name": "hasStageData",
419
+ "tags": [],
420
+ "docs": "For sleep data, indicates whether stage-level data was emitted.",
421
+ "complexTypes": [],
422
+ "type": "boolean | undefined"
423
+ },
408
424
  {
409
425
  "name": "systolic",
410
426
  "tags": [],
@@ -428,6 +444,45 @@
428
444
  }
429
445
  ]
430
446
  },
447
+ {
448
+ "name": "SleepStage",
449
+ "slug": "sleepstage",
450
+ "docs": "Stage-level sleep segment emitted for sleep samples when platform data is available.",
451
+ "tags": [],
452
+ "methods": [],
453
+ "properties": [
454
+ {
455
+ "name": "startDate",
456
+ "tags": [],
457
+ "docs": "Stage segment start date in ISO 8601 format.",
458
+ "complexTypes": [],
459
+ "type": "string"
460
+ },
461
+ {
462
+ "name": "endDate",
463
+ "tags": [],
464
+ "docs": "Stage segment end date in ISO 8601 format.",
465
+ "complexTypes": [],
466
+ "type": "string"
467
+ },
468
+ {
469
+ "name": "stage",
470
+ "tags": [],
471
+ "docs": "Sleep stage label for this segment.",
472
+ "complexTypes": [
473
+ "SleepState"
474
+ ],
475
+ "type": "SleepState"
476
+ },
477
+ {
478
+ "name": "durationMinutes",
479
+ "tags": [],
480
+ "docs": "Duration of this stage segment in minutes.",
481
+ "complexTypes": [],
482
+ "type": "number"
483
+ }
484
+ ]
485
+ },
431
486
  {
432
487
  "name": "QueryOptions",
433
488
  "slug": "queryoptions",
@@ -31,6 +31,17 @@ export interface QueryOptions {
31
31
  ascending?: boolean;
32
32
  }
33
33
  export type SleepState = 'inBed' | 'asleep' | 'awake' | 'rem' | 'deep' | 'light';
34
+ /** Stage-level sleep segment emitted for sleep samples when platform data is available. */
35
+ export interface SleepStage {
36
+ /** Stage segment start date in ISO 8601 format. */
37
+ startDate: string;
38
+ /** Stage segment end date in ISO 8601 format. */
39
+ endDate: string;
40
+ /** Sleep stage label for this segment. */
41
+ stage: SleepState;
42
+ /** Duration of this stage segment in minutes. */
43
+ durationMinutes: number;
44
+ }
34
45
  export interface HealthSample {
35
46
  dataType: HealthDataType;
36
47
  value: number;
@@ -43,6 +54,10 @@ export interface HealthSample {
43
54
  platformId?: string;
44
55
  /** For sleep data, indicates the sleep state (e.g., 'asleep', 'awake', 'rem', 'deep', 'light'). */
45
56
  sleepState?: SleepState;
57
+ /** For sleep data, individual sleep stages when the platform exposes stage-level data. */
58
+ stages?: SleepStage[];
59
+ /** For sleep data, indicates whether stage-level data was emitted. */
60
+ hasStageData?: boolean;
46
61
  /** For blood pressure data, the systolic value in mmHg. */
47
62
  systolic?: number;
48
63
  /** For blood pressure data, the diastolic value in mmHg. */
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export type HealthDataType =\n | 'steps'\n | 'distance'\n | 'calories'\n | 'heartRate'\n | 'weight'\n | 'sleep'\n | 'respiratoryRate'\n | 'oxygenSaturation'\n | 'restingHeartRate'\n | 'heartRateVariability'\n | 'vo2Max'\n | 'bloodPressure'\n | 'bloodGlucose'\n | 'bodyTemperature'\n | 'height'\n | 'flightsClimbed'\n | 'exerciseTime'\n | 'distanceCycling'\n | 'bodyFat'\n | 'basalBodyTemperature'\n | 'basalCalories'\n | 'totalCalories'\n | 'mindfulness'\n | 'workouts';\n\nexport type HealthUnit =\n | 'count'\n | 'meter'\n | 'kilocalorie'\n | 'bpm'\n | 'kilogram'\n | 'minute'\n | 'percent'\n | 'millisecond'\n | 'mL/min/kg'\n | 'mmHg'\n | 'mg/dL'\n | 'celsius'\n | 'fahrenheit'\n | 'centimeter';\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 type SleepState = 'inBed' | 'asleep' | 'awake' | 'rem' | 'deep' | 'light';\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 /** Platform-specific unique identifier (HealthKit UUID on iOS, Health Connect metadata ID on Android). */\n platformId?: string;\n /** For sleep data, indicates the sleep state (e.g., 'asleep', 'awake', 'rem', 'deep', 'light'). */\n sleepState?: SleepState;\n /** For blood pressure data, the systolic value in mmHg. */\n systolic?: number;\n /** For blood pressure data, the diastolic value in mmHg. */\n diastolic?: number;\n /** For VO2 max data on Android, Health Connect's measurement method enum value. */\n measurementMethod?: number;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport type WorkoutType =\n // Common types (supported on both platforms)\n | 'americanFootball'\n | 'australianFootball'\n | 'badminton'\n | 'baseball'\n | 'basketball'\n | 'bowling'\n | 'boxing'\n | 'climbing'\n | 'cricket'\n | 'crossTraining'\n | 'curling'\n | 'cycling'\n | 'dance'\n | 'elliptical'\n | 'fencing'\n | 'functionalStrengthTraining'\n | 'golf'\n | 'gymnastics'\n | 'handball'\n | 'hiking'\n | 'hockey'\n | 'jumpRope'\n | 'kickboxing'\n | 'lacrosse'\n | 'martialArts'\n | 'pilates'\n | 'racquetball'\n | 'rowing'\n | 'rugby'\n | 'running'\n | 'sailing'\n | 'skatingSports'\n | 'skiing'\n | 'snowboarding'\n | 'soccer'\n | 'softball'\n | 'squash'\n | 'stairClimbing'\n | 'strengthTraining'\n | 'surfing'\n | 'swimming'\n | 'swimmingPool'\n | 'swimmingOpenWater'\n | 'tableTennis'\n | 'tennis'\n | 'trackAndField'\n | 'traditionalStrengthTraining'\n | 'volleyball'\n | 'walking'\n | 'waterFitness'\n | 'waterPolo'\n | 'waterSports'\n | 'weightlifting'\n | 'wheelchair'\n | 'yoga'\n // iOS specific types\n | 'archery'\n | 'barre'\n | 'cooldown'\n | 'coreTraining'\n | 'crossCountrySkiing'\n | 'discSports'\n | 'downhillSkiing'\n | 'equestrianSports'\n | 'fishing'\n | 'fitnessGaming'\n | 'flexibility'\n | 'handCycling'\n | 'highIntensityIntervalTraining'\n | 'hunting'\n | 'mindAndBody'\n | 'mixedCardio'\n | 'paddleSports'\n | 'pickleball'\n | 'play'\n | 'preparationAndRecovery'\n | 'snowSports'\n | 'stairs'\n | 'stepTraining'\n | 'surfingSports'\n | 'taiChi'\n | 'transition'\n | 'underwaterDiving'\n | 'wheelchairRunPace'\n | 'wheelchairWalkPace'\n | 'wrestling'\n | 'cardioDance'\n | 'socialDance'\n // Android specific types\n | 'backExtension'\n | 'barbellShoulderPress'\n | 'benchPress'\n | 'benchSitUp'\n | 'bikingStationary'\n | 'bootCamp'\n | 'burpee'\n | 'calisthenics'\n | 'crunch'\n | 'dancing'\n | 'deadlift'\n | 'dumbbellCurlLeftArm'\n | 'dumbbellCurlRightArm'\n | 'dumbbellFrontRaise'\n | 'dumbbellLateralRaise'\n | 'dumbbellTricepsExtensionLeftArm'\n | 'dumbbellTricepsExtensionRightArm'\n | 'dumbbellTricepsExtensionTwoArm'\n | 'exerciseClass'\n | 'forwardTwist'\n | 'frisbeedisc'\n | 'guidedBreathing'\n | 'iceHockey'\n | 'iceSkating'\n | 'jumpingJack'\n | 'latPullDown'\n | 'lunge'\n | 'meditation'\n | 'paddling'\n | 'paraGliding'\n | 'plank'\n | 'rockClimbing'\n | 'rollerHockey'\n | 'rowingMachine'\n | 'runningTreadmill'\n | 'scubaDiving'\n | 'skating'\n | 'snowshoeing'\n | 'stairClimbingMachine'\n | 'stretching'\n | 'upperTwist'\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 is the ISO 8601 cursor returned by the previous query. On Android, this uses\n * 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 /** Platform-specific unique identifier (HealthKit UUID on iOS, Health Connect metadata ID on Android). */\n platformId?: 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 /** For blood pressure data, the systolic value in mmHg. Required when dataType is 'bloodPressure'. */\n systolic?: number;\n /** For blood pressure data, the diastolic value in mmHg. Required when dataType is 'bloodPressure'. */\n diastolic?: number;\n}\n\nexport type BucketType = 'hour' | 'day' | 'week' | 'month';\n\nexport type AggregationType = 'sum' | 'average' | 'min' | 'max';\n\nexport interface QueryAggregatedOptions {\n /** The type of data to aggregate 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 /** Time bucket for aggregation (defaults to 'day'). */\n bucket?: BucketType;\n /** Aggregation operation to perform (defaults to 'sum'). */\n aggregation?: AggregationType;\n}\n\nexport interface AggregatedSample {\n /** ISO 8601 start date of the bucket. */\n startDate: string;\n /** ISO 8601 end date of the bucket. */\n endDate: string;\n /** Aggregated value for the bucket. */\n value: number;\n /** Unit of the aggregated value. */\n unit: HealthUnit;\n}\n\nexport interface QueryAggregatedResult {\n samples: AggregatedSample[];\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 /**\n * Queries aggregated health data from the native health store.\n * Aggregates data into time buckets (hour, day, week, month) with operations like sum, average, min, or max.\n * This is more efficient than fetching individual samples for large date ranges.\n *\n * Supported on iOS (HealthKit) and Android (Health Connect).\n *\n * @param options Query options including data type, date range, bucket size, and aggregation type\n * @returns A promise that resolves with the aggregated samples\n * @throws An error if something went wrong\n */\n queryAggregated(options: QueryAggregatedOptions): Promise<QueryAggregatedResult>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export type HealthDataType =\n | 'steps'\n | 'distance'\n | 'calories'\n | 'heartRate'\n | 'weight'\n | 'sleep'\n | 'respiratoryRate'\n | 'oxygenSaturation'\n | 'restingHeartRate'\n | 'heartRateVariability'\n | 'vo2Max'\n | 'bloodPressure'\n | 'bloodGlucose'\n | 'bodyTemperature'\n | 'height'\n | 'flightsClimbed'\n | 'exerciseTime'\n | 'distanceCycling'\n | 'bodyFat'\n | 'basalBodyTemperature'\n | 'basalCalories'\n | 'totalCalories'\n | 'mindfulness'\n | 'workouts';\n\nexport type HealthUnit =\n | 'count'\n | 'meter'\n | 'kilocalorie'\n | 'bpm'\n | 'kilogram'\n | 'minute'\n | 'percent'\n | 'millisecond'\n | 'mL/min/kg'\n | 'mmHg'\n | 'mg/dL'\n | 'celsius'\n | 'fahrenheit'\n | 'centimeter';\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 type SleepState = 'inBed' | 'asleep' | 'awake' | 'rem' | 'deep' | 'light';\n\n/** Stage-level sleep segment emitted for sleep samples when platform data is available. */\nexport interface SleepStage {\n /** Stage segment start date in ISO 8601 format. */\n startDate: string;\n /** Stage segment end date in ISO 8601 format. */\n endDate: string;\n /** Sleep stage label for this segment. */\n stage: SleepState;\n /** Duration of this stage segment in minutes. */\n durationMinutes: number;\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 /** Platform-specific unique identifier (HealthKit UUID on iOS, Health Connect metadata ID on Android). */\n platformId?: string;\n /** For sleep data, indicates the sleep state (e.g., 'asleep', 'awake', 'rem', 'deep', 'light'). */\n sleepState?: SleepState;\n /** For sleep data, individual sleep stages when the platform exposes stage-level data. */\n stages?: SleepStage[];\n /** For sleep data, indicates whether stage-level data was emitted. */\n hasStageData?: boolean;\n /** For blood pressure data, the systolic value in mmHg. */\n systolic?: number;\n /** For blood pressure data, the diastolic value in mmHg. */\n diastolic?: number;\n /** For VO2 max data on Android, Health Connect's measurement method enum value. */\n measurementMethod?: number;\n}\n\nexport interface ReadSamplesResult {\n samples: HealthSample[];\n}\n\nexport type WorkoutType =\n // Common types (supported on both platforms)\n | 'americanFootball'\n | 'australianFootball'\n | 'badminton'\n | 'baseball'\n | 'basketball'\n | 'bowling'\n | 'boxing'\n | 'climbing'\n | 'cricket'\n | 'crossTraining'\n | 'curling'\n | 'cycling'\n | 'dance'\n | 'elliptical'\n | 'fencing'\n | 'functionalStrengthTraining'\n | 'golf'\n | 'gymnastics'\n | 'handball'\n | 'hiking'\n | 'hockey'\n | 'jumpRope'\n | 'kickboxing'\n | 'lacrosse'\n | 'martialArts'\n | 'pilates'\n | 'racquetball'\n | 'rowing'\n | 'rugby'\n | 'running'\n | 'sailing'\n | 'skatingSports'\n | 'skiing'\n | 'snowboarding'\n | 'soccer'\n | 'softball'\n | 'squash'\n | 'stairClimbing'\n | 'strengthTraining'\n | 'surfing'\n | 'swimming'\n | 'swimmingPool'\n | 'swimmingOpenWater'\n | 'tableTennis'\n | 'tennis'\n | 'trackAndField'\n | 'traditionalStrengthTraining'\n | 'volleyball'\n | 'walking'\n | 'waterFitness'\n | 'waterPolo'\n | 'waterSports'\n | 'weightlifting'\n | 'wheelchair'\n | 'yoga'\n // iOS specific types\n | 'archery'\n | 'barre'\n | 'cooldown'\n | 'coreTraining'\n | 'crossCountrySkiing'\n | 'discSports'\n | 'downhillSkiing'\n | 'equestrianSports'\n | 'fishing'\n | 'fitnessGaming'\n | 'flexibility'\n | 'handCycling'\n | 'highIntensityIntervalTraining'\n | 'hunting'\n | 'mindAndBody'\n | 'mixedCardio'\n | 'paddleSports'\n | 'pickleball'\n | 'play'\n | 'preparationAndRecovery'\n | 'snowSports'\n | 'stairs'\n | 'stepTraining'\n | 'surfingSports'\n | 'taiChi'\n | 'transition'\n | 'underwaterDiving'\n | 'wheelchairRunPace'\n | 'wheelchairWalkPace'\n | 'wrestling'\n | 'cardioDance'\n | 'socialDance'\n // Android specific types\n | 'backExtension'\n | 'barbellShoulderPress'\n | 'benchPress'\n | 'benchSitUp'\n | 'bikingStationary'\n | 'bootCamp'\n | 'burpee'\n | 'calisthenics'\n | 'crunch'\n | 'dancing'\n | 'deadlift'\n | 'dumbbellCurlLeftArm'\n | 'dumbbellCurlRightArm'\n | 'dumbbellFrontRaise'\n | 'dumbbellLateralRaise'\n | 'dumbbellTricepsExtensionLeftArm'\n | 'dumbbellTricepsExtensionRightArm'\n | 'dumbbellTricepsExtensionTwoArm'\n | 'exerciseClass'\n | 'forwardTwist'\n | 'frisbeedisc'\n | 'guidedBreathing'\n | 'iceHockey'\n | 'iceSkating'\n | 'jumpingJack'\n | 'latPullDown'\n | 'lunge'\n | 'meditation'\n | 'paddling'\n | 'paraGliding'\n | 'plank'\n | 'rockClimbing'\n | 'rollerHockey'\n | 'rowingMachine'\n | 'runningTreadmill'\n | 'scubaDiving'\n | 'skating'\n | 'snowshoeing'\n | 'stairClimbingMachine'\n | 'stretching'\n | 'upperTwist'\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 is the ISO 8601 cursor returned by the previous query. On Android, this uses\n * 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 /** Platform-specific unique identifier (HealthKit UUID on iOS, Health Connect metadata ID on Android). */\n platformId?: 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 /** For blood pressure data, the systolic value in mmHg. Required when dataType is 'bloodPressure'. */\n systolic?: number;\n /** For blood pressure data, the diastolic value in mmHg. Required when dataType is 'bloodPressure'. */\n diastolic?: number;\n}\n\nexport type BucketType = 'hour' | 'day' | 'week' | 'month';\n\nexport type AggregationType = 'sum' | 'average' | 'min' | 'max';\n\nexport interface QueryAggregatedOptions {\n /** The type of data to aggregate 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 /** Time bucket for aggregation (defaults to 'day'). */\n bucket?: BucketType;\n /** Aggregation operation to perform (defaults to 'sum'). */\n aggregation?: AggregationType;\n}\n\nexport interface AggregatedSample {\n /** ISO 8601 start date of the bucket. */\n startDate: string;\n /** ISO 8601 end date of the bucket. */\n endDate: string;\n /** Aggregated value for the bucket. */\n value: number;\n /** Unit of the aggregated value. */\n unit: HealthUnit;\n}\n\nexport interface QueryAggregatedResult {\n samples: AggregatedSample[];\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 /**\n * Queries aggregated health data from the native health store.\n * Aggregates data into time buckets (hour, day, week, month) with operations like sum, average, min, or max.\n * This is more efficient than fetching individual samples for large date ranges.\n *\n * Supported on iOS (HealthKit) and Android (Health Connect).\n *\n * @param options Query options including data type, date range, bucket size, and aggregation type\n * @returns A promise that resolves with the aggregated samples\n * @throws An error if something went wrong\n */\n queryAggregated(options: QueryAggregatedOptions): Promise<QueryAggregatedResult>;\n}\n"]}
@@ -881,22 +881,30 @@ final class Health {
881
881
  return
882
882
  }
883
883
 
884
- let results = categorySamples.map { sample -> [String: Any] in
884
+ let results = categorySamples.compactMap { sample -> [String: Any]? in
885
885
  let sleepValue = sample.value
886
886
  let durationMinutes = sample.endDate.timeIntervalSince(sample.startDate) / 60.0
887
-
888
- var payload: [String: Any] = [
889
- "dataType": dataType.rawValue,
890
- "value": durationMinutes,
891
- "unit": dataType.unitIdentifier,
892
- "startDate": self.isoFormatter.string(from: sample.startDate),
893
- "endDate": self.isoFormatter.string(from: sample.endDate)
894
- ]
895
-
896
- // Map HKCategoryValueSleepAnalysis to sleep state
887
+ guard var payload = self.readSamplePayload(
888
+ dataType: dataType,
889
+ value: durationMinutes,
890
+ startDate: sample.startDate,
891
+ endDate: sample.endDate
892
+ ) else {
893
+ return nil
894
+ }
895
+
897
896
  let sleepState = self.sleepStateFromValue(sleepValue)
898
897
  if let sleepState = sleepState {
899
898
  payload["sleepState"] = sleepState
899
+ payload["stages"] = [[
900
+ "startDate": self.isoFormatter.string(from: sample.startDate),
901
+ "endDate": self.isoFormatter.string(from: sample.endDate),
902
+ "stage": sleepState,
903
+ "durationMinutes": durationMinutes
904
+ ]]
905
+ payload["hasStageData"] = true
906
+ } else {
907
+ payload["hasStageData"] = false
900
908
  }
901
909
 
902
910
  self.addSampleMetadata(sample, to: &payload)
@@ -925,16 +933,16 @@ final class Health {
925
933
  return
926
934
  }
927
935
 
928
- let results = categorySamples.map { sample -> [String: Any] in
936
+ let results = categorySamples.compactMap { sample -> [String: Any]? in
929
937
  let durationMinutes = sample.endDate.timeIntervalSince(sample.startDate) / 60.0
930
-
931
- var payload: [String: Any] = [
932
- "dataType": dataType.rawValue,
933
- "value": durationMinutes,
934
- "unit": dataType.unitIdentifier,
935
- "startDate": self.isoFormatter.string(from: sample.startDate),
936
- "endDate": self.isoFormatter.string(from: sample.endDate)
937
- ]
938
+ guard var payload = self.readSamplePayload(
939
+ dataType: dataType,
940
+ value: durationMinutes,
941
+ startDate: sample.startDate,
942
+ endDate: sample.endDate
943
+ ) else {
944
+ return nil
945
+ }
938
946
 
939
947
  self.addSampleMetadata(sample, to: &payload)
940
948
 
@@ -972,16 +980,19 @@ final class Health {
972
980
 
973
981
  let systolicValue = systolicSample.quantity.doubleValue(for: HKUnit.millimeterOfMercury())
974
982
  let diastolicValue = diastolicSample.quantity.doubleValue(for: HKUnit.millimeterOfMercury())
975
-
976
- var payload: [String: Any] = [
977
- "dataType": dataType.rawValue,
978
- "value": systolicValue,
979
- "unit": dataType.unitIdentifier,
980
- "startDate": self.isoFormatter.string(from: correlation.startDate),
981
- "endDate": self.isoFormatter.string(from: correlation.endDate),
982
- "systolic": systolicValue,
983
- "diastolic": diastolicValue
984
- ]
983
+ guard systolicValue.isFinite,
984
+ diastolicValue.isFinite,
985
+ var payload = self.readSamplePayload(
986
+ dataType: dataType,
987
+ value: systolicValue,
988
+ startDate: correlation.startDate,
989
+ endDate: correlation.endDate
990
+ ) else {
991
+ return nil
992
+ }
993
+
994
+ payload["systolic"] = systolicValue
995
+ payload["diastolic"] = diastolicValue
985
996
 
986
997
  self.addSampleMetadata(correlation, to: &payload)
987
998
 
@@ -1008,15 +1019,16 @@ final class Health {
1008
1019
  return
1009
1020
  }
1010
1021
 
1011
- let results = quantitySamples.map { sample -> [String: Any] in
1022
+ let results = quantitySamples.compactMap { sample -> [String: Any]? in
1012
1023
  let value = sample.quantity.doubleValue(for: dataType.defaultUnit)
1013
- var payload: [String: Any] = [
1014
- "dataType": dataType.rawValue,
1015
- "value": value,
1016
- "unit": dataType.unitIdentifier,
1017
- "startDate": self.isoFormatter.string(from: sample.startDate),
1018
- "endDate": self.isoFormatter.string(from: sample.endDate)
1019
- ]
1024
+ guard var payload = self.readSamplePayload(
1025
+ dataType: dataType,
1026
+ value: value,
1027
+ startDate: sample.startDate,
1028
+ endDate: sample.endDate
1029
+ ) else {
1030
+ return nil
1031
+ }
1020
1032
 
1021
1033
  self.addSampleMetadata(sample, to: &payload)
1022
1034
 
@@ -1028,6 +1040,20 @@ final class Health {
1028
1040
 
1029
1041
  healthStore.execute(query)
1030
1042
  }
1043
+
1044
+ func readSamplePayload(dataType: HealthDataType, value: Double, startDate: Date, endDate: Date) -> [String: Any]? {
1045
+ guard value.isFinite else {
1046
+ return nil
1047
+ }
1048
+
1049
+ return [
1050
+ "dataType": dataType.rawValue,
1051
+ "value": value,
1052
+ "unit": dataType.unitIdentifier,
1053
+ "startDate": isoFormatter.string(from: startDate),
1054
+ "endDate": isoFormatter.string(from: endDate)
1055
+ ]
1056
+ }
1031
1057
 
1032
1058
  private func addSampleMetadata(_ sample: HKSample, to payload: inout [String: Any]) {
1033
1059
  let source = sample.sourceRevision.source
@@ -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.5.1"
6
+ private let pluginVersion: String = "8.6.0"
7
7
  public let identifier = "HealthPlugin"
8
8
  public let jsName = "Health"
9
9
  public let pluginMethods: [CAPPluginMethod] = [
@@ -2,14 +2,72 @@ import XCTest
2
2
  @testable import HealthPlugin
3
3
 
4
4
  class HealthTests: XCTestCase {
5
- func testEcho() {
6
- // This is an example of a functional test case for a plugin.
7
- // Use XCTAssert and related functions to verify your tests produce the correct results.
5
+ func testReadSamplePayloadIncludesFiniteValue() throws {
6
+ let implementation = Health()
7
+ let startDate = Date(timeIntervalSince1970: 0)
8
+ let endDate = Date(timeIntervalSince1970: 60)
9
+ let formatter = ISO8601DateFormatter()
10
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
11
+
12
+ let payload = implementation.readSamplePayload(
13
+ dataType: .oxygenSaturation,
14
+ value: 0.96,
15
+ startDate: startDate,
16
+ endDate: endDate
17
+ )
18
+
19
+ XCTAssertEqual(payload?["dataType"] as? String, "oxygenSaturation")
20
+ XCTAssertEqual(payload?["value"] as? Double, 0.96)
21
+ XCTAssertEqual(payload?["unit"] as? String, "percent")
8
22
 
23
+ let startDateString = try XCTUnwrap(payload?["startDate"] as? String)
24
+ let endDateString = try XCTUnwrap(payload?["endDate"] as? String)
25
+ let parsedStartDate = try XCTUnwrap(formatter.date(from: startDateString))
26
+ let parsedEndDate = try XCTUnwrap(formatter.date(from: endDateString))
27
+
28
+ XCTAssertEqual(parsedStartDate.timeIntervalSince1970, startDate.timeIntervalSince1970, accuracy: 0.001)
29
+ XCTAssertEqual(parsedEndDate.timeIntervalSince1970, endDate.timeIntervalSince1970, accuracy: 0.001)
30
+ }
31
+
32
+ func testReadSamplePayloadRejectsNaNValue() {
9
33
  let implementation = Health()
10
- let value = "Hello, World!"
11
- let result = implementation.echo(value)
34
+ let startDate = Date(timeIntervalSince1970: 0)
35
+
36
+ let payload = implementation.readSamplePayload(
37
+ dataType: .oxygenSaturation,
38
+ value: .nan,
39
+ startDate: startDate,
40
+ endDate: startDate
41
+ )
42
+
43
+ XCTAssertNil(payload)
44
+ }
45
+
46
+ func testReadSamplePayloadRejectsInfiniteValue() {
47
+ let implementation = Health()
48
+ let startDate = Date(timeIntervalSince1970: 0)
49
+
50
+ let payload = implementation.readSamplePayload(
51
+ dataType: .bloodGlucose,
52
+ value: .infinity,
53
+ startDate: startDate,
54
+ endDate: startDate
55
+ )
56
+
57
+ XCTAssertNil(payload)
58
+ }
59
+
60
+ func testReadSamplePayloadRejectsNegativeInfiniteValue() {
61
+ let implementation = Health()
62
+ let startDate = Date(timeIntervalSince1970: 0)
63
+
64
+ let payload = implementation.readSamplePayload(
65
+ dataType: .bloodGlucose,
66
+ value: -.infinity,
67
+ startDate: startDate,
68
+ endDate: startDate
69
+ )
12
70
 
13
- XCTAssertEqual(value, result)
71
+ XCTAssertNil(payload)
14
72
  }
15
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-health",
3
- "version": "8.5.1",
3
+ "version": "8.6.0",
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",