diff --git a/.changeset/native-background-sync-v2.md b/.changeset/native-background-sync-v2.md new file mode 100644 index 00000000..90a474d7 --- /dev/null +++ b/.changeset/native-background-sync-v2.md @@ -0,0 +1,51 @@ +--- +"@kingstinct/react-native-healthkit": major +--- + +**Native background sync v2** — four sample kinds, configurable lookback, deterministic dispatch. + +This is a major-version bump to the background sync API introduced in the previous minor release. Breaking changes to `SyncTypeConfig`, `BackgroundSyncEndpoint`, and the `BackgroundDeliveryManager` → `NativeSyncEngine` dispatch contract. + +### What's new + +- **Four sample kinds** instead of the previous `cumulative: boolean`. `SyncTypeConfig.kind` now takes a string value of `'cumulativeQuantity' | 'discreteQuantity' | 'categorySample' | 'workout'`. The native engine dispatches per-kind query strategy: + - `cumulativeQuantity` — `HKStatisticsCollectionQuery` with `.cumulativeSum` (steps, distance, calories, exercise minutes, flights) + - `discreteQuantity` — `HKSampleQuery` for per-sample records (heart rate, HRV, weight, blood pressure, SpO2, body temperature) + - `categorySample` — `HKSampleQuery` on `HKCategoryType`, with explicit value mapping for sleep stages (`in_bed`, `awake`, `asleep_core`, `asleep_deep`, `asleep_rem`, `asleep_unspecified`; iOS 15 legacy `asleep` preserved; unknown raw values emitted as `unknown:` rather than silently dropped) + - `workout` — `HKSampleQuery` on `HKWorkoutType`, emits activity type, duration, distance, energy, and (on iOS 17+) average + max heart rate via `workout.statistics(for:)`. Falls back to deprecated totals on iOS 15–16. + +- **Configurable lookback** via `BackgroundSyncEndpoint.lookbackDays` (default `1` — today only, minimum-surprise for new consumers). Consumers that need Watch→iPhone eventual-consistency catch-up, offline-day recovery, or late-arriving correction picks can opt into higher values (e.g., `3`). + +- **Deterministic dispatch** — the `BackgroundDeliveryManager` no longer gates native sync on "JS bridge absent". Every observer wake runs the native engine, which batches all registered types of the triggered kind in a single HTTP POST. When the JS bridge is also alive (foreground), the `subscribeToObserverQuery` callback still fires in parallel so consumers can reactively refresh UI; any duplicate pushes should be handled by the consumer's backend (e.g., dedup by `recordId`). + +- **Swift concurrency** — internal rewrite uses `withThrowingTaskGroup` + `async let` to parallelize per-kind queries under a single 20-second deadline via `Task.sleep` + `Task.cancel`. `completionHandler()` is guaranteed to fire within the budget even on partial failure, avoiding iOS exponential backoff. + +### Migration + +```ts +// Before (v0.1) +const configs: SyncTypeConfig[] = [ + { identifier: '...', type: 'steps', unit: 'count', cumulative: true }, + { identifier: '...', type: 'heart_rate', unit: 'count/min', cumulative: false }, +]; +await configureBackgroundSync({ url, method, headers }, configs, frequency); + +// After (v0.2) +const configs: SyncTypeConfig[] = [ + { identifier: '...', type: 'steps', unit: 'count', kind: 'cumulativeQuantity' }, + { identifier: '...', type: 'heart_rate', unit: 'count/min', kind: 'discreteQuantity' }, + { identifier: 'HKCategoryTypeIdentifierSleepAnalysis', type: 'sleep', unit: '', kind: 'categorySample' }, + { identifier: 'HKWorkoutTypeIdentifier', type: 'workout', unit: '', kind: 'workout' }, +]; +await configureBackgroundSync( + { url, method, headers, lookbackDays: 1 }, // lookbackDays optional, default 1 + configs, + frequency, +); +``` + +### Internal notes for contributors + +- `NativeSyncEngine.swift` now exposes `syncKind(_:completion:)` as the primary entry point; the old `syncType(_:completion:)` is preserved as a back-compat wrapper. +- `BackgroundDeliveryManager.configure` now takes `registrations: [Registration]` (identifier + kind pairs) instead of `typeIdentifiers: [String]`. Persisted format changes from a string array to JSON-encoded registrations. +- Record shape gains optional fields per kind: `category` (categorySample), `workoutType`/`duration`/`distance`/`calories`/`averageHeartRate`/`maxHeartRate` (workout). diff --git a/README.md b/README.md index 144c61a7..0021ad09 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,127 @@ Example: // etc.. ``` +## Background Delivery & Native Sync + +HealthKit can wake your app in the background when new samples arrive, even +after the app has been terminated by the system. This library provides two +complementary APIs for that: + +| API | Mechanism | Best for | +|----------------------------|-------------------------------|--------------------------------------------------------------| +| `configureBackgroundTypes` | Observer queries, JS callback | Updating app state, caches, notifications when data arrives | +| `configureBackgroundSync` | Observer queries, native HTTP | Shipping data to your backend without the JS bridge | + +Pick `configureBackgroundTypes` if you want JS code to run on each wake (JS bridge boots, your listeners fire). Pick `configureBackgroundSync` if you just need to forward data to a server — it runs entirely in native Swift and works even when JS is unavailable (e.g. just after a cold wake from termination). + +The two are not mutually exclusive: you can use `configureBackgroundSync` for server forwarding and `subscribeToChanges` (foreground) for UI updates. + +### `configureBackgroundTypes` — observer-only + +Registers `HKObserverQuery` instances at AppDelegate time (before the JS bridge +boots) and enables background delivery. When samples arrive, events are queued +and flushed to your JS `subscribeToChanges` callback once the bridge connects. + +Use this when you want **JS-side logic** to run on each delivery event. + +```typescript +import { configureBackgroundTypes, UpdateFrequency } from '@kingstinct/react-native-healthkit' + +await configureBackgroundTypes( + ['HKQuantityTypeIdentifierStepCount', 'HKQuantityTypeIdentifierDistanceWalkingRunning'], + UpdateFrequency.immediate, +) +``` + +### `configureBackgroundSync` — native-first sync + +For apps that want to send data to a backend **without relying on the JS bridge** +(works even when the app is terminated), this registers observer queries and +runs a fully-native sync engine on each wake. The engine queries HealthKit for +today's data and POSTs to your configured HTTP endpoint. + +- Cumulative types (marked `cumulative: true`) use `HKStatisticsCollectionQuery` + with `.cumulativeSum` — Apple deduplicates across iPhone + Watch and returns + one correct total per day. +- Discrete types use `HKSampleQuery` — individual samples with their HK UUIDs. +- You define the translation via `type` and `unit` fields so records arrive in + whatever format your backend expects. + +```typescript +import { configureBackgroundSync, UpdateFrequency } from '@kingstinct/react-native-healthkit' + +await configureBackgroundSync( + { + url: 'https://api.example.com/ingest', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ', + }, + }, + [ + { identifier: 'HKQuantityTypeIdentifierStepCount', type: 'steps', unit: 'count', cumulative: true }, + { identifier: 'HKQuantityTypeIdentifierHeartRate', type: 'heart_rate', unit: 'count/min', cumulative: false }, + ], + UpdateFrequency.immediate, +) +``` + +Body sent to your endpoint is a JSON object with a `records` array: + +```json +{ + "records": [ + { + "type": "steps", + "value": 8318, + "unit": "count", + "startTime": "2026-04-11T22:00:00.000Z", + "endTime": "2026-04-12T22:00:00.000Z", + "recordId": "steps-2026-04-12", + "frequency": "daily" + } + ] +} +``` + +Per-record fields: + +| Field | Description | +|------------|-----------------------------------------------------------------------------| +| `type` | The `type` string you provided in `SyncTypeConfig` | +| `value` | Quantity sample value (in the unit you configured) or category sample rawValue | +| `unit` | The `unit` string you provided in `SyncTypeConfig` (omitted for category samples) | +| `startTime`/`endTime` | ISO 8601 timestamps | +| `recordId` | HKSample UUID for discrete types; `"{type}-{YYYY-MM-DD}"` for cumulative aggregates (stable for backend deduplication) | +| `frequency`| `"realtime"` for per-sample records, `"daily"` for cumulative daily totals | +| `workoutActivityType` | HKWorkoutActivityType rawValue (workouts only) | +| `duration` | Workout duration in seconds (workouts only) | + +Call `clearBackgroundSync()` to stop observers and clear stored credentials. + +**Important behaviors:** +- **No retries on failure.** If your endpoint is unreachable, the sync event is dropped. iOS will fire observers again on the next HealthKit change. Ensure your endpoint is highly available; don't rely on this for reconciliation. +- **Today-only window.** Each sync sends only the current day's data. For multi-day backfill, use the foreground API (`queryQuantitySamples`, `queryStatisticsCollectionForQuantity`, etc.) and your own sync loop when the app is open. +- **~15 second budget per sync.** iOS gives the app ~30s of background execution; the engine enforces a 20s hard timeout internally (5s buffer for iOS). If your endpoint takes longer, syncs are terminated. Optimize for low-latency ingestion. + +**Requirements:** +- `com.apple.developer.healthkit.background-delivery` entitlement (handled by + the Expo plugin with `background: true`, which is the default). +- The companion pod `ReactNativeHealthkitBackground` is automatically linked + via `pod install` — it contains `BackgroundDeliveryManager.swift` and + `NativeSyncEngine.swift`, and is free of NitroModules/C++ dependencies so + AppDelegate can safely import it on any React Native version. + +**Design notes:** +- User force-quit (swipe up in app switcher) does **not** disable HealthKit + background delivery — the HealthKit daemon has its own launch registry + separate from the general iOS scheduler. +- iOS gives ~30 seconds of background execution per wake; the native sync + engine uses a 20-second hard timeout to stay within budget. +- Native sync only handles today's data — HealthKit + iOS already queue wakes + for offline/catch-up scenarios, so there's no need for multi-day backfill. + ## Migration to 9.0.0 There are a lot of under-the-hood changes in version 9.0.0, some of them are breaking (although I've tried to reduce it as much as possible). diff --git a/packages/react-native-healthkit/ReactNativeHealthkit.podspec b/packages/react-native-healthkit/ReactNativeHealthkit.podspec index 0b4e52ad..f7125c02 100644 --- a/packages/react-native-healthkit/ReactNativeHealthkit.podspec +++ b/packages/react-native-healthkit/ReactNativeHealthkit.podspec @@ -22,6 +22,22 @@ Pod::Spec.new do |s| "cpp/**/*.{hpp,cpp}", ] + # The companion ReactNativeHealthkitBackground pod owns its own copy of a + # lightweight HKUnit NSException catcher (BackgroundHKUnitCatcher). It + # can't depend on this pod (would pull in NitroModules, defeating the + # point) and two pods defining the same C function symbol would cause a + # duplicate-symbol linker error. Exclude the companion-only helper here. + s.exclude_files = [ + "ios/BackgroundHKUnitCatcher.h", + "ios/BackgroundHKUnitCatcher.mm", + # The companion pod compiles these files — excluding them from the main pod + # prevents duplicate @objc singletons (BackgroundDeliveryManager.shared, + # NativeSyncEngine.shared) which cause competing HKHealthStore instances + # and DispatchQueue lock contention at launch. + "ios/BackgroundDeliveryManager.swift", + "ios/NativeSyncEngine.swift", + ] + s.public_header_files = "ios/**/*.h" s.pod_target_xcconfig = { diff --git a/packages/react-native-healthkit/ReactNativeHealthkitBackground.podspec b/packages/react-native-healthkit/ReactNativeHealthkitBackground.podspec new file mode 100644 index 00000000..f243f8c4 --- /dev/null +++ b/packages/react-native-healthkit/ReactNativeHealthkitBackground.podspec @@ -0,0 +1,44 @@ +# Companion pod for HealthKit background delivery + native sync. +# Contains BackgroundDeliveryManager + NativeSyncEngine — no NitroModules, no C++ headers. +# Safe to import from AppDelegate on any RN version. + +Pod::Spec.new do |s| + s.name = "ReactNativeHealthkitBackground" + s.version = "13.4.0" + s.summary = "HealthKit background delivery and native sync for React Native" + s.homepage = "https://github.com/kingstinct/react-native-healthkit" + s.license = "MIT" + s.author = "Robert Herber" + s.source = { :git => "https://github.com/kingstinct/react-native-healthkit.git" } + s.ios.deployment_target = "13.0" + + s.source_files = [ + "ios/BackgroundDeliveryManager.swift", + "ios/NativeSyncEngine.swift", + # Companion-local ObjC helper (NSException catcher for HKUnit parsing). + # The main pod's podspec excludes these filenames explicitly to avoid a + # duplicate C-symbol linker error. + "ios/BackgroundHKUnitCatcher.h", + "ios/BackgroundHKUnitCatcher.mm", + ] + # Expose the ObjC helper's header to the pod's Swift module so + # NativeSyncEngine.swift can call BGSafeHKUnitFromString without a manual + # bridging header. CocoaPods puts listed public headers into the auto- + # generated umbrella + modulemap. + s.public_header_files = "ios/BackgroundHKUnitCatcher.h" + + s.frameworks = "HealthKit" + + s.pod_target_xcconfig = { + "DEFINES_MODULE" => "YES", + "SWIFT_VERSION" => "5.0", + # The ObjC header #imports which is a framework + # import — framework modules disallow non-modular includes by default. + "CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES" => "YES", + # NativeSyncEngine.swift is compiled by both the main pod and this + # companion pod. Each pod has its own ObjC NSException catcher with a + # distinct symbol name to avoid duplicate-symbol linker errors. This + # flag lets NativeSyncEngine pick the right one via #if. + "SWIFT_ACTIVE_COMPILATION_CONDITIONS" => "$(inherited) HEALTHKIT_BACKGROUND_POD", + } +end diff --git a/packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift b/packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift index 912a5ed2..ae1fe4c5 100644 --- a/packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift +++ b/packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift @@ -1,17 +1,19 @@ import Foundation import HealthKit -/// Manages HealthKit background delivery by registering observer queries at app launch, -/// before the JS bridge is available. This is required by Apple — observer queries must -/// be set up in `application(_:didFinishLaunchingWithOptions:)` to receive background +/// Manages HealthKit background delivery by registering observer queries at +/// app launch, before the JS bridge is available. This is required by +/// Apple — observer queries must be set up in +/// `application(_:didFinishLaunchingWithOptions:)` to receive background /// delivery callbacks after the app has been terminated. /// /// Usage from AppDelegate.swift: /// BackgroundDeliveryManager.shared.setupBackgroundObservers() /// -/// The types to observe are persisted in UserDefaults by `configureBackgroundTypes()` -/// called from JS. On subsequent cold launches, the manager reads these and registers -/// observers immediately, queuing any events until JS subscribes via `drainPendingEvents()`. +/// The registrations (identifier + kind pairs) are persisted in UserDefaults +/// by `configure(registrations:frequency:)` called from JS via +/// `CoreModule.configureBackgroundSync`. On subsequent cold launches the +/// manager reads them and registers observers immediately. @objc public class BackgroundDeliveryManager: NSObject { @objc public static let shared = BackgroundDeliveryManager() @@ -22,40 +24,61 @@ import HealthKit private var jsCallback: ((String, String?) -> Void)? private var isSetUp = false - static let typesKey = "com.kingstinct.healthkit.backgroundTypes" + static let registrationsKey = "com.kingstinct.healthkit.backgroundRegistrations" static let frequencyKey = "com.kingstinct.healthkit.backgroundFrequency" + /// Identifier + kind pair persisted for every registered type. Kind is + /// stored as the raw string value of `SyncKind` so the on-disk format + /// survives Nitro regenerations. + public struct Registration: Codable { + public let identifier: String + public let kind: String + + public init(identifier: String, kind: String) { + self.identifier = identifier + self.kind = kind + } + } + private override init() { super.init() } - /// Call this from AppDelegate.didFinishLaunchingWithOptions to register observer queries - /// for any previously configured background delivery types. + /// Call this from AppDelegate.didFinishLaunchingWithOptions to register + /// observer queries for any previously configured background delivery + /// types. @objc public func setupBackgroundObservers() { guard HKHealthStore.isHealthDataAvailable() else { return } - guard let typeIdentifiers = UserDefaults.standard.stringArray(forKey: BackgroundDeliveryManager.typesKey) else { + guard let data = UserDefaults.standard.data(forKey: BackgroundDeliveryManager.registrationsKey), + let registrations = try? JSONDecoder().decode([Registration].self, from: data) + else { return } let frequencyRaw = UserDefaults.standard.integer(forKey: BackgroundDeliveryManager.frequencyKey) let frequency = HKUpdateFrequency(rawValue: frequencyRaw) ?? .immediate - registerObservers(typeIdentifiers: typeIdentifiers, frequency: frequency) + registerObservers(registrations: registrations, frequency: frequency) } - /// Persist types and frequency, then register observers for the current session. - /// Called from JS via CoreModule.configureBackgroundTypes(). - func configure(typeIdentifiers: [String], frequency: HKUpdateFrequency) { - UserDefaults.standard.set(typeIdentifiers, forKey: BackgroundDeliveryManager.typesKey) + /// Persist registrations and frequency, then register observers for the + /// current session. Called from JS via + /// `CoreModule.configureBackgroundSync`. + func configure(registrations: [Registration], frequency: HKUpdateFrequency) { + if let data = try? JSONEncoder().encode(registrations) { + UserDefaults.standard.set(data, forKey: BackgroundDeliveryManager.registrationsKey) + } UserDefaults.standard.set(frequency.rawValue, forKey: BackgroundDeliveryManager.frequencyKey) - // Tear down existing observers before re-registering tearDown() - registerObservers(typeIdentifiers: typeIdentifiers, frequency: frequency) + registerObservers(registrations: registrations, frequency: frequency) } - /// Subscribe a JS callback. Any events that arrived before JS was ready are flushed immediately. + /// Subscribe a JS callback for reactive foreground updates. Any events + /// that arrived before JS was ready are flushed immediately. Note: the + /// callback runs alongside the native sync path — both fire in the + /// foreground, backend dedup absorbs overlap. func setCallback(_ callback: @escaping (String, String?) -> Void) { queue.sync(flags: .barrier) { self.jsCallback = callback @@ -75,8 +98,9 @@ import HealthKit } } - /// Returns any pending events and clears the queue. Used by CoreModule.subscribeToObserverQuery - /// to flush events that arrived before JS subscribed. + /// Returns any pending events and clears the queue. Used by + /// CoreModule.subscribeToObserverQuery to flush events that arrived + /// before JS subscribed. func drainPendingEvents() -> [(typeIdentifier: String, errorMessage: String?)] { return queue.sync(flags: .barrier) { let events = self.pendingEvents @@ -98,59 +122,70 @@ import HealthKit /// Clear persisted configuration (disables background delivery on next launch). func clearConfiguration() { - UserDefaults.standard.removeObject(forKey: BackgroundDeliveryManager.typesKey) + UserDefaults.standard.removeObject(forKey: BackgroundDeliveryManager.registrationsKey) UserDefaults.standard.removeObject(forKey: BackgroundDeliveryManager.frequencyKey) tearDown() } - private func registerObservers(typeIdentifiers: [String], frequency: HKUpdateFrequency) { + private func registerObservers(registrations: [Registration], frequency: HKUpdateFrequency) { queue.sync(flags: .barrier) { guard !self.isSetUp else { return } self.isSetUp = true } - for typeIdentifier in typeIdentifiers { - guard let sampleType = sampleTypeFromString(typeIdentifier) else { - print("[react-native-healthkit] BackgroundDeliveryManager: skipping unrecognized type \(typeIdentifier)") + for reg in registrations { + guard let sampleType = BackgroundDeliveryManager.sampleTypeFromString(reg.identifier) else { + print("[react-native-healthkit] BackgroundDeliveryManager: skipping unrecognized type \(reg.identifier)") continue } - // Use nil predicate to catch all samples, including those written while the app was terminated. - // The current subscribeToObserverQuery uses Date.init() which misses data from when the app was dead. + // Capture kind so the observer callback knows which set of registered + // types to batch-query on fire. + let kind = reg.kind + + // nil predicate catches all samples including those written while the + // app was terminated. let query = HKObserverQuery( sampleType: sampleType, predicate: nil ) { [weak self] (_: HKObserverQuery, completionHandler: @escaping HKObserverQueryCompletionHandler, error: Error?) in - self?.handleObserverCallback( - typeIdentifier: typeIdentifier, - error: error - ) - // Must call the completion handler promptly so iOS knows we processed the update. - completionHandler() + guard let self = self else { completionHandler(); return } + + // Always run native sync — deterministic, no race with JS boot. + // The callback runs on a HealthKit-owned background queue. + NativeSyncEngine.shared.syncKind(kind) { + completionHandler() + } + + // In parallel, notify any subscribed JS listener for reactive + // foreground UI. This intentionally runs alongside the native sync; + // backend dedup handles any duplicate pushes. + self.notifyJs(typeIdentifier: reg.identifier, error: error) } healthStore.execute(query) healthStore.enableBackgroundDelivery(for: sampleType, frequency: frequency) { success, error in if let error = error { - print("[react-native-healthkit] BackgroundDeliveryManager: enableBackgroundDelivery failed for \(typeIdentifier): \(error.localizedDescription)") + print("[react-native-healthkit] BackgroundDeliveryManager: enableBackgroundDelivery failed for \(reg.identifier): \(error.localizedDescription)") } else if !success { - print("[react-native-healthkit] BackgroundDeliveryManager: enableBackgroundDelivery returned false for \(typeIdentifier)") + print("[react-native-healthkit] BackgroundDeliveryManager: enableBackgroundDelivery returned false for \(reg.identifier)") } } queue.sync(flags: .barrier) { - self.observerQueries[typeIdentifier] = query + self.observerQueries[reg.identifier] = query } } } - private func handleObserverCallback(typeIdentifier: String, error: Error?) { + /// Notify any subscribed JS callback (used for reactive foreground UI). + /// Runs independently of the native sync path — both fire concurrently. + private func notifyJs(typeIdentifier: String, error: Error?) { let errorMessage = error?.localizedDescription queue.sync(flags: .barrier) { if let callback = self.jsCallback { - // JS is connected — dispatch to main thread for JSI safety DispatchQueue.main.async { callback(typeIdentifier, errorMessage) } @@ -161,9 +196,11 @@ import HealthKit } } - // Local type resolution that doesn't depend on NitroModules (which isn't available at AppDelegate time). - // Uses the older factory APIs (quantityType(forIdentifier:) etc.) for iOS 13+ compatibility. - private func sampleTypeFromString(_ identifier: String) -> HKSampleType? { + // Local type resolution that doesn't depend on NitroModules (which isn't + // available at AppDelegate time). Uses the older factory APIs + // (quantityType(forIdentifier:) etc.) for iOS 13+ compatibility. Static so + // NativeSyncEngine can also resolve types without NitroModules. + static func sampleTypeFromString(_ identifier: String) -> HKSampleType? { if identifier.starts(with: "HKQuantityTypeIdentifier") { let typeId = HKQuantityTypeIdentifier(rawValue: identifier) return HKSampleType.quantityType(forIdentifier: typeId) diff --git a/packages/react-native-healthkit/ios/BackgroundHKUnitCatcher.h b/packages/react-native-healthkit/ios/BackgroundHKUnitCatcher.h new file mode 100644 index 00000000..31215abc --- /dev/null +++ b/packages/react-native-healthkit/ios/BackgroundHKUnitCatcher.h @@ -0,0 +1,29 @@ +// +// BackgroundHKUnitCatcher.h +// +// Companion-pod-only mirror of the main pod's ExceptionCatcher. Kept under +// a subdirectory (ios/background-internal/) and excluded from the main +// pod's source_files so the same C function symbol isn't defined twice +// — linkers treat duplicate C symbols across static libraries as an error. +// +// The companion pod cannot depend on the main pod (which would pull in +// NitroModules / C++ interop and defeat the point of a lightweight +// background pod safe for AppDelegate imports), so this file duplicates +// the logic under a different function name. +// + +#import +#import + +#ifdef __cplusplus +extern "C" { +#endif + +/// Safely parse an HKUnit string without letting Apple's NSException bubble +/// out. Returns `nil` on invalid strings and (if `outError` non-null) +/// captures the exception name + userInfo. +HKUnit * _Nullable BGSafeHKUnitFromString(NSString * _Nonnull unitString, NSError * _Nullable * _Nullable outError); + +#ifdef __cplusplus +} +#endif diff --git a/packages/react-native-healthkit/ios/BackgroundHKUnitCatcher.mm b/packages/react-native-healthkit/ios/BackgroundHKUnitCatcher.mm new file mode 100644 index 00000000..7900b69e --- /dev/null +++ b/packages/react-native-healthkit/ios/BackgroundHKUnitCatcher.mm @@ -0,0 +1,23 @@ +// +// BackgroundHKUnitCatcher.mm +// +// See header for rationale — companion-pod-local NSException catcher for +// HKUnit parsing. Function name distinct from the main pod's +// HKUnitFromStringCatchingExceptions so both pods can link cleanly. +// + +#import "BackgroundHKUnitCatcher.h" + +HKUnit * _Nullable BGSafeHKUnitFromString(NSString * _Nonnull unitString, NSError * _Nullable * _Nullable outError) { + if (outError) { *outError = nil; } + @try { + return [HKUnit unitFromString:unitString]; + } + @catch (NSException *exception) { + if (outError) { + NSDictionary *userInfo = exception.userInfo ?: @{}; + *outError = [NSError errorWithDomain:exception.name code:0 userInfo:userInfo]; + } + return nil; + } +} diff --git a/packages/react-native-healthkit/ios/CoreModule.swift b/packages/react-native-healthkit/ios/CoreModule.swift index 9ff6e3aa..bd4064a6 100644 --- a/packages/react-native-healthkit/ios/CoreModule.swift +++ b/packages/react-native-healthkit/ios/CoreModule.swift @@ -430,16 +430,78 @@ class CoreModule: HybridCoreModuleSpec { } } - func configureBackgroundTypes( - typeIdentifiers: [String], updateFrequency: UpdateFrequency + func configureBackgroundSync( + endpoint: BackgroundSyncEndpoint, + typeConfigs: [SyncTypeConfig], + updateFrequency: UpdateFrequency ) -> Promise { return Promise.async { + // Validate inputs up-front so misconfiguration surfaces at setup time + // rather than silently failing on every background wake. guard let frequency = HKUpdateFrequency(rawValue: Int(updateFrequency.rawValue)) else { throw runtimeErrorWithPrefix("Invalid update frequency rawValue: \(updateFrequency)") } + guard URL(string: endpoint.url) != nil else { + throw runtimeErrorWithPrefix("Invalid endpoint URL: \(endpoint.url)") + } + let allowedMethods = ["POST", "PUT", "PATCH"] + guard allowedMethods.contains(endpoint.method.uppercased()) else { + throw runtimeErrorWithPrefix("Unsupported HTTP method '\(endpoint.method)' — must be one of \(allowedMethods.joined(separator: ", "))") + } + guard !typeConfigs.isEmpty else { + throw runtimeErrorWithPrefix("typeConfigs must not be empty") + } + + // Validate HKUnit parseability at setup time so consumers see the error + // synchronously in `configureBackgroundSync` rather than silently + // failing on every background wake (HKUnit(from:) raises an ObjC + // NSException on invalid strings — uncatchable by Swift). + for config in typeConfigs { + let effectiveUnit = config.hkUnit ?? config.unit + // Category and workout kinds don't need a unit — skip validation. + if config.kind == .categorysample || config.kind == .workout { + continue + } + if HKUnitFromStringCatchingExceptions(effectiveUnit, nil) == nil { + throw runtimeErrorWithPrefix( + "Invalid HKUnit string '\(effectiveUnit)' for type '\(config.type)' — provide SyncTypeConfig.hkUnit with Apple's factorization grammar (e.g. 'count/min' instead of 'bpm', '%' instead of 'percent', 'mL' instead of 'ml')" + ) + } + } + // 1. Store endpoint + type config in UserDefaults (NativeSyncEngine reads this) + let nativeConfigs = typeConfigs.map { + NativeSyncEngine.TypeConfig( + identifier: $0.identifier, + type: $0.type, + unit: $0.unit, + hkUnit: $0.hkUnit, + kind: $0.kind.stringValue + ) + } + let lookback = endpoint.lookbackDays.map { Int($0) } + let nativeEndpoint = NativeSyncEngine.EndpointConfig( + url: endpoint.url, + method: endpoint.method, + headers: endpoint.headers, + lookbackDays: lookback + ) + try NativeSyncEngine.writeConfig( + endpoint: nativeEndpoint, + typeConfigs: nativeConfigs + ) + + // 2. Register observer queries + enable background delivery. + // One observer per distinct kind (not per type) — see + // BackgroundDeliveryManager for the rationale. + let registrations = typeConfigs.map { + BackgroundDeliveryManager.Registration( + identifier: $0.identifier, + kind: $0.kind.stringValue + ) + } BackgroundDeliveryManager.shared.configure( - typeIdentifiers: typeIdentifiers, + registrations: registrations, frequency: frequency ) @@ -447,9 +509,10 @@ class CoreModule: HybridCoreModuleSpec { } } - func clearBackgroundTypes() -> Promise { + func clearBackgroundSync() -> Promise { return Promise.async { BackgroundDeliveryManager.shared.clearConfiguration() + NativeSyncEngine.clearConfig() return true } } diff --git a/packages/react-native-healthkit/ios/NativeSyncEngine.swift b/packages/react-native-healthkit/ios/NativeSyncEngine.swift new file mode 100644 index 00000000..cf07b6df --- /dev/null +++ b/packages/react-native-healthkit/ios/NativeSyncEngine.swift @@ -0,0 +1,784 @@ +import Foundation +import HealthKit + +/// Native sync engine for HealthKit background delivery. +/// +/// When a HealthKit observer fires and the JS bridge isn't available (app +/// terminated), this engine queries HealthKit for recent data and POSTs +/// records to the configured endpoint. Records use the consumer-provided +/// `type` and `unit` strings from SyncTypeConfig — the library is generic, +/// the format is consumer-defined. +/// +/// ## Record shape (uniform across kinds) +/// +/// { +/// "type": "", +/// "startTime": "", +/// "endTime": "", +/// "recordId": "", +/// "frequency": "realtime" | "daily", +/// // Kind-specific extras: +/// "value": , // cumulativeQuantity / discreteQuantity +/// "unit": "", // cumulativeQuantity / discreteQuantity +/// "category": "", // categorySample (e.g. "asleep_deep") +/// "workoutType": "", // workout +/// "duration": , // workout +/// "distance": { "value": m, "unit": "m" }, +/// "calories": { "value": kcal, "unit": "kcal" }, +/// "averageHeartRate": , +/// "maxHeartRate": , +/// } +/// +/// ## Performance +/// +/// - Queries of different kinds run in parallel via `withThrowingTaskGroup`. +/// - Hard 20s deadline (iOS kills background work at ~30s). If we hit it, +/// the task group is cancelled and we call the caller's completion +/// immediately — iOS never sees a missed completion. +/// - Each HTTP POST has its own 15s timeout inside that budget. +@objc public class NativeSyncEngine: NSObject { + @objc public static let shared = NativeSyncEngine() + + private let healthStore = HKHealthStore() + + static let endpointKey = "com.kingstinct.healthkit.sync.endpoint" + static let typeConfigsKey = "com.kingstinct.healthkit.sync.typeConfigs" + static let breadcrumbsKey = "com.kingstinct.healthkit.sync.clientFailures" + + private static let httpTimeoutSeconds: TimeInterval = 15 + private static let syncBudgetSeconds: UInt64 = 20 + /// Max breadcrumbs retained in UserDefaults. Oldest evicted on overflow. + /// Keeps persistent storage bounded even if the device is offline for days. + private static let maxBreadcrumbs = 50 + + private let dateFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private let localDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + // Use the current calendar/timezone at format time — computed per-record. + return f + }() + + private override init() { + super.init() + } + + // MARK: - Configuration + + struct EndpointConfig: Codable { + let url: String + let method: String + let headers: [String: String] + /// How many days of history to re-query on every wake. Defaults to 1 + /// (today only) when absent from storage. + let lookbackDays: Int? + } + + struct TypeConfig: Codable { + let identifier: String + let type: String + /// Unit emitted on output records — the consumer's wire format. + let unit: String + /// HealthKit factorization-grammar unit used for `HKUnit(from:)`. Falls + /// back to `unit` when absent (which is fine when both formats align, + /// e.g. `count`, `kg`, `m`). Decoded as optional to stay compatible with + /// configs persisted by earlier fork versions that didn't emit this. + let hkUnit: String? + /// Raw string value of `SyncKind` — persisted as a String so the on-disk + /// format survives Nitro regenerations without risking an enum rawValue + /// drift. + let kind: String + + /// The unit string to hand to `HKUnit(from:)`. Prefers `hkUnit` when + /// set; otherwise returns `unit`. + var effectiveHKUnit: String { hkUnit ?? unit } + } + + /// Parse an HKUnit string safely — delegates to the pod-appropriate + /// NSException catcher (BGSafeHKUnitFromString in the companion pod, + /// HKUnitFromStringCatchingExceptions in the main pod). Returns nil on + /// invalid strings without raising an ObjC NSException. + private static func safeHKUnit(from string: String) -> HKUnit? { + #if HEALTHKIT_BACKGROUND_POD + return BGSafeHKUnitFromString(string, nil) + #else + return HKUnitFromStringCatchingExceptions(string, nil) + #endif + } + + // MARK: - Sample metadata serializers + + /// Serialize HKDevice to a dict. Returns nil if device is nil or empty. + private static func serializeDevice(_ device: HKDevice?) -> [String: String]? { + guard let d = device else { return nil } + var dict = [String: String]() + if let name = d.name { dict["name"] = name } + if let manufacturer = d.manufacturer { dict["manufacturer"] = manufacturer } + if let model = d.model { dict["model"] = model } + if let hardwareVersion = d.hardwareVersion { dict["hardwareVersion"] = hardwareVersion } + if let softwareVersion = d.softwareVersion { dict["softwareVersion"] = softwareVersion } + return dict.isEmpty ? nil : dict + } + + /// Serialize HKSourceRevision to a dict. + private static func serializeSource(_ sr: HKSourceRevision) -> [String: String] { + var dict: [String: String] = [ + "name": sr.source.name, + "bundleIdentifier": sr.source.bundleIdentifier, + ] + if let version = sr.version { dict["version"] = version } + return dict + } + + /// Shared ISO8601 formatter for static serializers (metadata dates). + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + /// Serialize sample metadata, converting known non-JSON types to strings. + private static func serializeMetadata(_ metadata: [String: Any]?) -> [String: Any]? { + guard let meta = metadata, !meta.isEmpty else { return nil } + var dict = [String: Any]() + for (key, value) in meta { + switch value { + case let date as Date: + dict[key] = isoFormatter.string(from: date) + case let quantity as HKQuantity: + dict[key] = quantity.description + default: + dict[key] = value + } + } + return dict.isEmpty ? nil : dict + } + + /// Enrich a record dict with device, source, and metadata from an HKSample. + private static func enrichRecord(_ record: inout [String: Any], from sample: HKSample) { + if let device = serializeDevice(sample.device) { + record["device"] = device + } + record["source"] = serializeSource(sample.sourceRevision) + if let metadata = serializeMetadata(sample.metadata) { + record["metadata"] = metadata + } + } + + struct SyncConfig { + let endpoint: EndpointConfig + let typeConfigs: [TypeConfig] + } + + /// One entry per local POST failure. Persisted across wakes and flushed to + /// the consumer's backend on the next successful push via a + /// `clientFailuresSince` field in the request body. Gives consumers + /// visibility into native-path HTTP failures that otherwise never reach + /// their backend logs at all. + struct Breadcrumb: Codable { + let timestamp: String // ISO8601 + let reason: String + let path: String // "native" + } + + func readConfig() -> SyncConfig? { + let defaults = UserDefaults.standard + guard let endpointData = defaults.data(forKey: NativeSyncEngine.endpointKey), + let configData = defaults.data(forKey: NativeSyncEngine.typeConfigsKey) + else { return nil } + + guard let endpoint = try? JSONDecoder().decode(EndpointConfig.self, from: endpointData), + let typeConfigs = try? JSONDecoder().decode([TypeConfig].self, from: configData) + else { return nil } + + return SyncConfig(endpoint: endpoint, typeConfigs: typeConfigs) + } + + static func writeConfig(endpoint: EndpointConfig, typeConfigs: [TypeConfig]) throws { + // Encode first (fail fast) before writing to UserDefaults. If encoding + // fails we'd rather surface the error to the caller than silently skip + // the write and fail every subsequent background wake. + let endpointData = try JSONEncoder().encode(endpoint) + let configData = try JSONEncoder().encode(typeConfigs) + let defaults = UserDefaults.standard + defaults.set(endpointData, forKey: endpointKey) + defaults.set(configData, forKey: typeConfigsKey) + } + + static func clearConfig() { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: endpointKey) + defaults.removeObject(forKey: typeConfigsKey) + } + + // MARK: - Entry points + + /// Called by `BackgroundDeliveryManager` when an observer for a given kind + /// fires. Queries all registered types of that kind in parallel, POSTs the + /// combined records, and calls `completion` exactly once within the budget. + @objc public func syncKind(_ kindRawValue: String, completion: @escaping () -> Void) { + guard let config = readConfig() else { completion(); return } + let matching = config.typeConfigs.filter { $0.kind == kindRawValue } + guard !matching.isEmpty else { completion(); return } + + syncWithDeadline(configs: matching, endpoint: config.endpoint, completion: completion) + } + + /// Convenience: sync every registered type regardless of kind. Used when a + /// consumer only wants a single observer fanning out to everything. + @objc public func syncAll(completion: @escaping () -> Void) { + guard let config = readConfig(), !config.typeConfigs.isEmpty else { + completion(); return + } + syncWithDeadline(configs: config.typeConfigs, endpoint: config.endpoint, completion: completion) + } + + /// Back-compat wrapper — the old API dispatched per-identifier. Maps to + /// the new "all registered types of that identifier's kind" behavior so + /// callers upgrading from v0.1 don't need to change immediately. + @objc public func syncType(_ typeIdentifier: String, completion: @escaping () -> Void) { + guard let config = readConfig(), + let match = config.typeConfigs.first(where: { $0.identifier == typeIdentifier }) + else { completion(); return } + syncKind(match.kind, completion: completion) + } + + // MARK: - Task orchestration + + /// Runs `runSync` against a 20s deadline. On deadline the task is cancelled + /// and `completion` is called so iOS doesn't see a missed callback. + private func syncWithDeadline( + configs: [TypeConfig], + endpoint: EndpointConfig, + completion: @escaping () -> Void + ) { + let completedFlag = CompletedFlag() + + func finishOnce() { + if completedFlag.setDone() { + completion() + } + } + + let work = Task { [weak self] in + guard let self = self else { finishOnce(); return } + do { + try await self.runSync(configs: configs, endpoint: endpoint) + } catch { + print("[react-native-healthkit] NativeSyncEngine: sync failed: \(error.localizedDescription)") + } + finishOnce() + } + + // Hard deadline watchdog. We can't use Task.sleep(for:) on iOS 13–15 + // without availability gating, so we use the nanosecond overload. + Task { + try? await Task.sleep(nanoseconds: NativeSyncEngine.syncBudgetSeconds * 1_000_000_000) + if !completedFlag.isDone { + print("[react-native-healthkit] NativeSyncEngine: hit 20s budget, cancelling") + work.cancel() + } + finishOnce() + } + } + + /// Fan-out query work by kind, then POST everything in a single request. + private func runSync(configs: [TypeConfig], endpoint: EndpointConfig) async throws { + let lookback = max(1, endpoint.lookbackDays ?? 1) + let since: Date = { + let cal = Calendar.current + let startOfToday = cal.startOfDay(for: Date()) + return cal.date(byAdding: .day, value: -(lookback - 1), to: startOfToday) ?? startOfToday + }() + + // Group configs by kind so we can dispatch appropriately. + var byKind: [String: [TypeConfig]] = [:] + for c in configs { + byKind[c.kind, default: []].append(c) + } + + var records: [[String: Any]] = [] + try await withThrowingTaskGroup(of: [[String: Any]].self) { group in + if let cumulative = byKind["cumulativeQuantity"] { + group.addTask { try await self.syncCumulative(cumulative, since: since) } + } + if let discrete = byKind["discreteQuantity"] { + group.addTask { try await self.syncDiscrete(discrete, since: since) } + } + if let category = byKind["categorySample"] { + group.addTask { try await self.syncCategory(category, since: since) } + } + if let workout = byKind["workout"] { + group.addTask { try await self.syncWorkout(workout, since: since) } + } + for try await subset in group { + records.append(contentsOf: subset) + } + } + + // Inject timezone + local date into every record. These are identical + // across records in a single sync, so we compute once. Optional on the + // wire — backends that don't need them ignore the fields. + let localeFields = Self.currentLocaleFields(formatter: localDateFormatter) + let enriched: [[String: Any]] = records.map { r in + var m = r + for (k, v) in localeFields { m[k] = v } + return m + } + + guard !enriched.isEmpty else { return } + try Task.checkCancellation() + + // Flush any persisted client-failure breadcrumbs with this successful + // push, then clear. On failure, append a new breadcrumb so the next + // successful push surfaces it to the backend. + let pending = Self.loadBreadcrumbs() + do { + try await sendRecords(enriched, endpoint: endpoint, breadcrumbs: pending) + if !pending.isEmpty { + Self.clearBreadcrumbs() + } + } catch { + Self.appendBreadcrumb(reason: error.localizedDescription, iso: dateFormatter) + throw error + } + } + + /// Produces timezone/localDate fields matching the consumer's optional + /// locale schema. Applied to every outgoing record so backends can bucket + /// by the user's local day regardless of UTC offset. + private static func currentLocaleFields(formatter: DateFormatter) -> [String: Any] { + let tz = TimeZone.current + let now = Date() + // Use a fresh formatter-local copy to avoid mutating shared state. + let f = formatter + f.timeZone = tz + return [ + "timeZone": tz.identifier, + "timeZoneOffsetMinutes": tz.secondsFromGMT(for: now) / 60, + "localDate": f.string(from: now), + ] + } + + // MARK: - Cumulative + + private func syncCumulative(_ configs: [TypeConfig], since: Date) async throws -> [[String: Any]] { + try await withThrowingTaskGroup(of: [[String: Any]].self) { group in + for config in configs { + group.addTask { try await self.queryCumulative(config, since: since) } + } + var all: [[String: Any]] = [] + for try await part in group { + all.append(contentsOf: part) + } + return all + } + } + + /// `HKStatisticsCollectionQuery` with `.cumulativeSum` — Apple deduplicates + /// across sources (iPhone + Watch + 3rd party apps) and returns one total + /// per daily bucket. + private func queryCumulative(_ config: TypeConfig, since: Date) async throws -> [[String: Any]] { + guard let quantityType = BackgroundDeliveryManager.sampleTypeFromString(config.identifier) + as? HKQuantityType + else { return [] } + // configureBackgroundSync validates `effectiveHKUnit` is parseable at + // setup time; this catcher is a belt-and-braces guard in case the + // persisted UserDefaults config predates the validation (e.g. upgrades + // across breaking fork versions). + guard let hkUnit = Self.safeHKUnit(from: config.effectiveHKUnit) else { + print("[react-native-healthkit] NativeSyncEngine: invalid HKUnit '\(config.effectiveHKUnit)' for type '\(config.type)' at query time — skipping (consumer should re-run configureBackgroundSync)") + return [] + } + + return try await withCheckedThrowingContinuation { cont in + let interval = DateComponents(day: 1) + let predicate = HKQuery.predicateForSamples(withStart: since, end: nil) + + let query = HKStatisticsCollectionQuery( + quantityType: quantityType, + quantitySamplePredicate: predicate, + options: .cumulativeSum, + anchorDate: since, + intervalComponents: interval + ) + query.initialResultsHandler = { _, results, error in + if let error = error { cont.resume(throwing: error); return } + guard let results = results else { cont.resume(returning: []); return } + + var records: [[String: Any]] = [] + results.enumerateStatistics(from: since, to: Date()) { stats, _ in + guard let sum = stats.sumQuantity() else { return } + let dateKey = Self.dateKey(for: stats.startDate) + records.append([ + "type": config.type, + "value": sum.doubleValue(for: hkUnit), + "unit": config.unit, + "startTime": self.dateFormatter.string(from: stats.startDate), + "endTime": self.dateFormatter.string(from: stats.endDate), + "recordId": "\(config.type)-\(dateKey)", + "frequency": "daily", + ]) + } + cont.resume(returning: records) + } + healthStore.execute(query) + } + } + + // MARK: - Discrete quantity + + private func syncDiscrete(_ configs: [TypeConfig], since: Date) async throws -> [[String: Any]] { + try await withThrowingTaskGroup(of: [[String: Any]].self) { group in + for config in configs { + group.addTask { try await self.queryDiscrete(config, since: since) } + } + var all: [[String: Any]] = [] + for try await part in group { + all.append(contentsOf: part) + } + return all + } + } + + private func queryDiscrete(_ config: TypeConfig, since: Date) async throws -> [[String: Any]] { + guard let sampleType = BackgroundDeliveryManager.sampleTypeFromString(config.identifier) + as? HKQuantityType + else { return [] } + guard let hkUnit = Self.safeHKUnit(from: config.effectiveHKUnit) else { + print("[react-native-healthkit] NativeSyncEngine: invalid HKUnit '\(config.effectiveHKUnit)' for type '\(config.type)' at query time — skipping (consumer should re-run configureBackgroundSync)") + return [] + } + + return try await withCheckedThrowingContinuation { cont in + let predicate = HKQuery.predicateForSamples(withStart: since, end: nil) + + let query = HKSampleQuery( + sampleType: sampleType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: nil + ) { _, samples, error in + if let error = error { cont.resume(throwing: error); return } + let records: [[String: Any]] = (samples ?? []).compactMap { sample in + guard let q = sample as? HKQuantitySample else { return nil } + var record: [String: Any] = [ + "type": config.type, + "value": q.quantity.doubleValue(for: hkUnit), + "unit": config.unit, + "startTime": self.dateFormatter.string(from: q.startDate), + "endTime": self.dateFormatter.string(from: q.endDate), + "recordId": q.uuid.uuidString, + "frequency": "realtime", + ] + Self.enrichRecord(&record, from: q) + return record + } + cont.resume(returning: records) + } + healthStore.execute(query) + } + } + + // MARK: - Category (sleep etc.) + + private func syncCategory(_ configs: [TypeConfig], since: Date) async throws -> [[String: Any]] { + try await withThrowingTaskGroup(of: [[String: Any]].self) { group in + for config in configs { + group.addTask { try await self.queryCategory(config, since: since) } + } + var all: [[String: Any]] = [] + for try await part in group { + all.append(contentsOf: part) + } + return all + } + } + + private func queryCategory(_ config: TypeConfig, since: Date) async throws -> [[String: Any]] { + guard let sampleType = BackgroundDeliveryManager.sampleTypeFromString(config.identifier) + as? HKCategoryType + else { return [] } + + return try await withCheckedThrowingContinuation { cont in + let predicate = HKQuery.predicateForSamples(withStart: since, end: nil) + + let query = HKSampleQuery( + sampleType: sampleType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: nil + ) { _, samples, error in + if let error = error { cont.resume(throwing: error); return } + let isSleep = sampleType.identifier == HKCategoryTypeIdentifier.sleepAnalysis.rawValue + + let records: [[String: Any]] = (samples ?? []).compactMap { sample in + guard let c = sample as? HKCategorySample else { return nil } + var record: [String: Any] = [ + "type": config.type, + "startTime": self.dateFormatter.string(from: c.startDate), + "endTime": self.dateFormatter.string(from: c.endDate), + "recordId": c.uuid.uuidString, + "frequency": "realtime", + ] + // Emit a human-readable value for sleep (handles iOS 15/16/17+). + // For other category types the raw Int is included as a fallback. + if isSleep { + record["category"] = Self.mapSleepValue(c.value) + } else { + record["value"] = c.value + } + Self.enrichRecord(&record, from: c) + return record + } + cont.resume(returning: records) + } + healthStore.execute(query) + } + } + + // MARK: - Workout + + private func syncWorkout(_ configs: [TypeConfig], since: Date) async throws -> [[String: Any]] { + // All workout configs share the same HKWorkoutType — run the query once + // and emit a record per config (usually just one). + let iso = dateFormatter + return try await withCheckedThrowingContinuation { cont in + let predicate = HKQuery.predicateForSamples(withStart: since, end: nil) + + let query = HKSampleQuery( + sampleType: HKObjectType.workoutType(), + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: nil + ) { _, samples, error in + if let error = error { cont.resume(throwing: error); return } + let workouts = (samples ?? []).compactMap { $0 as? HKWorkout } + var records: [[String: Any]] = [] + for config in configs { + for w in workouts { + records.append(Self.buildWorkoutRecord(w, config: config, iso: iso)) + } + } + cont.resume(returning: records) + } + healthStore.execute(query) + } + } + + private static func buildWorkoutRecord( + _ w: HKWorkout, + config: TypeConfig, + iso: ISO8601DateFormatter + ) -> [String: Any] { + var record: [String: Any] = [ + "type": config.type, + "startTime": iso.string(from: w.startDate), + "endTime": iso.string(from: w.endDate), + "recordId": w.uuid.uuidString, + "frequency": "realtime", + "workoutType": "\(w.workoutActivityType.rawValue)", + "duration": w.duration, + ] + + if let distance = workoutDistance(w) { + record["distance"] = ["value": distance, "unit": "m"] + } + if let energy = workoutEnergy(w) { + record["calories"] = ["value": energy, "unit": "kcal"] + } + if let avg = workoutAverageHR(w) { + record["averageHeartRate"] = avg + } + if let max = workoutMaxHR(w) { + record["maxHeartRate"] = max + } + enrichRecord(&record, from: w) + return record + } + + // MARK: - Workout field accessors (iOS 17 deprecation) + + private static func workoutDistance(_ w: HKWorkout) -> Double? { + if #available(iOS 17.0, *) { + return w.statistics(for: HKQuantityType(.distanceWalkingRunning))? + .sumQuantity()?.doubleValue(for: .meter()) + } else { + return w.totalDistance?.doubleValue(for: .meter()) + } + } + + private static func workoutEnergy(_ w: HKWorkout) -> Double? { + if #available(iOS 17.0, *) { + return w.statistics(for: HKQuantityType(.activeEnergyBurned))? + .sumQuantity()?.doubleValue(for: .kilocalorie()) + } else { + return w.totalEnergyBurned?.doubleValue(for: .kilocalorie()) + } + } + + private static func workoutAverageHR(_ w: HKWorkout) -> Double? { + if #available(iOS 17.0, *) { + return w.statistics(for: HKQuantityType(.heartRate))? + .averageQuantity()?.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) + } else { + return nil // Not exposed on pre-iOS 17 HKWorkout + } + } + + private static func workoutMaxHR(_ w: HKWorkout) -> Double? { + if #available(iOS 17.0, *) { + return w.statistics(for: HKQuantityType(.heartRate))? + .maximumQuantity()?.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) + } else { + return nil + } + } + + // MARK: - Sleep value mapping + + /// Maps `HKCategoryValueSleepAnalysis` raw values to stable string keys. + /// Switches on raw `Int` rather than the enum so we sidestep Swift's + /// version-aware exhaustiveness checks when iOS 16+ values are present. + /// Covers iOS 15 (inBed/asleep/awake), iOS 16+ (asleepCore/Deep/REM/ + /// Unspecified), and emits `unknown:` for future Apple additions. + static func mapSleepValue(_ rawValue: Int) -> String { + switch rawValue { + case HKCategoryValueSleepAnalysis.inBed.rawValue: + return "in_bed" + case HKCategoryValueSleepAnalysis.awake.rawValue: + return "awake" + case 1: + // Raw value 1 is `.asleep` on iOS 15 and `.asleepUnspecified` on + // iOS 16+ (same numeric value, just renamed). Using the literal + // avoids the deprecation warning for `.asleep` on modern SDKs. + return "asleep_unspecified" + default: + if #available(iOS 16.0, *) { + switch rawValue { + case HKCategoryValueSleepAnalysis.asleepCore.rawValue: + return "asleep_core" + case HKCategoryValueSleepAnalysis.asleepDeep.rawValue: + return "asleep_deep" + case HKCategoryValueSleepAnalysis.asleepREM.rawValue: + return "asleep_rem" + case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue: + return "asleep_unspecified" + default: break + } + } + print("[react-native-healthkit] NativeSyncEngine: unmapped sleep rawValue=\(rawValue)") + return "unknown:\(rawValue)" + } + } + + // MARK: - Helpers + + private static func dateKey(for date: Date) -> String { + let cal = Calendar.current + let y = cal.component(.year, from: date) + let m = String(format: "%02d", cal.component(.month, from: date)) + let d = String(format: "%02d", cal.component(.day, from: date)) + return "\(y)-\(m)-\(d)" + } + + // MARK: - HTTP + + /// Send records as JSON to the configured endpoint. The body shape is + /// `{ records: [...], clientFailuresSince?: [...] }` — a wrapper object + /// that's easier for consumer backends to extend. `clientFailuresSince` + /// is only included when there are persisted breadcrumbs to flush. + private func sendRecords( + _ records: [[String: Any]], + endpoint: EndpointConfig, + breadcrumbs: [Breadcrumb] + ) async throws { + var body: [String: Any] = ["records": records] + if !breadcrumbs.isEmpty { + body["clientFailuresSince"] = breadcrumbs.map { + ["timestamp": $0.timestamp, "reason": $0.reason, "path": $0.path] + } + } + guard let url = URL(string: endpoint.url) else { return } + let jsonData = try JSONSerialization.data(withJSONObject: body) + + var request = URLRequest(url: url) + request.httpMethod = endpoint.method + request.timeoutInterval = NativeSyncEngine.httpTimeoutSeconds + request.httpBody = jsonData + + let hasContentType = endpoint.headers.keys.contains { $0.lowercased() == "content-type" } + if !hasContentType { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + for (key, value) in endpoint.headers { + request.setValue(value, forHTTPHeaderField: key) + } + + let (_, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw NSError( + domain: "com.kingstinct.healthkit.sync", + code: http.statusCode, + userInfo: [NSLocalizedDescriptionKey: "HTTP \(http.statusCode)"] + ) + } + } + + // MARK: - Breadcrumbs + + static func loadBreadcrumbs() -> [Breadcrumb] { + guard let data = UserDefaults.standard.data(forKey: breadcrumbsKey), + let decoded = try? JSONDecoder().decode([Breadcrumb].self, from: data) + else { return [] } + return decoded + } + + static func appendBreadcrumb(reason: String, iso: ISO8601DateFormatter) { + let entry = Breadcrumb( + timestamp: iso.string(from: Date()), + reason: reason, + path: "native" + ) + var existing = loadBreadcrumbs() + existing.append(entry) + // Cap — oldest evicted. + if existing.count > maxBreadcrumbs { + existing = Array(existing.suffix(maxBreadcrumbs)) + } + if let encoded = try? JSONEncoder().encode(existing) { + UserDefaults.standard.set(encoded, forKey: breadcrumbsKey) + } + } + + static func clearBreadcrumbs() { + UserDefaults.standard.removeObject(forKey: breadcrumbsKey) + } +} + +// MARK: - CompletedFlag helper + +/// Thread-safe boolean flag — ensures `completion` fires exactly once even +/// when the work task and the deadline watchdog race. +private final class CompletedFlag { + private var done = false + private let lock = NSLock() + + var isDone: Bool { + lock.lock(); defer { lock.unlock() } + return done + } + + /// Returns `true` the first time called, `false` thereafter. + func setDone() -> Bool { + lock.lock(); defer { lock.unlock() } + if done { return false } + done = true + return true + } +} diff --git a/packages/react-native-healthkit/package.json b/packages/react-native-healthkit/package.json index 60e4e0c0..fa6f996c 100644 --- a/packages/react-native-healthkit/package.json +++ b/packages/react-native-healthkit/package.json @@ -1,6 +1,6 @@ { "name": "@kingstinct/react-native-healthkit", - "version": "14.0.0", + "version": "14.1.0-beta.8", "description": "React Native bindings for HealthKit", "main": "lib/commonjs/index.js", "module": "lib/module/index.js", diff --git a/packages/react-native-healthkit/src/healthkit.ios.ts b/packages/react-native-healthkit/src/healthkit.ios.ts index d55c45a1..f75078b3 100644 --- a/packages/react-native-healthkit/src/healthkit.ios.ts +++ b/packages/react-native-healthkit/src/healthkit.ios.ts @@ -305,8 +305,8 @@ export const disableAllBackgroundDelivery = export const disableBackgroundDelivery = Core.disableBackgroundDelivery.bind(Core) export const enableBackgroundDelivery = Core.enableBackgroundDelivery.bind(Core) -export const configureBackgroundTypes = Core.configureBackgroundTypes.bind(Core) -export const clearBackgroundTypes = Core.clearBackgroundTypes.bind(Core) +export const configureBackgroundSync = Core.configureBackgroundSync.bind(Core) +export const clearBackgroundSync = Core.clearBackgroundSync.bind(Core) export const getBiologicalSex = Characteristics.getBiologicalSex.bind(Characteristics) export const getBloodType = Characteristics.getBloodType.bind(Characteristics) @@ -410,8 +410,8 @@ export default { areObjectTypesAvailable, areObjectTypesAvailableAsync, isQuantityCompatibleWithUnit, - configureBackgroundTypes, - clearBackgroundTypes, + configureBackgroundSync, + clearBackgroundSync, disableAllBackgroundDelivery, disableBackgroundDelivery, enableBackgroundDelivery, diff --git a/packages/react-native-healthkit/src/healthkit.ts b/packages/react-native-healthkit/src/healthkit.ts index 11df17f4..bb4e23d9 100644 --- a/packages/react-native-healthkit/src/healthkit.ts +++ b/packages/react-native-healthkit/src/healthkit.ts @@ -91,12 +91,12 @@ export const enableBackgroundDelivery = UnavailableFnFromModule( 'enableBackgroundDelivery', Promise.resolve(false), ) -export const configureBackgroundTypes = UnavailableFnFromModule( - 'configureBackgroundTypes', +export const configureBackgroundSync = UnavailableFnFromModule( + 'configureBackgroundSync', Promise.resolve(false), ) -export const clearBackgroundTypes = UnavailableFnFromModule( - 'clearBackgroundTypes', +export const clearBackgroundSync = UnavailableFnFromModule( + 'clearBackgroundSync', Promise.resolve(false), ) export const getPreferredUnits = UnavailableFnFromModule( @@ -587,8 +587,8 @@ const HealthkitModule = { areObjectTypesAvailable, areObjectTypesAvailableAsync, isQuantityCompatibleWithUnit, - configureBackgroundTypes, - clearBackgroundTypes, + configureBackgroundSync, + clearBackgroundSync, disableAllBackgroundDelivery, disableBackgroundDelivery, enableBackgroundDelivery, diff --git a/packages/react-native-healthkit/src/specs/CoreModule.nitro.ts b/packages/react-native-healthkit/src/specs/CoreModule.nitro.ts index efc43f9e..f8acad85 100644 --- a/packages/react-native-healthkit/src/specs/CoreModule.nitro.ts +++ b/packages/react-native-healthkit/src/specs/CoreModule.nitro.ts @@ -3,7 +3,11 @@ import type { AuthorizationRequestStatus, AuthorizationStatus, } from '../types/Auth' -import type { UpdateFrequency } from '../types/Background' +import type { + BackgroundSyncEndpoint, + SyncTypeConfig, + UpdateFrequency, +} from '../types/Background' import type { QuantityTypeIdentifier } from '../types/QuantityTypeIdentifier' import type { FilterForSamples } from '../types/QueryOptions' import type { @@ -41,24 +45,31 @@ export interface CoreModule extends HybridObject<{ ios: 'swift' }> { disableAllBackgroundDelivery(): Promise /** - * Configure background delivery types that will be registered natively in - * AppDelegate.didFinishLaunchingWithOptions — surviving app termination. - * Types and frequency are persisted to UserDefaults so they're available - * before the JS bridge boots on subsequent cold launches. + * Configure native background sync. Stores endpoint config and type mappings + * in UserDefaults, then registers HealthKit observer queries and enables + * background delivery. + * + * When HealthKit data changes and the JS bridge isn't available (app terminated), + * NativeSyncEngine queries HealthKit for today's data in the triggered type + * and sends it to the configured endpoint. + * + * Native sync only handles today — HealthKit + iOS already queue wakes for + * offline/catch-up scenarios. For multi-day backfill, use the foreground JS sync. * - * Requires the Expo config plugin with `background: true` (default) or - * manual AppDelegate setup: `BackgroundDeliveryManager.shared.setupBackgroundObservers()` + * @param endpoint - HTTP endpoint to send data to (url, method, headers) + * @param typeConfigs - HealthKit identifier → type name + unit mapping + * @param updateFrequency - HealthKit delivery frequency cap */ - configureBackgroundTypes( - typeIdentifiers: string[], + configureBackgroundSync( + endpoint: BackgroundSyncEndpoint, + typeConfigs: SyncTypeConfig[], updateFrequency: UpdateFrequency, ): Promise /** - * Clear persisted background delivery configuration and stop all observer queries. - * After calling this, the app will no longer register observers on cold launch. + * Clear all native background sync configuration and stop observer queries. */ - clearBackgroundTypes(): Promise + clearBackgroundSync(): Promise /** * @see {@link https://developer.apple.com/documentation/healthkit/hkhealthstore/1614180-ishealthdataavailable Apple Docs } diff --git a/packages/react-native-healthkit/src/test-setup.ts b/packages/react-native-healthkit/src/test-setup.ts index 159434d3..e6453dd9 100644 --- a/packages/react-native-healthkit/src/test-setup.ts +++ b/packages/react-native-healthkit/src/test-setup.ts @@ -14,8 +14,8 @@ const mockModule = { disableAllBackgroundDelivery: jest.fn(), disableBackgroundDelivery: jest.fn(), enableBackgroundDelivery: jest.fn(), - configureBackgroundTypes: jest.fn(), - clearBackgroundTypes: jest.fn(), + configureBackgroundSync: jest.fn(), + clearBackgroundSync: jest.fn(), queryCategorySamplesWithAnchor: jest.fn(), queryQuantitySamplesWithAnchor: jest.fn(), getBiologicalSex: jest.fn(), diff --git a/packages/react-native-healthkit/src/types/Background.ts b/packages/react-native-healthkit/src/types/Background.ts index 75bdd89d..a5eb4897 100644 --- a/packages/react-native-healthkit/src/types/Background.ts +++ b/packages/react-native-healthkit/src/types/Background.ts @@ -7,3 +7,228 @@ export enum UpdateFrequency { daily = 3, weekly = 4, } + +/** + * HealthKit sample kind — tells the native sync engine which query pattern + * and record shape to use for a given identifier. + * + * - `cumulativeQuantity`: `HKQuantityType` aggregated via + * `HKStatisticsCollectionQuery` with `.cumulativeSum` and emitted as one + * per-day bucket. Apple deduplicates across sources (iPhone + Watch + 3rd + * party apps). Use for steps, distance, energy, exercise minutes, flights. + * - `discreteQuantity`: `HKQuantityType` sampled directly via + * `HKSampleQuery`. Each sample becomes one record. Use for heart rate, + * HRV, weight, body temperature, blood pressure, SpO2. + * - `categorySample`: `HKCategoryType` sampled via `HKSampleQuery`. Each + * segment becomes one record; the category value is emitted as a string + * name (e.g. `asleep_core`). Use for sleep analysis, mindful sessions. + * - `workout`: `HKWorkoutType` queried via `HKSampleQuery`. Each workout + * becomes one record with summary fields (activity type, duration, + * distance, energy, HR stats). Uses iOS 17+ `statistics(for:)` when + * available, falls back to deprecated totals on iOS 15–16. + * + * Declared as a TypeScript union (not `enum`) so nitrogen generates a + * string-backed C++ enum that bridges to a proper Swift enum. + */ +export type SyncKind = + | 'cumulativeQuantity' + | 'discreteQuantity' + | 'categorySample' + | 'workout' + +/** + * Type configuration for native background sync. + * + * Maps a HealthKit identifier (what the library observes/queries) to the + * `type` name and `unit` string the consumer's backend expects in the output. + * The library is generic — it emits records using whatever translation the + * consumer provides. + * + * - `identifier`: HKQuantityTypeIdentifier / HKCategoryTypeIdentifier / HKWorkoutTypeIdentifier + * - `type`: consumer's canonical type name (e.g. "steps"). Included verbatim in output records. + * - `unit`: HealthKit unit string (e.g. "count", "m", "kcal"). Used both for + * querying HKUnit and as the record's `unit` field. Pass empty string for + * kinds where unit doesn't apply (`categorySample`, `workout`). + * - `kind`: query pattern + record shape — see {@link SyncKind}. + * + * @example + * // Cumulative — steps, distance, active energy, exercise minutes, flights + * { identifier: 'HKQuantityTypeIdentifierStepCount', type: 'steps', unit: 'count', kind: 'cumulativeQuantity' } + * + * // Discrete — heart rate, HRV, weight, body temperature, blood pressure + * { identifier: 'HKQuantityTypeIdentifierHeartRate', type: 'heart_rate', unit: 'count/min', kind: 'discreteQuantity' } + * + * // Category — sleep analysis (value becomes `asleep_core`, `asleep_deep`, etc.) + * { identifier: 'HKCategoryTypeIdentifierSleepAnalysis', type: 'sleep', unit: '', kind: 'categorySample' } + * + * // Workout — emits activity type, duration, distance, energy, HR stats + * { identifier: 'HKWorkoutTypeIdentifier', type: 'workout', unit: '', kind: 'workout' } + */ +export interface SyncTypeConfig { + readonly identifier: string + readonly type: string + /** + * Unit string emitted verbatim on output records — whatever the consumer's + * backend expects in its wire schema (e.g. `bpm`, `percent`, `ml`). + */ + readonly unit: string + /** + * HealthKit unit string in Apple's factorization grammar — used for + * `HKUnit(from:)` when querying the HealthKit store. Examples: `count/min`, + * `%`, `mL`, `kcal`, `ms`. Provide this when `unit` (the wire format) is + * not a valid HKUnit string. When omitted, the library falls back to + * `unit`. An invalid `hkUnit` causes `configureBackgroundSync` to throw + * a descriptive error at setup time rather than crashing on a background + * wake. + */ + readonly hkUnit?: string + readonly kind: SyncKind +} + +// --------------------------------------------------------------------------- +// Output record types — the JSON shapes POSTed to the consumer's endpoint. +// These are documentation-only (the native engine builds the JSON in Swift); +// they are exported so consumers can type their backend request handlers. +// --------------------------------------------------------------------------- + +/** + * Device that produced the sample (from `HKDevice`). + * Present on discrete, category, and workout records. + * `null` when HealthKit doesn't associate a device with the sample. + */ +export interface SyncRecordDevice { + readonly name?: string + readonly manufacturer?: string + readonly model?: string + readonly hardwareVersion?: string + readonly softwareVersion?: string +} + +/** + * Source app/revision that wrote the sample (from `HKSourceRevision`). + * Present on discrete, category, and workout records. + */ +export interface SyncRecordSource { + readonly name: string + readonly bundleIdentifier: string + readonly version?: string +} + +/** + * Fields common to every output record regardless of kind. + */ +export interface SyncRecordBase { + readonly type: string + readonly startTime: string + readonly endTime: string + readonly recordId: string + /** `"daily"` for cumulative aggregates, `"realtime"` for per-sample records. */ + readonly frequency: 'daily' | 'realtime' + /** IANA timezone identifier, e.g. `"Europe/Stockholm"`. */ + readonly timeZone: string + /** UTC offset in minutes at sync time. */ + readonly timeZoneOffsetMinutes: number + /** ISO 8601 date string in the user's local timezone. */ + readonly localDate: string +} + +/** + * Cumulative record — one per day per type. + * Produced by `HKStatisticsCollectionQuery` with `.cumulativeSum`. + * Apple deduplicates across sources (iPhone + Watch + 3rd-party). + * + * **No device/source/metadata** — `HKStatistics` is an aggregate with no + * per-sample provenance. + */ +export interface SyncRecordCumulative extends SyncRecordBase { + readonly value: number + readonly unit: string + readonly frequency: 'daily' +} + +/** + * Discrete quantity record — one per sample. + * Produced by `HKSampleQuery` on `HKQuantityType`. + * Includes device, source, and metadata from the original `HKQuantitySample`. + */ +export interface SyncRecordDiscrete extends SyncRecordBase { + readonly value: number + readonly unit: string + readonly frequency: 'realtime' + readonly device?: SyncRecordDevice + readonly source?: SyncRecordSource + readonly metadata?: Record +} + +/** + * Category record — one per sample (e.g. each sleep segment). + * Produced by `HKSampleQuery` on `HKCategoryType`. + * + * For sleep analysis the `category` field contains a human-readable stage name + * (`in_bed`, `awake`, `asleep_core`, `asleep_deep`, `asleep_rem`, + * `asleep_unspecified`, or `unknown:` for future Apple values). + * For other category types the raw integer `value` is included instead. + */ +export interface SyncRecordCategory extends SyncRecordBase { + readonly frequency: 'realtime' + readonly category?: string + readonly value?: number + readonly device?: SyncRecordDevice + readonly source?: SyncRecordSource + readonly metadata?: Record +} + +/** + * Workout record — one per `HKWorkout` sample. + * Includes summary stats, and on iOS 17+ average/max heart rate via + * `workout.statistics(for:)`. + */ +export interface SyncRecordWorkout extends SyncRecordBase { + readonly frequency: 'realtime' + readonly workoutType: string + readonly duration: number + readonly distance?: { readonly value: number; readonly unit: 'm' } + readonly calories?: { readonly value: number; readonly unit: 'kcal' } + readonly averageHeartRate?: number + readonly maxHeartRate?: number + readonly device?: SyncRecordDevice + readonly source?: SyncRecordSource + readonly metadata?: Record +} + +/** Union of all output record types. */ +export type SyncRecord = + | SyncRecordCumulative + | SyncRecordDiscrete + | SyncRecordCategory + | SyncRecordWorkout + +/** Shape of the HTTP request body sent to the consumer's endpoint. */ +export interface SyncRequestBody { + readonly records: readonly SyncRecord[] +} + +/** + * HTTP endpoint configuration for native background sync. + * The library sends HealthKit data as JSON to this endpoint — no assumptions + * about auth scheme, API paths, or backend implementation. + * + * Body shape: {@link SyncRequestBody} — `{ records: [...] }` where each + * record is a {@link SyncRecord}. Cumulative records contain only core fields + * (type, value, unit, timestamps). Discrete, category, and workout records + * additionally include `device`, `source`, and `metadata` from the underlying + * `HKSample` — see {@link SyncRecordDiscrete}, {@link SyncRecordCategory}, + * {@link SyncRecordWorkout}. + * + * - `lookbackDays`: how many days of history to re-query on every observer + * wake. Default `1` (today only — minimal work, minimal wire traffic). Set + * higher when the consumer needs catch-up for late Watch-to-iPhone sync, + * offline days, or data correction — typical values are 1–7. Backends + * should deduplicate on `recordId` since days get re-sent. + */ +export interface BackgroundSyncEndpoint { + readonly url: string + readonly method: string + readonly headers: Record + readonly lookbackDays?: number +}