@flomentumsolutions/capacitor-health-extended 0.6.3 → 0.6.4
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 +4 -3
- package/android/src/main/java/com/flomentum/{health → vitals}/capacitor/HealthPlugin.kt +27 -0
- package/dist/esm/definitions.d.ts +2 -1
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/HealthPluginPlugin/HealthPlugin.swift +107 -0
- package/package.json +1 -1
- /package/android/src/main/java/com/flomentum/{health → vitals}/capacitor/PermissionsRationaleActivity.kt +0 -0
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Thanks [@mley](https://github.com/mley) for the ground work. The goal of this fo
|
|
|
27
27
|
- Request and verify health permissions
|
|
28
28
|
- Query aggregated data like steps or calories
|
|
29
29
|
- Retrieve workout sessions with optional route and heart rate data
|
|
30
|
-
- Fetch the latest samples for steps, distance (incl. cycling), calories (active/total/basal), heart‑rate, resting HR, HRV, respiratory rate, blood pressure, oxygen saturation, blood glucose, body temperature (basal + core), body fat, height, weight, flights climbed, sleep, and exercise time.
|
|
30
|
+
- Fetch the latest samples for steps, distance (incl. cycling), calories (active/total/basal), heart‑rate, resting HR, HRV, respiratory rate, blood pressure, oxygen saturation, blood glucose, body temperature (basal + core), body fat, height, weight, flights climbed, sleep (incl. REM duration), and exercise time.
|
|
31
31
|
- Read profile characteristics on iOS: biological sex, blood type, date of birth, Fitzpatrick skin type, wheelchair use.
|
|
32
32
|
|
|
33
33
|
### Supported data types (parity iOS + Android)
|
|
@@ -36,7 +36,7 @@ Thanks [@mley](https://github.com/mley) for the ground work. The goal of this fo
|
|
|
36
36
|
- Vitals: heart rate, resting heart rate, HRV, respiratory rate, blood pressure, oxygen saturation, blood glucose, body temperature, basal body temperature
|
|
37
37
|
- Body: weight, height, body fat
|
|
38
38
|
- Characteristics (iOS): biological sex, blood type, date of birth, Fitzpatrick skin type, wheelchair use
|
|
39
|
-
- Sessions: mindfulness, sleep
|
|
39
|
+
- Sessions: mindfulness, sleep, sleep REM (latest sample only)
|
|
40
40
|
|
|
41
41
|
## Install
|
|
42
42
|
|
|
@@ -384,6 +384,7 @@ queryLatestSample(request: { dataType: LatestDataType; }) => Promise<QueryLatest
|
|
|
384
384
|
|
|
385
385
|
Query latest sample for a specific data type
|
|
386
386
|
- Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.
|
|
387
|
+
- `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.
|
|
387
388
|
|
|
388
389
|
| Param | Type |
|
|
389
390
|
| ------------- | ------------------------------------------------------------------------ |
|
|
@@ -609,6 +610,6 @@ Construct a type with a set of properties K of type T
|
|
|
609
610
|
|
|
610
611
|
#### LatestDataType
|
|
611
612
|
|
|
612
|
-
<code>'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'hrv' | 'blood-pressure'</code>
|
|
613
|
+
<code>'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'sleep-rem' | 'hrv' | 'blood-pressure'</code>
|
|
613
614
|
|
|
614
615
|
</docgen-api>
|
|
@@ -405,6 +405,7 @@ class HealthPlugin : Plugin() {
|
|
|
405
405
|
"exercise-time" -> readLatestExerciseTime()
|
|
406
406
|
"mindfulness" -> readLatestMindfulness()
|
|
407
407
|
"sleep" -> readLatestSleep()
|
|
408
|
+
"sleep-rem" -> readLatestSleepRem()
|
|
408
409
|
else -> throw IllegalArgumentException("Unsupported data type: $dataType")
|
|
409
410
|
}
|
|
410
411
|
call.resolve(result)
|
|
@@ -815,6 +816,32 @@ class HealthPlugin : Plugin() {
|
|
|
815
816
|
}
|
|
816
817
|
}
|
|
817
818
|
|
|
819
|
+
private suspend fun readLatestSleepRem(): JSObject {
|
|
820
|
+
val hasSleepPermission = hasPermission(CapHealthPermission.READ_SLEEP)
|
|
821
|
+
if (!hasSleepPermission) {
|
|
822
|
+
throw Exception("Permission for sleep not granted")
|
|
823
|
+
}
|
|
824
|
+
val request = ReadRecordsRequest(
|
|
825
|
+
recordType = SleepSessionRecord::class,
|
|
826
|
+
timeRangeFilter = TimeRangeFilter.after(Instant.EPOCH),
|
|
827
|
+
pageSize = 1
|
|
828
|
+
)
|
|
829
|
+
val record = healthConnectClient.readRecords(request).records.firstOrNull()
|
|
830
|
+
?: throw Exception("No sleep data found")
|
|
831
|
+
val remMinutes = record.stages
|
|
832
|
+
.filter { it.stage == SleepSessionRecord.STAGE_TYPE_REM }
|
|
833
|
+
.sumOf { it.endTime.epochSecond - it.startTime.epochSecond } / 60.0
|
|
834
|
+
if (remMinutes <= 0) {
|
|
835
|
+
throw Exception("No REM sleep data found")
|
|
836
|
+
}
|
|
837
|
+
return JSObject().apply {
|
|
838
|
+
put("value", remMinutes)
|
|
839
|
+
put("timestamp", record.startTime.epochSecond * 1000)
|
|
840
|
+
put("endTimestamp", record.endTime.epochSecond * 1000)
|
|
841
|
+
put("unit", "min")
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
818
845
|
private suspend fun sumActiveAndBasalCalories(
|
|
819
846
|
timeRange: TimeRangeFilter,
|
|
820
847
|
includeTotalMetricFallback: Boolean = false
|
|
@@ -64,6 +64,7 @@ export interface HealthPlugin {
|
|
|
64
64
|
/**
|
|
65
65
|
* Query latest sample for a specific data type
|
|
66
66
|
* - Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.
|
|
67
|
+
* - `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.
|
|
67
68
|
* @param request
|
|
68
69
|
*/
|
|
69
70
|
queryLatestSample(request: {
|
|
@@ -87,7 +88,7 @@ export interface HealthPlugin {
|
|
|
87
88
|
querySteps(): Promise<QueryLatestSampleResponse>;
|
|
88
89
|
}
|
|
89
90
|
export declare type HealthPermission = 'READ_STEPS' | 'READ_WORKOUTS' | 'READ_ACTIVE_CALORIES' | 'READ_TOTAL_CALORIES' | 'READ_DISTANCE' | 'READ_WEIGHT' | 'READ_HEIGHT' | 'READ_HEART_RATE' | 'READ_RESTING_HEART_RATE' | 'READ_ROUTE' | 'READ_MINDFULNESS' | 'READ_HRV' | 'READ_BLOOD_PRESSURE' | 'READ_BASAL_CALORIES' | 'READ_RESPIRATORY_RATE' | 'READ_OXYGEN_SATURATION' | 'READ_BLOOD_GLUCOSE' | 'READ_BODY_TEMPERATURE' | 'READ_BASAL_BODY_TEMPERATURE' | 'READ_BODY_FAT' | 'READ_FLOORS_CLIMBED' | 'READ_SLEEP' | 'READ_EXERCISE_TIME' | 'READ_BIOLOGICAL_SEX' | 'READ_BLOOD_TYPE' | 'READ_DATE_OF_BIRTH' | 'READ_FITZPATRICK_SKIN_TYPE' | 'READ_WHEELCHAIR_USE';
|
|
90
|
-
export type LatestDataType = 'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'hrv' | 'blood-pressure';
|
|
91
|
+
export type LatestDataType = 'steps' | 'active-calories' | 'total-calories' | 'basal-calories' | 'distance' | 'weight' | 'height' | 'heart-rate' | 'resting-heart-rate' | 'respiratory-rate' | 'oxygen-saturation' | 'blood-glucose' | 'body-temperature' | 'basal-body-temperature' | 'body-fat' | 'flights-climbed' | 'exercise-time' | 'distance-cycling' | 'mindfulness' | 'sleep' | 'sleep-rem' | 'hrv' | 'blood-pressure';
|
|
91
92
|
export interface PermissionsRequest {
|
|
92
93
|
permissions: HealthPermission[];
|
|
93
94
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * iOS only: Reads user characteristics such as biological sex, blood type, date of birth, Fitzpatrick skin type, and wheelchair use.\n * Values are null when unavailable or permission was not granted. Android does not expose these characteristics; it returns `platformSupported: false` and a `platformMessage` for UI hints without emitting null values.\n */\n getCharacteristics(): Promise<CharacteristicsResponse>;\n\n /**\n * Query aggregated data\n * - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.\n * - `total-calories` is derived as active + basal energy on both iOS and Android for latest samples, aggregated queries, and workouts. We fall back to the platform's total‑calories metric (or active calories) when basal data isn't available or permission is missing. Request both `READ_ACTIVE_CALORIES` and `READ_BASAL_CALORIES` for full totals.\n * - Weight/height aggregation returns the latest sample per day (no averaging).\n * - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.\n * - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).\n * - Daily `bucket: \"day\"` queries use calendar-day boundaries in the device time zone (start-of-day through the next start-of-day) instead of a trailing 24-hour window. For “today,” send `startDate` at today’s start-of-day and `endDate` at now or tomorrow’s start-of-day.\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * - Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.\n * @param request\n */\n queryLatestSample(request: { dataType: LatestDataType }): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest weight sample\n */\n queryWeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest height sample\n */\n queryHeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest heart rate sample\n */\n queryHeartRate(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest steps sample\n */\n querySteps(): Promise<QueryLatestSampleResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE'\n | 'READ_BASAL_CALORIES'\n | 'READ_RESPIRATORY_RATE'\n | 'READ_OXYGEN_SATURATION'\n | 'READ_BLOOD_GLUCOSE'\n | 'READ_BODY_TEMPERATURE'\n | 'READ_BASAL_BODY_TEMPERATURE'\n | 'READ_BODY_FAT'\n | 'READ_FLOORS_CLIMBED'\n | 'READ_SLEEP'\n | 'READ_EXERCISE_TIME'\n | 'READ_BIOLOGICAL_SEX'\n | 'READ_BLOOD_TYPE'\n | 'READ_DATE_OF_BIRTH'\n | 'READ_FITZPATRICK_SKIN_TYPE'\n | 'READ_WHEELCHAIR_USE';\n\nexport type LatestDataType =\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'mindfulness'\n | 'sleep'\n | 'hrv'\n | 'blood-pressure';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: Record<HealthPermission, boolean>;\n}\n\nexport interface QueryWorkoutRequest {\n startDate: string;\n endDate: string;\n includeHeartRate: boolean;\n includeRoute: boolean;\n includeSteps: boolean;\n}\n\nexport interface HeartRateSample {\n timestamp: string;\n bpm: number;\n}\n\nexport interface RouteSample {\n timestamp: string;\n lat: number;\n lng: number;\n alt?: number;\n}\n\nexport interface QueryWorkoutResponse {\n workouts: Workout[];\n}\n\nexport interface Workout {\n startDate: string;\n endDate: string;\n workoutType: string;\n sourceName: string;\n id?: string;\n duration: number;\n distance?: number;\n steps?: number;\n calories: number;\n sourceBundleId: string;\n route?: RouteSample[];\n heartRate?: HeartRateSample[];\n}\n\nexport interface QueryAggregatedRequest {\n startDate: string;\n endDate: string;\n dataType:\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'sleep'\n | 'mindfulness'\n | 'hrv'\n | 'blood-pressure';\n bucket: string;\n}\n\nexport interface QueryAggregatedResponse {\n aggregatedData: AggregatedSample[];\n}\n\nexport interface AggregatedSample {\n startDate: string;\n endDate: string;\n value: number;\n systolic?: number;\n diastolic?: number;\n unit?: string;\n}\n\nexport interface QueryLatestSampleResponse {\n value?: number;\n systolic?: number;\n diastolic?: number;\n timestamp: number;\n endTimestamp?: number;\n unit: string;\n metadata?: Record<string, unknown>;\n}\n\nexport interface CharacteristicsResponse {\n biologicalSex?: HealthBiologicalSex | null;\n bloodType?: HealthBloodType | null;\n dateOfBirth?: string | null;\n fitzpatrickSkinType?: HealthFitzpatrickSkinType | null;\n wheelchairUse?: HealthWheelchairUse | null;\n /**\n * Indicates whether the platform exposes these characteristics via the plugin (true on iOS, false on Android).\n */\n platformSupported?: boolean;\n /**\n * Optional platform-specific message; on Android we return a user-facing note explaining that values remain empty unless synced from iOS.\n */\n platformMessage?: string;\n}\n\nexport type HealthBiologicalSex = 'female' | 'male' | 'other' | 'not_set' | 'unknown';\n\nexport type HealthBloodType =\n | 'a-positive'\n | 'a-negative'\n | 'b-positive'\n | 'b-negative'\n | 'ab-positive'\n | 'ab-negative'\n | 'o-positive'\n | 'o-negative'\n | 'not_set'\n | 'unknown';\n\nexport type HealthFitzpatrickSkinType = 'type1' | 'type2' | 'type3' | 'type4' | 'type5' | 'type6' | 'not_set' | 'unknown';\n\nexport type HealthWheelchairUse = 'wheelchair_user' | 'not_wheelchair_user' | 'not_set' | 'unknown';\n"]}
|
|
1
|
+
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface HealthPlugin {\n /**\n * Checks if health API is available.\n * Android: If false is returned, the Google Health Connect app is probably not installed.\n * See showHealthConnectInPlayStore()\n *\n */\n isHealthAvailable(): Promise<{ available: boolean }>;\n\n /**\n * Android only: Returns for each given permission, if it was granted by the underlying health API\n * @param permissions permissions to query\n */\n checkHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Requests the permissions from the user.\n *\n * Android: Apps can ask only a few times for permissions, after that the user has to grant them manually in\n * the Health Connect app. See openHealthConnectSettings()\n *\n * iOS: If the permissions are already granted or denied, this method will just return without asking the user. In iOS\n * we can't really detect if a user granted or denied a permission. The return value reflects the assumption that all\n * permissions were granted.\n *\n * @param permissions permissions to request\n */\n requestHealthPermissions(permissions: PermissionsRequest): Promise<PermissionResponse>;\n\n /**\n * Opens the apps settings, which is kind of wrong, because health permissions are configured under:\n * Settings > Apps > (Apple) Health > Access and Devices > [app-name]\n * But we can't go there directly.\n */\n openAppleHealthSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app\n */\n openHealthConnectSettings(): Promise<void>;\n\n /**\n * Opens the Google Health Connect app in PlayStore\n */\n showHealthConnectInPlayStore(): Promise<void>;\n\n /**\n * iOS only: Reads user characteristics such as biological sex, blood type, date of birth, Fitzpatrick skin type, and wheelchair use.\n * Values are null when unavailable or permission was not granted. Android does not expose these characteristics; it returns `platformSupported: false` and a `platformMessage` for UI hints without emitting null values.\n */\n getCharacteristics(): Promise<CharacteristicsResponse>;\n\n /**\n * Query aggregated data\n * - Blood-pressure aggregates return the systolic average in `value` plus `systolic`, `diastolic`, and `unit`.\n * - `total-calories` is derived as active + basal energy on both iOS and Android for latest samples, aggregated queries, and workouts. We fall back to the platform's total‑calories metric (or active calories) when basal data isn't available or permission is missing. Request both `READ_ACTIVE_CALORIES` and `READ_BASAL_CALORIES` for full totals.\n * - Weight/height aggregation returns the latest sample per day (no averaging).\n * - Android aggregation currently supports daily buckets; unsupported buckets will be rejected.\n * - Android `distance-cycling` aggregates distance recorded during biking exercise sessions (requires distance + workouts permissions).\n * - Daily `bucket: \"day\"` queries use calendar-day boundaries in the device time zone (start-of-day through the next start-of-day) instead of a trailing 24-hour window. For “today,” send `startDate` at today’s start-of-day and `endDate` at now or tomorrow’s start-of-day.\n * @param request\n */\n queryAggregated(request: QueryAggregatedRequest): Promise<QueryAggregatedResponse>;\n\n /**\n * Query workouts\n * @param request\n */\n queryWorkouts(request: QueryWorkoutRequest): Promise<QueryWorkoutResponse>;\n\n /**\n * Query latest sample for a specific data type\n * - Latest sleep sample returns the most recent complete sleep session (asleep states only) from the last ~36 hours; if a longer overnight session exists, shorter naps are ignored.\n * - `sleep-rem` returns REM duration (minutes) for the latest sleep session; requires iOS 16+ sleep stages and Health Connect REM data on Android.\n * @param request\n */\n queryLatestSample(request: { dataType: LatestDataType }): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest weight sample\n */\n queryWeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest height sample\n */\n queryHeight(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest heart rate sample\n */\n queryHeartRate(): Promise<QueryLatestSampleResponse>;\n\n /**\n * Query latest steps sample\n */\n querySteps(): Promise<QueryLatestSampleResponse>;\n}\n\nexport declare type HealthPermission =\n | 'READ_STEPS'\n | 'READ_WORKOUTS'\n | 'READ_ACTIVE_CALORIES'\n | 'READ_TOTAL_CALORIES'\n | 'READ_DISTANCE'\n | 'READ_WEIGHT'\n | 'READ_HEIGHT'\n | 'READ_HEART_RATE'\n | 'READ_RESTING_HEART_RATE'\n | 'READ_ROUTE'\n | 'READ_MINDFULNESS'\n | 'READ_HRV'\n | 'READ_BLOOD_PRESSURE'\n | 'READ_BASAL_CALORIES'\n | 'READ_RESPIRATORY_RATE'\n | 'READ_OXYGEN_SATURATION'\n | 'READ_BLOOD_GLUCOSE'\n | 'READ_BODY_TEMPERATURE'\n | 'READ_BASAL_BODY_TEMPERATURE'\n | 'READ_BODY_FAT'\n | 'READ_FLOORS_CLIMBED'\n | 'READ_SLEEP'\n | 'READ_EXERCISE_TIME'\n | 'READ_BIOLOGICAL_SEX'\n | 'READ_BLOOD_TYPE'\n | 'READ_DATE_OF_BIRTH'\n | 'READ_FITZPATRICK_SKIN_TYPE'\n | 'READ_WHEELCHAIR_USE';\n\nexport type LatestDataType =\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'mindfulness'\n | 'sleep'\n | 'sleep-rem'\n | 'hrv'\n | 'blood-pressure';\n\nexport interface PermissionsRequest {\n permissions: HealthPermission[];\n}\n\nexport interface PermissionResponse {\n permissions: Record<HealthPermission, boolean>;\n}\n\nexport interface QueryWorkoutRequest {\n startDate: string;\n endDate: string;\n includeHeartRate: boolean;\n includeRoute: boolean;\n includeSteps: boolean;\n}\n\nexport interface HeartRateSample {\n timestamp: string;\n bpm: number;\n}\n\nexport interface RouteSample {\n timestamp: string;\n lat: number;\n lng: number;\n alt?: number;\n}\n\nexport interface QueryWorkoutResponse {\n workouts: Workout[];\n}\n\nexport interface Workout {\n startDate: string;\n endDate: string;\n workoutType: string;\n sourceName: string;\n id?: string;\n duration: number;\n distance?: number;\n steps?: number;\n calories: number;\n sourceBundleId: string;\n route?: RouteSample[];\n heartRate?: HeartRateSample[];\n}\n\nexport interface QueryAggregatedRequest {\n startDate: string;\n endDate: string;\n dataType:\n | 'steps'\n | 'active-calories'\n | 'total-calories'\n | 'basal-calories'\n | 'distance'\n | 'weight'\n | 'height'\n | 'heart-rate'\n | 'resting-heart-rate'\n | 'respiratory-rate'\n | 'oxygen-saturation'\n | 'blood-glucose'\n | 'body-temperature'\n | 'basal-body-temperature'\n | 'body-fat'\n | 'flights-climbed'\n | 'exercise-time'\n | 'distance-cycling'\n | 'sleep'\n | 'mindfulness'\n | 'hrv'\n | 'blood-pressure';\n bucket: string;\n}\n\nexport interface QueryAggregatedResponse {\n aggregatedData: AggregatedSample[];\n}\n\nexport interface AggregatedSample {\n startDate: string;\n endDate: string;\n value: number;\n systolic?: number;\n diastolic?: number;\n unit?: string;\n}\n\nexport interface QueryLatestSampleResponse {\n value?: number;\n systolic?: number;\n diastolic?: number;\n timestamp: number;\n endTimestamp?: number;\n unit: string;\n metadata?: Record<string, unknown>;\n}\n\nexport interface CharacteristicsResponse {\n biologicalSex?: HealthBiologicalSex | null;\n bloodType?: HealthBloodType | null;\n dateOfBirth?: string | null;\n fitzpatrickSkinType?: HealthFitzpatrickSkinType | null;\n wheelchairUse?: HealthWheelchairUse | null;\n /**\n * Indicates whether the platform exposes these characteristics via the plugin (true on iOS, false on Android).\n */\n platformSupported?: boolean;\n /**\n * Optional platform-specific message; on Android we return a user-facing note explaining that values remain empty unless synced from iOS.\n */\n platformMessage?: string;\n}\n\nexport type HealthBiologicalSex = 'female' | 'male' | 'other' | 'not_set' | 'unknown';\n\nexport type HealthBloodType =\n | 'a-positive'\n | 'a-negative'\n | 'b-positive'\n | 'b-negative'\n | 'ab-positive'\n | 'ab-negative'\n | 'o-positive'\n | 'o-negative'\n | 'not_set'\n | 'unknown';\n\nexport type HealthFitzpatrickSkinType = 'type1' | 'type2' | 'type3' | 'type4' | 'type5' | 'type6' | 'not_set' | 'unknown';\n\nexport type HealthWheelchairUse = 'wheelchair_user' | 'not_wheelchair_user' | 'not_set' | 'unknown';\n"]}
|
|
@@ -349,6 +349,113 @@ public class HealthPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
349
349
|
healthStore.execute(query)
|
|
350
350
|
return
|
|
351
351
|
}
|
|
352
|
+
// ---- Special handling for REM sleep sessions (category samples) ----
|
|
353
|
+
if dataTypeString == "sleep-rem" {
|
|
354
|
+
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
|
|
355
|
+
call.reject("Sleep type not available")
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let endDate = Date()
|
|
360
|
+
let startDate = Calendar.current.date(byAdding: .hour, value: -36, to: endDate) ?? endDate.addingTimeInterval(-36 * 3600)
|
|
361
|
+
let predicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: .strictEndDate)
|
|
362
|
+
|
|
363
|
+
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, error in
|
|
364
|
+
if let error = error {
|
|
365
|
+
call.reject("Error fetching latest REM sleep sample", "NO_SAMPLE", error)
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
guard let categorySamples = samples as? [HKCategorySample], !categorySamples.isEmpty else {
|
|
369
|
+
call.reject("No REM sleep sample found", "NO_SAMPLE")
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let asleepValues: Set<Int> = {
|
|
374
|
+
if #available(iOS 16.0, *) {
|
|
375
|
+
return Set([
|
|
376
|
+
HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue,
|
|
377
|
+
HKCategoryValueSleepAnalysis.asleepCore.rawValue,
|
|
378
|
+
HKCategoryValueSleepAnalysis.asleepDeep.rawValue,
|
|
379
|
+
HKCategoryValueSleepAnalysis.asleepREM.rawValue
|
|
380
|
+
])
|
|
381
|
+
} else {
|
|
382
|
+
return Set([HKCategoryValueSleepAnalysis.asleep.rawValue])
|
|
383
|
+
}
|
|
384
|
+
}()
|
|
385
|
+
|
|
386
|
+
let remValue: Int? = {
|
|
387
|
+
if #available(iOS 16.0, *) {
|
|
388
|
+
return HKCategoryValueSleepAnalysis.asleepREM.rawValue
|
|
389
|
+
}
|
|
390
|
+
return nil
|
|
391
|
+
}()
|
|
392
|
+
|
|
393
|
+
func isAsleep(_ value: Int) -> Bool {
|
|
394
|
+
if asleepValues.contains(value) {
|
|
395
|
+
return true
|
|
396
|
+
}
|
|
397
|
+
return value != HKCategoryValueSleepAnalysis.inBed.rawValue &&
|
|
398
|
+
value != HKCategoryValueSleepAnalysis.awake.rawValue
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let asleepSamples = categorySamples
|
|
402
|
+
.filter { isAsleep($0.value) }
|
|
403
|
+
.sorted { $0.startDate < $1.startDate }
|
|
404
|
+
|
|
405
|
+
guard !asleepSamples.isEmpty else {
|
|
406
|
+
call.reject("No REM sleep sample found", "NO_SAMPLE")
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let maxGap: TimeInterval = 90 * 60 // 90 minutes separates sessions
|
|
411
|
+
var sessions: [(start: Date, end: Date, duration: TimeInterval, remDuration: TimeInterval)] = []
|
|
412
|
+
var currentStart: Date?
|
|
413
|
+
var currentEnd: Date?
|
|
414
|
+
var currentDuration: TimeInterval = 0
|
|
415
|
+
var currentRemDuration: TimeInterval = 0
|
|
416
|
+
|
|
417
|
+
for sample in asleepSamples {
|
|
418
|
+
if let lastEnd = currentEnd, sample.startDate.timeIntervalSince(lastEnd) > maxGap {
|
|
419
|
+
sessions.append((start: currentStart ?? lastEnd, end: lastEnd, duration: currentDuration, remDuration: currentRemDuration))
|
|
420
|
+
currentStart = nil
|
|
421
|
+
currentEnd = nil
|
|
422
|
+
currentDuration = 0
|
|
423
|
+
currentRemDuration = 0
|
|
424
|
+
}
|
|
425
|
+
if currentStart == nil { currentStart = sample.startDate }
|
|
426
|
+
currentEnd = sample.endDate
|
|
427
|
+
let sampleDuration = sample.endDate.timeIntervalSince(sample.startDate)
|
|
428
|
+
currentDuration += sampleDuration
|
|
429
|
+
if let remValue = remValue, sample.value == remValue {
|
|
430
|
+
currentRemDuration += sampleDuration
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if let start = currentStart, let end = currentEnd {
|
|
434
|
+
sessions.append((start: start, end: end, duration: currentDuration, remDuration: currentRemDuration))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
guard !sessions.isEmpty else {
|
|
438
|
+
call.reject("No REM sleep sample found", "NO_SAMPLE")
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let minSessionDuration: TimeInterval = 3 * 3600 // prefer sessions 3h+
|
|
443
|
+
let preferredSession = sessions.reversed().first { $0.duration >= minSessionDuration } ?? sessions.last!
|
|
444
|
+
if preferredSession.remDuration <= 0 {
|
|
445
|
+
call.reject("No REM sleep sample found", "NO_SAMPLE")
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
call.resolve([
|
|
450
|
+
"value": preferredSession.remDuration / 60,
|
|
451
|
+
"timestamp": preferredSession.start.timeIntervalSince1970 * 1000,
|
|
452
|
+
"endTimestamp": preferredSession.end.timeIntervalSince1970 * 1000,
|
|
453
|
+
"unit": "min"
|
|
454
|
+
])
|
|
455
|
+
}
|
|
456
|
+
healthStore.execute(query)
|
|
457
|
+
return
|
|
458
|
+
}
|
|
352
459
|
// ---- Special handling for mindfulness sessions (category samples) ----
|
|
353
460
|
if dataTypeString == "mindfulness" {
|
|
354
461
|
guard let mindfulType = HKObjectType.categoryType(forIdentifier: .mindfulSession) else {
|
package/package.json
CHANGED
|
File without changes
|