Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/native-background-sync.md
Original file line number Diff line number Diff line change
@@ -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<boolean>`
- `clearBackgroundSync(): Promise<boolean>`
- `BackgroundSyncEndpoint` / `SyncTypeConfig` types (in `types/Background`)
121 changes: 121 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <device-token>',
},
},
[
{ 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).
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 40 additions & 4 deletions packages/react-native-healthkit/ios/CoreModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -430,26 +430,62 @@ class CoreModule: HybridCoreModuleSpec {
}
}

func configureBackgroundTypes(
typeIdentifiers: [String], updateFrequency: UpdateFrequency
func configureBackgroundSync(
endpoint: BackgroundSyncEndpoint,
typeConfigs: [SyncTypeConfig],
updateFrequency: UpdateFrequency
) -> Promise<Bool> {
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
)

return true
}
}

func clearBackgroundTypes() -> Promise<Bool> {
func clearBackgroundSync() -> Promise<Bool> {
return Promise.async {
BackgroundDeliveryManager.shared.clearConfiguration()
NativeSyncEngine.clearConfig()
return true
}
}
Expand Down
Loading
Loading