From b2ffc2c2d0abe2eba73b96d03f7c929667405486 Mon Sep 17 00:00:00 2001 From: Oakleaf Date: Tue, 14 Apr 2026 09:12:11 +0200 Subject: [PATCH 1/3] feat(background-sync): native sync engine v2 Rewrite the native HealthKit sync engine with: - Four sample-kind handlers (cumulativeQuantity, discreteQuantity, categorySample, workout) replacing the old cumulative-only path - Configurable lookback window (consumer sets lookbackDays; default 1) - Swift concurrency with 20s deadline per observer wake - Sleep category value mapping with @unknown default + raw-value fallback - iOS 17+ workout.statistics(for:) with graceful pre-17 fallback - Deterministic native-first dispatch: native sync always runs on wake, JS callback fires in parallel for reactive foreground UI - Native breadcrumb channel persisted in UserDefaults, flushed to the consumer's backend on next successful push via clientFailuresSince - Every record carries timeZone (IANA), timeZoneOffsetMinutes, localDate BackgroundDeliveryManager registers one observer per distinct kind (not per type) and stores registrations + kind in UserDefaults for cold-launch restore. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/native-background-sync-v2.md | 51 ++ .changeset/native-background-sync.md | 22 + README.md | 121 +++ .../ReactNativeHealthkitBackground.podspec | 22 + .../ios/BackgroundDeliveryManager.swift | 119 +-- .../ios/CoreModule.swift | 53 +- .../ios/NativeSyncEngine.swift | 689 ++++++++++++++++++ .../src/healthkit.ios.ts | 8 +- .../react-native-healthkit/src/healthkit.ts | 12 +- .../src/specs/CoreModule.nitro.ts | 35 +- .../react-native-healthkit/src/test-setup.ts | 4 +- .../src/types/Background.ts | 85 +++ 12 files changed, 1152 insertions(+), 69 deletions(-) create mode 100644 .changeset/native-background-sync-v2.md create mode 100644 .changeset/native-background-sync.md create mode 100644 packages/react-native-healthkit/ReactNativeHealthkitBackground.podspec create mode 100644 packages/react-native-healthkit/ios/NativeSyncEngine.swift 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/.changeset/native-background-sync.md b/.changeset/native-background-sync.md new file mode 100644 index 00000000..0a917168 --- /dev/null +++ b/.changeset/native-background-sync.md @@ -0,0 +1,22 @@ +--- +"@kingstinct/react-native-healthkit": minor +--- + +Add `configureBackgroundSync` — native-first HealthKit background sync. + +Runs entirely in native Swift (no JS bridge required) when an observer query fires after app termination. Queries HealthKit for today's data and POSTs to a configured HTTP endpoint. Uses `HKStatisticsCollectionQuery` with cumulativeSum for cumulative types (steps, distance, etc.) so iPhone + Watch overlaps are correctly deduplicated. Discrete types use `HKSampleQuery`. + +**Design:** +- Library is generic — consumers provide the type/unit translation via `SyncTypeConfig` and the full HTTP endpoint config (url, method, headers). No assumptions about backend shape, auth scheme, or provider conventions. +- Body sent is `{ records: [...] }`. Each record has `type`, `value`, `unit`, `startTime`, `endTime`, `recordId` (HK UUID for discrete / `"{type}-{YYYY-MM-DD}"` for cumulative aggregates), `frequency` (`"realtime"` or `"daily"`). +- Hard 20-second sync budget (iOS allows ~30s total background execution). +- No retries — if the HTTP POST fails, the event is dropped and iOS will fire observers again on the next HealthKit change. +- Content-Type defaults to `application/json` if not provided in `headers`. +- Setup-time validation: URL well-formedness, HTTP method (POST/PUT/PATCH), non-empty typeConfigs, encodability to UserDefaults. All throw at `configureBackgroundSync()` time rather than failing silently on every wake. + +**New native file:** `ios/NativeSyncEngine.swift` — part of the existing `ReactNativeHealthkitBackground` companion pod; no NitroModules/C++ dependencies. + +**New TypeScript APIs:** +- `configureBackgroundSync(endpoint, typeConfigs, updateFrequency): Promise` +- `clearBackgroundSync(): Promise` +- `BackgroundSyncEndpoint` / `SyncTypeConfig` types (in `types/Background`) 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/ReactNativeHealthkitBackground.podspec b/packages/react-native-healthkit/ReactNativeHealthkitBackground.podspec new file mode 100644 index 00000000..13f4a9c2 --- /dev/null +++ b/packages/react-native-healthkit/ReactNativeHealthkitBackground.podspec @@ -0,0 +1,22 @@ +# 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" + s.frameworks = "HealthKit" + + s.pod_target_xcconfig = { + "DEFINES_MODULE" => "YES", + "SWIFT_VERSION" => "5.0", + } +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/CoreModule.swift b/packages/react-native-healthkit/ios/CoreModule.swift index 9ff6e3aa..6c278bb6 100644 --- a/packages/react-native-healthkit/ios/CoreModule.swift +++ b/packages/react-native-healthkit/ios/CoreModule.swift @@ -430,16 +430,60 @@ 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") + } + + // 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, + 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 +491,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..187012be --- /dev/null +++ b/packages/react-native-healthkit/ios/NativeSyncEngine.swift @@ -0,0 +1,689 @@ +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 + let unit: 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 + } + + 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 [] } + + return try await withCheckedThrowingContinuation { cont in + let interval = DateComponents(day: 1) + let predicate = HKQuery.predicateForSamples(withStart: since, end: nil) + let hkUnit = HKUnit(from: config.unit) + + 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 [] } + + return try await withCheckedThrowingContinuation { cont in + let predicate = HKQuery.predicateForSamples(withStart: since, end: nil) + let hkUnit = HKUnit(from: config.unit) + + 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 } + return [ + "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", + ] + } + 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 + } + 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 + } + 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/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..7f7cc184 100644 --- a/packages/react-native-healthkit/src/types/Background.ts +++ b/packages/react-native-healthkit/src/types/Background.ts @@ -7,3 +7,88 @@ 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 + readonly unit: string + readonly kind: SyncKind +} + +/** + * 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 sent: `{ records: [...] }` where each record contains + * `type`, `value`, `unit`, `startTime`, `endTime`, `recordId`, `frequency`, + * plus kind-specific extras (category value name, workout fields, etc.). + * + * - `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 +} From f904004a39d3a180001361a3de93922d782ff807 Mon Sep 17 00:00:00 2001 From: Oakleaf Date: Tue, 14 Apr 2026 09:12:46 +0200 Subject: [PATCH 2/3] feat(background-sync): dual-unit hkUnit + device/source/metadata enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyncTypeConfig gains optional hkUnit field for when a consumer's wire unit differs from Apple's HKUnit factorization grammar (e.g. "bpm" vs "count/min"). Set hkUnit for HealthKit queries, unit for the wire. Omit hkUnit when they match — falls back to unit automatically. Configure-time validation in configureBackgroundSync surfaces invalid HKUnit strings synchronously instead of silently dropping records on background wake hours later. NativeSyncEngine now emits device (HKDevice), source (HKSourceRevision), and metadata on all sample-based records (discrete, category, workout). Cumulative queries return aggregated HKStatistics — no per-sample metadata to expose. Companion pod gains SWIFT_ACTIVE_COMPILATION_CONDITIONS so NativeSyncEngine compiles cleanly in both pod targets: calls BGSafeHKUnitFromString in the companion pod, falls back to HKUnitFromStringCatchingExceptions in the main pod. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ReactNativeHealthkit.podspec | 10 ++ .../ReactNativeHealthkitBackground.podspec | 24 ++++- .../ios/BackgroundHKUnitCatcher.h | 29 +++++ .../ios/BackgroundHKUnitCatcher.mm | 23 ++++ .../ios/CoreModule.swift | 18 ++++ .../ios/NativeSyncEngine.swift | 101 +++++++++++++++++- packages/react-native-healthkit/package.json | 2 +- .../src/types/Background.ts | 14 +++ 8 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-healthkit/ios/BackgroundHKUnitCatcher.h create mode 100644 packages/react-native-healthkit/ios/BackgroundHKUnitCatcher.mm diff --git a/packages/react-native-healthkit/ReactNativeHealthkit.podspec b/packages/react-native-healthkit/ReactNativeHealthkit.podspec index 0b4e52ad..c1241b63 100644 --- a/packages/react-native-healthkit/ReactNativeHealthkit.podspec +++ b/packages/react-native-healthkit/ReactNativeHealthkit.podspec @@ -22,6 +22,16 @@ 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", + ] + 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 index 13f4a9c2..f243f8c4 100644 --- a/packages/react-native-healthkit/ReactNativeHealthkitBackground.podspec +++ b/packages/react-native-healthkit/ReactNativeHealthkitBackground.podspec @@ -12,11 +12,33 @@ Pod::Spec.new do |s| 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" + 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/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 6c278bb6..bd4064a6 100644 --- a/packages/react-native-healthkit/ios/CoreModule.swift +++ b/packages/react-native-healthkit/ios/CoreModule.swift @@ -452,12 +452,30 @@ class CoreModule: HybridCoreModuleSpec { 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 ) } diff --git a/packages/react-native-healthkit/ios/NativeSyncEngine.swift b/packages/react-native-healthkit/ios/NativeSyncEngine.swift index 187012be..cf07b6df 100644 --- a/packages/react-native-healthkit/ios/NativeSyncEngine.swift +++ b/packages/react-native-healthkit/ios/NativeSyncEngine.swift @@ -82,11 +82,92 @@ import HealthKit 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 { @@ -306,11 +387,18 @@ import HealthKit 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 hkUnit = HKUnit(from: config.unit) let query = HKStatisticsCollectionQuery( quantityType: quantityType, @@ -362,10 +450,13 @@ import HealthKit 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 hkUnit = HKUnit(from: config.unit) let query = HKSampleQuery( sampleType: sampleType, @@ -376,7 +467,7 @@ import HealthKit 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 } - return [ + var record: [String: Any] = [ "type": config.type, "value": q.quantity.doubleValue(for: hkUnit), "unit": config.unit, @@ -385,6 +476,8 @@ import HealthKit "recordId": q.uuid.uuidString, "frequency": "realtime", ] + Self.enrichRecord(&record, from: q) + return record } cont.resume(returning: records) } @@ -440,6 +533,7 @@ import HealthKit } else { record["value"] = c.value } + Self.enrichRecord(&record, from: c) return record } cont.resume(returning: records) @@ -504,6 +598,7 @@ import HealthKit if let max = workoutMaxHR(w) { record["maxHeartRate"] = max } + enrichRecord(&record, from: w) return record } 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/types/Background.ts b/packages/react-native-healthkit/src/types/Background.ts index 7f7cc184..5ba878dd 100644 --- a/packages/react-native-healthkit/src/types/Background.ts +++ b/packages/react-native-healthkit/src/types/Background.ts @@ -67,7 +67,21 @@ export type SyncKind = 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 } From 02ecd012f52331dcb0d719f55d7ec00379298511 Mon Sep 17 00:00:00 2001 From: Oakleaf Date: Thu, 16 Apr 2026 06:31:19 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?consolidate=20changeset,=20fix=20podspec,=20add=20output=20reco?= =?UTF-8?q?rd=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete duplicate v1 changeset (native-background-sync.md) — keep only v2 - Exclude BackgroundDeliveryManager.swift and NativeSyncEngine.swift from the main pod's source_files glob — these @objc singletons were being compiled by both the main and companion pods, causing duplicate HKHealthStore instances and DispatchQueue lock contention that hung the contract test suite - Add TypeScript interfaces for output record shapes (SyncRecordDevice, SyncRecordSource, SyncRecordCumulative, SyncRecordDiscrete, SyncRecordCategory, SyncRecordWorkout) so consumers can type their backend request handlers and the metadata flow is explicit in the PR diff Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/native-background-sync.md | 22 --- .../ReactNativeHealthkit.podspec | 6 + .../src/types/Background.ts | 132 +++++++++++++++++- 3 files changed, 135 insertions(+), 25 deletions(-) delete mode 100644 .changeset/native-background-sync.md diff --git a/.changeset/native-background-sync.md b/.changeset/native-background-sync.md deleted file mode 100644 index 0a917168..00000000 --- a/.changeset/native-background-sync.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@kingstinct/react-native-healthkit": minor ---- - -Add `configureBackgroundSync` — native-first HealthKit background sync. - -Runs entirely in native Swift (no JS bridge required) when an observer query fires after app termination. Queries HealthKit for today's data and POSTs to a configured HTTP endpoint. Uses `HKStatisticsCollectionQuery` with cumulativeSum for cumulative types (steps, distance, etc.) so iPhone + Watch overlaps are correctly deduplicated. Discrete types use `HKSampleQuery`. - -**Design:** -- Library is generic — consumers provide the type/unit translation via `SyncTypeConfig` and the full HTTP endpoint config (url, method, headers). No assumptions about backend shape, auth scheme, or provider conventions. -- Body sent is `{ records: [...] }`. Each record has `type`, `value`, `unit`, `startTime`, `endTime`, `recordId` (HK UUID for discrete / `"{type}-{YYYY-MM-DD}"` for cumulative aggregates), `frequency` (`"realtime"` or `"daily"`). -- Hard 20-second sync budget (iOS allows ~30s total background execution). -- No retries — if the HTTP POST fails, the event is dropped and iOS will fire observers again on the next HealthKit change. -- Content-Type defaults to `application/json` if not provided in `headers`. -- Setup-time validation: URL well-formedness, HTTP method (POST/PUT/PATCH), non-empty typeConfigs, encodability to UserDefaults. All throw at `configureBackgroundSync()` time rather than failing silently on every wake. - -**New native file:** `ios/NativeSyncEngine.swift` — part of the existing `ReactNativeHealthkitBackground` companion pod; no NitroModules/C++ dependencies. - -**New TypeScript APIs:** -- `configureBackgroundSync(endpoint, typeConfigs, updateFrequency): Promise` -- `clearBackgroundSync(): Promise` -- `BackgroundSyncEndpoint` / `SyncTypeConfig` types (in `types/Background`) diff --git a/packages/react-native-healthkit/ReactNativeHealthkit.podspec b/packages/react-native-healthkit/ReactNativeHealthkit.podspec index c1241b63..f7125c02 100644 --- a/packages/react-native-healthkit/ReactNativeHealthkit.podspec +++ b/packages/react-native-healthkit/ReactNativeHealthkit.podspec @@ -30,6 +30,12 @@ Pod::Spec.new do |s| 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" diff --git a/packages/react-native-healthkit/src/types/Background.ts b/packages/react-native-healthkit/src/types/Background.ts index 5ba878dd..a5eb4897 100644 --- a/packages/react-native-healthkit/src/types/Background.ts +++ b/packages/react-native-healthkit/src/types/Background.ts @@ -85,14 +85,140 @@ export interface SyncTypeConfig { 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 sent: `{ records: [...] }` where each record contains - * `type`, `value`, `unit`, `startTime`, `endTime`, `recordId`, `frequency`, - * plus kind-specific extras (category value name, workout fields, etc.). + * 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