Skip to content
Open
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
51 changes: 51 additions & 0 deletions .changeset/native-background-sync-v2.md
Original file line number Diff line number Diff line change
@@ -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:<N>` 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).
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
16 changes: 16 additions & 0 deletions packages/react-native-healthkit/ReactNativeHealthkit.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ Pod::Spec.new do |s|
"cpp/**/*.{hpp,cpp}",
]

# The companion ReactNativeHealthkitBackground pod owns its own copy of a
# lightweight HKUnit NSException catcher (BackgroundHKUnitCatcher). It
# can't depend on this pod (would pull in NitroModules, defeating the
# point) and two pods defining the same C function symbol would cause a
# duplicate-symbol linker error. Exclude the companion-only helper here.
s.exclude_files = [
"ios/BackgroundHKUnitCatcher.h",
"ios/BackgroundHKUnitCatcher.mm",
# The companion pod compiles these files — excluding them from the main pod
# prevents duplicate @objc singletons (BackgroundDeliveryManager.shared,
# NativeSyncEngine.shared) which cause competing HKHealthStore instances
# and DispatchQueue lock contention at launch.
"ios/BackgroundDeliveryManager.swift",
"ios/NativeSyncEngine.swift",
]

s.public_header_files = "ios/**/*.h"

s.pod_target_xcconfig = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Companion pod for HealthKit background delivery + native sync.
# Contains BackgroundDeliveryManager + NativeSyncEngine — no NitroModules, no C++ headers.
# Safe to import from AppDelegate on any RN version.

Pod::Spec.new do |s|
s.name = "ReactNativeHealthkitBackground"
s.version = "13.4.0"
s.summary = "HealthKit background delivery and native sync for React Native"
s.homepage = "https://github.com/kingstinct/react-native-healthkit"
s.license = "MIT"
s.author = "Robert Herber"
s.source = { :git => "https://github.com/kingstinct/react-native-healthkit.git" }
s.ios.deployment_target = "13.0"

s.source_files = [
"ios/BackgroundDeliveryManager.swift",
"ios/NativeSyncEngine.swift",
# Companion-local ObjC helper (NSException catcher for HKUnit parsing).
# The main pod's podspec excludes these filenames explicitly to avoid a
# duplicate C-symbol linker error.
"ios/BackgroundHKUnitCatcher.h",
"ios/BackgroundHKUnitCatcher.mm",
]
# Expose the ObjC helper's header to the pod's Swift module so
# NativeSyncEngine.swift can call BGSafeHKUnitFromString without a manual
# bridging header. CocoaPods puts listed public headers into the auto-
# generated umbrella + modulemap.
s.public_header_files = "ios/BackgroundHKUnitCatcher.h"

s.frameworks = "HealthKit"

s.pod_target_xcconfig = {
"DEFINES_MODULE" => "YES",
"SWIFT_VERSION" => "5.0",
# The ObjC header #imports <HealthKit/HealthKit.h> 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
Loading
Loading