From e82a0c665dd0bba3a412cbedd50530f9225837b4 Mon Sep 17 00:00:00 2001 From: Oakleaf Date: Sun, 12 Apr 2026 14:00:33 +0200 Subject: [PATCH] feat: native background sync engine for HealthKit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `configureBackgroundSync` — a fully-native background sync pipeline that runs when HealthKit observer queries fire without the JS bridge available (e.g. after the app has been terminated by iOS). The library is consumer-agnostic: you provide the HTTP endpoint (url, method, headers), a list of SyncTypeConfig entries mapping HealthKit identifiers to your own type names and units, and an UpdateFrequency. Records are sent as a `{ records: [...] }` JSON body with fields the consumer defined. Cumulative types (steps, distance, etc.) use HKStatisticsCollectionQuery with .cumulativeSum — Apple deduplicates across iPhone + Watch and returns one correct daily total per bucket. Discrete types use HKSampleQuery. Implementation notes: - New native file NativeSyncEngine.swift added to the existing companion ReactNativeHealthkitBackground pod (no NitroModules / C++ deps, safe for AppDelegate imports on any RN version). - Setup-time validation: URL well-formedness, HTTP method (POST/PUT/PATCH), non-empty typeConfigs, JSON encodability. Errors surface at configureBackgroundSync() call time rather than failing silently on every background wake. - 20-second hard timeout per sync event (iOS allows ~30s; 10s buffer for safety). - No retry / queue on HTTP failure — relies on iOS to fire observers again on the next HealthKit change. - Content-Type defaults to application/json when not supplied. See README "Background Delivery & Native Sync" for comparison with the existing configureBackgroundTypes observer-only API. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/native-background-sync.md | 22 ++ README.md | 121 ++++++++ .../ReactNativeHealthkitBackground.podspec | 22 ++ .../ios/BackgroundDeliveryManager.swift | 29 +- .../ios/CoreModule.swift | 44 ++- .../ios/NativeSyncEngine.swift | 283 ++++++++++++++++++ .../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 | 48 +++ 11 files changed, 592 insertions(+), 36 deletions(-) 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.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..74407d49 100644 --- a/packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift +++ b/packages/react-native-healthkit/ios/BackgroundDeliveryManager.swift @@ -110,7 +110,7 @@ import HealthKit } for typeIdentifier in typeIdentifiers { - guard let sampleType = sampleTypeFromString(typeIdentifier) else { + guard let sampleType = BackgroundDeliveryManager.sampleTypeFromString(typeIdentifier) else { print("[react-native-healthkit] BackgroundDeliveryManager: skipping unrecognized type \(typeIdentifier)") continue } @@ -121,12 +121,24 @@ import HealthKit 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 } + + // The observer callback runs on a HealthKit-owned background queue. + // We check jsCallback via a synchronous barrier read — this is a fast + // property access (no long-running work under the lock), so it's safe + // to do synchronously even though iOS expects prompt progress here. + let hasJsCallback = self.queue.sync { self.jsCallback != nil } + + if hasJsCallback { + // JS bridge available (foreground) — dispatch to JS, complete immediately + self.handleObserverCallback(typeIdentifier: typeIdentifier, error: error) + completionHandler() + } else { + // No JS bridge (terminated/headless) — native sync within ~30s budget + NativeSyncEngine.shared.syncType(typeIdentifier) { + completionHandler() + } + } } healthStore.execute(query) @@ -163,7 +175,8 @@ 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? { + // 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..f9dc8737 100644 --- a/packages/react-native-healthkit/ios/CoreModule.swift +++ b/packages/react-native-healthkit/ios/CoreModule.swift @@ -430,16 +430,51 @@ 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, + cumulative: $0.cumulative + ) + } + let nativeEndpoint = NativeSyncEngine.EndpointConfig( + url: endpoint.url, + method: endpoint.method, + headers: endpoint.headers + ) + try NativeSyncEngine.writeConfig( + endpoint: nativeEndpoint, + typeConfigs: nativeConfigs + ) + // 2. Register observer queries + enable background delivery + let identifiers = typeConfigs.map { $0.identifier } BackgroundDeliveryManager.shared.configure( - typeIdentifiers: typeIdentifiers, + typeIdentifiers: identifiers, frequency: frequency ) @@ -447,9 +482,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..638cb836 --- /dev/null +++ b/packages/react-native-healthkit/ios/NativeSyncEngine.swift @@ -0,0 +1,283 @@ +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 today's 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 emitted (consumer-friendly defaults for time-series health sync): +/// { +/// "type": "", +/// "value": , +/// "unit": "", +/// "startTime": "", +/// "endTime": "", +/// "recordId": "", +/// "frequency": "realtime" | "daily" +/// } +@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" + + private static let httpTimeoutSeconds: TimeInterval = 15 + private static let syncBudgetSeconds: TimeInterval = 20 + + private let dateFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private override init() { + super.init() + } + + // MARK: - Configuration + + struct EndpointConfig: Codable { + let url: String + let method: String + let headers: [String: String] + } + + struct SyncConfig { + let endpoint: EndpointConfig + let typeConfigs: [TypeConfig] + } + + struct TypeConfig: Codable { + let identifier: String + let type: String + let unit: String + let cumulative: Bool + } + + 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: - Sync + + func syncType(_ typeIdentifier: String, completion: @escaping () -> Void) { + guard let config = readConfig(), + let sampleType = BackgroundDeliveryManager.sampleTypeFromString(typeIdentifier), + let typeConfig = config.typeConfigs.first(where: { $0.identifier == typeIdentifier }) + else { + completion() + return + } + + // Hard timeout — iOS kills us at ~30s, finish by 20s + var completed = false + let lock = NSLock() + + func safeComplete() { + lock.lock() + defer { lock.unlock() } + guard !completed else { return } + completed = true + completion() + } + + DispatchQueue.global().asyncAfter(deadline: .now() + NativeSyncEngine.syncBudgetSeconds) { + safeComplete() + } + + // Native sync only handles today — HealthKit queues wakes for offline/catch-up + let since = Calendar.current.startOfDay(for: Date()) + + if typeConfig.cumulative, let quantityType = sampleType as? HKQuantityType { + syncCumulativeType(quantityType, typeConfig: typeConfig, since: since, endpoint: config.endpoint, completion: safeComplete) + } else { + syncDiscreteType(sampleType, typeConfig: typeConfig, since: since, endpoint: config.endpoint, completion: safeComplete) + } + } + + // MARK: - Cumulative Types + + /// HKStatisticsCollectionQuery with cumulativeSum — Apple deduplicates across + /// sources (iPhone + Watch) and returns one correct total per day. + /// sourceRecordId is synthesized from consumer's type name + bucket date. + private func syncCumulativeType( + _ quantityType: HKQuantityType, + typeConfig: TypeConfig, + since: Date, + endpoint: EndpointConfig, + completion: @escaping () -> Void + ) { + let interval = DateComponents(day: 1) + let predicate = HKQuery.predicateForSamples(withStart: since, end: nil) + let hkUnit = HKUnit(from: typeConfig.unit) + + let query = HKStatisticsCollectionQuery( + quantityType: quantityType, + quantitySamplePredicate: predicate, + options: .cumulativeSum, + anchorDate: since, + intervalComponents: interval + ) + + query.initialResultsHandler = { [weak self] _, results, _ in + guard let self = self, let results = results else { completion(); return } + + var records: [[String: Any]] = [] + results.enumerateStatistics(from: since, to: Date()) { stats, _ in + guard let sum = stats.sumQuantity() else { return } + + let cal = Calendar.current + let y = cal.component(.year, from: stats.startDate) + let m = String(format: "%02d", cal.component(.month, from: stats.startDate)) + let d = String(format: "%02d", cal.component(.day, from: stats.startDate)) + let dateKey = "\(y)-\(m)-\(d)" + + records.append([ + "type": typeConfig.type, + "value": sum.doubleValue(for: hkUnit), + "unit": typeConfig.unit, + "startTime": self.dateFormatter.string(from: stats.startDate), + "endTime": self.dateFormatter.string(from: stats.endDate), + "recordId": "\(typeConfig.type)-\(dateKey)", + "frequency": "daily", + ]) + } + + guard !records.isEmpty else { completion(); return } + self.sendRecords(records, endpoint: endpoint, completion: completion) + } + + healthStore.execute(query) + } + + // MARK: - Discrete Types + + /// HKSampleQuery for discrete types — each sample is a distinct observation. + /// sourceRecordId = HK sample uuid. + private func syncDiscreteType( + _ sampleType: HKSampleType, + typeConfig: TypeConfig, + since: Date, + endpoint: EndpointConfig, + completion: @escaping () -> Void + ) { + let predicate = HKQuery.predicateForSamples(withStart: since, end: nil) + + let query = HKSampleQuery( + sampleType: sampleType, + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: nil + ) { [weak self] _, samples, _ in + guard let self = self, let samples = samples, !samples.isEmpty else { + completion() + return + } + + let records = self.buildDiscreteRecords(samples, typeConfig: typeConfig) + guard !records.isEmpty else { completion(); return } + self.sendRecords(records, endpoint: endpoint, completion: completion) + } + + healthStore.execute(query) + } + + // MARK: - Record Formatting + + private func buildDiscreteRecords(_ samples: [HKSample], typeConfig: TypeConfig) -> [[String: Any]] { + let hkUnit = HKUnit(from: typeConfig.unit) + + return samples.compactMap { sample -> [String: Any]? in + var record: [String: Any] = [ + "type": typeConfig.type, + "startTime": dateFormatter.string(from: sample.startDate), + "endTime": dateFormatter.string(from: sample.endDate), + "recordId": sample.uuid.uuidString, + "frequency": "realtime", + ] + + if let q = sample as? HKQuantitySample { + record["value"] = q.quantity.doubleValue(for: hkUnit) + record["unit"] = typeConfig.unit + } else if let c = sample as? HKCategorySample { + record["value"] = c.value + } else if let w = sample as? HKWorkout { + record["workoutType"] = String(w.workoutActivityType.rawValue) + record["duration"] = w.duration + } else { + return nil + } + + return record + } + } + + // MARK: - HTTP + + /// Send records as JSON to the configured endpoint. The body shape is + /// { records: [...] } — a wrapper object that's easier for consumer backends + /// to extend (add provider, device info, etc. without changing the shape). + private func sendRecords(_ records: [[String: Any]], endpoint: EndpointConfig, completion: @escaping () -> Void) { + let body: [String: Any] = ["records": records] + guard let url = URL(string: endpoint.url), + let jsonData = try? JSONSerialization.data(withJSONObject: body) + else { + completion() + return + } + + var request = URLRequest(url: url) + request.httpMethod = endpoint.method + request.timeoutInterval = NativeSyncEngine.httpTimeoutSeconds + request.httpBody = jsonData + + // Default Content-Type to application/json if the consumer didn't override it. + 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) + } + + URLSession.shared.dataTask(with: request) { _, response, error in + if let error = error { + print("[react-native-healthkit] NativeSyncEngine: request failed: \(error.localizedDescription)") + } else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + print("[react-native-healthkit] NativeSyncEngine: request returned \(httpResponse.statusCode)") + } + completion() + }.resume() + } +} 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..3870eadc 100644 --- a/packages/react-native-healthkit/src/types/Background.ts +++ b/packages/react-native-healthkit/src/types/Background.ts @@ -7,3 +7,51 @@ export enum UpdateFrequency { daily = 3, weekly = 4, } + +/** + * 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. + * - `cumulative`: set `true` for quantity types where multiple sources overlap + * (e.g. iPhone + Watch both record steps). The library uses + * `HKStatisticsCollectionQuery` with `.cumulativeSum` and emits one daily + * total (Apple deduplicates across sources). Set `false` for discrete + * observations (heart rate, weight, HRV) where each sample is independent. + * + * @example + * // Cumulative — steps, distance, active energy, exercise minutes, flights + * { identifier: 'HKQuantityTypeIdentifierStepCount', type: 'steps', unit: 'count', cumulative: true } + * + * // Discrete — heart rate, HRV, weight, body temperature, blood pressure + * { identifier: 'HKQuantityTypeIdentifierHeartRate', type: 'heart_rate', unit: 'count/min', cumulative: false } + * + * // Category — sleep, mindful sessions (always discrete) + * { identifier: 'HKCategoryTypeIdentifierSleepAnalysis', type: 'sleep', unit: '', cumulative: false } + */ +export interface SyncTypeConfig { + readonly identifier: string + readonly type: string + readonly unit: string + readonly cumulative: boolean +} + +/** + * 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`, `sourceRecordId`, `granularity`. + */ +export interface BackgroundSyncEndpoint { + readonly url: string + readonly method: string + readonly headers: Record +}