Skip to content

N0ku/react-native-nitro-healthkit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

8 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

react-native-nitro-healthkit πŸ₯

npm version CI License: MIT PRs Welcome

React Native TypeScript Swift Kotlin iOS Android

A high-performance React Native library exposing a single TypeScript API on top of Apple HealthKit (iOS) and Android Health Connect (Android) via Nitro Modules. Built with Swift + Kotlin and powered by modern C++ interop for native performance.

Cross-platform. Health Connect is the canonical Android health data API; everything that writes into it (Samsung Health, Google Fit, Fitbit since 2024, Withings, Oura, MyFitnessPal, …) is readable through this module on Android. See the Android support matrix below.

✨ Features

  • πŸš€ Ultra-fast: Built with Nitro Modules for native performance
  • 🌍 Cross-platform: One TypeScript API, two native implementations (iOS Swift + Android Kotlin)
  • πŸ“Š Comprehensive: Steps, heart rate, active energy, distance, floors, sleep, workouts, and ~130 quantity / 70 category types
  • πŸ”’ Privacy-first: Proper HealthKit & Health Connect authorization flows
  • πŸ“± iOS Native: Pure Swift implementation with HealthKit framework
  • πŸ€– Android Native: Pure Kotlin implementation on top of androidx.health.connect:connect-client
  • ✍️ Writes: writeQuantityData / writeCategoryData (insert manual samples)
  • πŸ‘€ Observers: observeQuantityChanges / observeCategoryChanges
  • πŸŒ™ Background sync: register a periodic background job (Android WorkManager / iOS BGTaskScheduler) that POSTs deltas to your backend
  • 🎯 Type-safe: Full TypeScript support
  • ⚑ Promise-based: Modern async/await API

πŸ“¦ Installation

npm install react-native-nitro-healthkit
# or
yarn add react-native-nitro-healthkit

iOS Setup

Minimum iOS deployment target: 14.0.

  1. Install pods:

    cd example/my-app/ios && pod install
  2. Add HealthKit capability:

    • Open your Xcode project
    • Select your target β†’ Signing & Capabilities
    • Click "+ Capability" and add "HealthKit"
  3. Add privacy descriptions to Info.plist (NSHealthShareUsageDescription is required to read; NSHealthUpdateUsageDescription is required only if you call writeQuantityData / writeCategoryData):

    <key>NSHealthShareUsageDescription</key>
    <string>We need access to your health data to track your activity</string>
    <key>NSHealthUpdateUsageDescription</key>
    <string>We need access to your health data to track your activity</string>
  4. Ensure entitlements are set. Your *.entitlements file should contain:

    <key>com.apple.developer.healthkit</key>
    <true/>

    For observeQuantityChanges / observeCategoryChanges to fire while the app is backgrounded, also add the background-delivery entitlement:

    <key>com.apple.developer.healthkit.background-delivery</key>
    <true/>
  5. Background sync only (skip if you don't call registerBackgroundSync). iOS requires the background-task handler to be registered at launch, before application(_:didFinishLaunchingWithOptions:) returns:

    • Declare the task identifier and background modes in Info.plist:
      <key>BGTaskSchedulerPermittedIdentifiers</key>
      <array>
        <string>com.nitrohealthkit.sync</string>
      </array>
      <key>UIBackgroundModes</key>
      <array>
        <string>fetch</string>
        <string>processing</string>
      </array>
    • Register the launch handler from your AppDelegate:
      import NitroHealthkit // Swift
      
      func application(_ application: UIApplication,
                       didFinishLaunchingWithOptions launchOptions: ...) -> Bool {
        HealthKitBackgroundSync.registerLaunchHandler()
        // ...
      }
      // Objective-C AppDelegate
      #import <NitroHealthkit/NitroHealthkit-Swift.h>
      [HealthKitBackgroundSync registerLaunchHandler];

    iOS decides when to actually run the task β€” intervalMinutes is a lower bound, not a guarantee.

Android Setup

Health Connect ships in the platform on Android 14+. On Android 8–13, users install the "Health Connect" app from the Play Store; the module detects its presence via HealthConnectClient.getSdkStatus and surfaces it via isHealthKitAvailable().

  1. Minimum SDK: 26 (Android 8.0). The module's build.gradle defaults match.

  2. Declare permissions in your host app's AndroidManifest.xml (or via app.config.ts if you use Expo prebuild). The library's own manifest declares the same set so it merges naturally.

    <uses-permission android:name="android.permission.health.READ_STEPS" />
    <uses-permission android:name="android.permission.health.WRITE_STEPS" />
    <uses-permission android:name="android.permission.health.READ_HEART_RATE" />
    <uses-permission android:name="android.permission.health.READ_SLEEP" />
    <uses-permission android:name="android.permission.health.READ_EXERCISE" />
    <uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
    <uses-permission android:name="android.permission.health.READ_DISTANCE" />
    <uses-permission android:name="android.permission.health.READ_FLOORS_CLIMBED" />
    <!-- …and the WRITE_* counterparts if you call writeQuantityData / writeCategoryData -->
  3. Add the Health Connect rationale intent filter to your main Activity (Google requires this β€” without it, the system permission dialog won't show a link back to your app):

    <activity android:name=".MainActivity" …>
      <intent-filter>
        <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
      </intent-filter>
    </activity>
  4. First-run flow: requestAuthorization() on Android only reports whether the user has already granted the default set of permissions β€” requesting them needs an Activity, so it must be triggered from your UI layer:

    val requestPermissions = registerForActivityResult(
      PermissionController.createRequestPermissionResultContract()
    ) { granted -> /* update state */ }
    requestPermissions.launch(setOf(
      HealthPermission.getReadPermission(StepsRecord::class),
      HealthPermission.getReadPermission(HeartRateRecord::class),
      // ... etc.
    ))

    On the JS side, the host app handles this via react-native-permissions or a thin Kotlin glue Activity β€” the module deliberately does not own this flow because the choice of UI is host-app territory.

  5. Production launch: for each WRITE_* permission you ship, Google Play asks for a written justification (review takes ~3 business days). Plan accordingly.

πŸš€ Usage

Basic Example

import { HealthKitModule } from 'react-native-nitro-healthkit';

// Request authorization
const authorized = await HealthKitModule.requestAuthorization();

if (authorized) {
  // Get today's steps
  const today = new Date();
  const startOfDay = new Date(today.setHours(0, 0, 0, 0));
  const steps = await HealthKitModule.getSteps(startOfDay, new Date());
  
  console.log(`Steps today: ${steps}`);
}

Get Health Data for Multiple Days

// Get last 7 days of health data
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7);

const healthData = await HealthKitModule.getHealthData(startDate, endDate);

console.log('Health Data:', {
  steps: healthData.steps,
  heartRate: healthData.heartRate,
  activeEnergy: healthData.activeEnergy,
  distance: healthData.distance,
});

Complete Component Example

import React, { useState } from 'react';
import { View, Button, Text, Alert } from 'react-native';
import { HealthKitModule, type HealthData } from 'react-native-nitro-healthkit';

export default function HealthScreen() {
  const [isAuthorized, setIsAuthorized] = useState(false);
  const [healthData, setHealthData] = useState<HealthData | null>(null);

  const requestPermissions = async () => {
    try {
      const authorized = await HealthKitModule.requestAuthorization();
      setIsAuthorized(authorized);
      
      if (!authorized) {
        Alert.alert('Authorization denied');
      }
    } catch (error) {
      Alert.alert('Error', `${error}`);
    }
  };

  const fetchHealthData = async () => {
    if (!isAuthorized) {
      Alert.alert('Please authorize HealthKit first');
      return;
    }

    try {
      const endDate = new Date();
      const startDate = new Date();
      startDate.setDate(startDate.getDate() - 7);

      const data = await HealthKitModule.getHealthData(startDate, endDate);
      setHealthData(data);
    } catch (error) {
      Alert.alert('Error', `${error}`);
    }
  };

  return (
    <View>
      <Button
        title="Request Authorization"
        onPress={requestPermissions}
      />
      
      {isAuthorized && (
        <Button
          title="Fetch Health Data (7 days)"
          onPress={fetchHealthData}
        />
      )}

      {healthData && (
        <View>
          {healthData.steps && (
            <Text>Steps: {Math.round(healthData.steps)}</Text>
          )}
          {healthData.heartRate && (
            <Text>Heart Rate: {Math.round(healthData.heartRate)} bpm</Text>
          )}
        </View>
      )}
    </View>
  );
}

πŸ“š API Reference

requestAuthorization(): Promise<boolean>

Requests authorization to access HealthKit data.

Returns: Promise<boolean> - true if authorized, false otherwise

Example:

const authorized = await HealthKitModule.requestAuthorization();

getSteps(startDate: Date, endDate: Date): Promise<number>

Gets the total step count for the specified period.

Parameters:

  • startDate: Date - Start of the period
  • endDate: Date - End of the period

Returns: Promise<number> - Total steps count

Example:

const steps = await HealthKitModule.getSteps(
  new Date('2025-10-17'),
  new Date('2025-10-24')
);

getHeartRate(startDate: Date, endDate: Date): Promise<number>

Gets the average heart rate for the specified period.

Parameters:

  • startDate: Date - Start of the period
  • endDate: Date - End of the period

Returns: Promise<number> - Average heart rate in BPM

Example:

const bpm = await HealthKitModule.getHeartRate(
  new Date('2025-10-17'),
  new Date('2025-10-24')
);

getHealthData(startDate: Date, endDate: Date): Promise<HealthData>

Gets comprehensive health data for the specified period. This method fetches all available metrics in parallel and returns partial data if some metrics are unavailable.

Parameters:

  • startDate: Date - Start of the period
  • endDate: Date - End of the period

Returns: Promise<HealthData> - Object containing available health metrics

Example:

const data = await HealthKitModule.getHealthData(
  new Date('2025-10-17'),
  new Date('2025-10-24')
);

HealthData Interface

interface HealthData {
  steps?: number;           // Total steps count
  heartRate?: number;       // Average heart rate (BPM)
  activeEnergy?: number;    // Active energy burned (kcal)
  distance?: number;        // Distance traveled (meters)
  sleepAnalysis?: string;   // Sleep summary
}

getHealthData currently populates steps and heartRate; the other fields are reserved on the interface. To read active energy, distance or sleep today, use getAggregatedQuantity / getQuantityData (e.g. ACTIVE_ENERGY_BURNED, DISTANCE_WALKING_RUNNING) and getCategoryData (SLEEP_ANALYSIS).

πŸ” Error Handling

The library handles errors gracefully. If a specific metric is unavailable (e.g., no heart rate data), other metrics will still be returned:

const data = await HealthKitModule.getHealthData(startDate, endDate);

// Even if heart rate data is unavailable, steps will be returned
if (data.steps) {
  console.log(`Steps: ${data.steps}`);
}

if (!data.heartRate) {
  console.log('No heart rate data available');
}

πŸ—οΈ Architecture

This library is built with Nitro Modules, providing:

  • Native performance: Direct Swift/C++ implementation
  • Type safety: Full TypeScript definitions generated from native specs
  • Modern APIs: Promise-based async/await interface
  • Zero-copy: Efficient data passing between JavaScript and native

Project Structure

packages/
β”œβ”€β”€ ios/                                       # Swift HealthKit implementation
β”‚   β”œβ”€β”€ HealthKitModule.swift
β”‚   β”œβ”€β”€ CacheManager.swift
β”‚   β”œβ”€β”€ Observers/HealthKitObserverManager.swift   # HKObserverQuery + background delivery
β”‚   β”œβ”€β”€ BackgroundSync/                            # BGTaskScheduler + Keychain
β”‚   β”‚   β”œβ”€β”€ HealthKitBackgroundSync.swift
β”‚   β”‚   └── KeychainCredentialsStore.swift
β”‚   └── NitroHealthkitObjcBridge.swift
β”œβ”€β”€ android/                                   # Kotlin Health Connect implementation
β”‚   β”œβ”€β”€ build.gradle
β”‚   └── src/main/kotlin/io/github/n0ku/nitrohealthkit/
β”‚       β”œβ”€β”€ HealthKitModule.kt                 # extends HybridHealthKitSpec
β”‚       β”œβ”€β”€ cache/CacheManager.kt              # parity with iOS CacheManager
β”‚       β”œβ”€β”€ mappers/                           # HK ↔ Health Connect mapping
β”‚       β”‚   β”œβ”€β”€ QuantityMapper.kt
β”‚       β”‚   β”œβ”€β”€ CategoryMapper.kt
β”‚       β”‚   └── WorkoutMapper.kt
β”‚       β”œβ”€β”€ observers/ChangesObserver.kt       # Health Connect changes API
β”‚       β”œβ”€β”€ workers/HealthSyncWorker.kt        # WorkManager periodic sync
β”‚       β”œβ”€β”€ auth/SecureCredentialsStore.kt     # EncryptedSharedPreferences-backed
β”‚       └── util/                              # TimeRangeHelper, context holder
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ index.ts                               # JS entry point + types
β”‚   └── specs/Example.nitro.ts                 # Nitro spec (source of truth)
β”œβ”€β”€ nitrogen/                                  # Generated Swift / Kotlin / C++ stubs
└── lib/                                       # Compiled JavaScript

πŸ€– Android support matrix

HealthKit type Android record Read Write Notes
STEPS StepsRecord βœ… βœ… aggregate via StepsRecord.COUNT_TOTAL
HEART_RATE HeartRateRecord (samples) βœ… βœ… per-sample flatten
RESTING_HEART_RATE RestingHeartRateRecord βœ… βœ…
HEART_RATE_VARIABILITY_SDNN HeartRateVariabilityRmssdRecord βœ… ❌ HC has RMSSD, not SDNN β€” best-effort
ACTIVE_ENERGY_BURNED ActiveCaloriesBurnedRecord βœ… βœ… kcal
BASAL_ENERGY_BURNED BasalMetabolicRateRecord βœ… ❌ kcal/day
DIETARY_ENERGY_CONSUMED TotalCaloriesBurnedRecord βœ… βœ…
DISTANCE_* DistanceRecord βœ… βœ… metres
FLIGHTS_CLIMBED FloorsClimbedRecord βœ… βœ…
BODY_MASS / HEIGHT / BODY_FAT_PERCENTAGE / LEAN_BODY_MASS WeightRecord / HeightRecord / BodyFatRecord / LeanBodyMassRecord βœ… βœ…
BLOOD_GLUCOSE / BLOOD_PRESSURE_* / BLOOD_OXYGEN_SATURATION BloodGlucoseRecord / BloodPressureRecord / OxygenSaturationRecord βœ… ❌
BODY_TEMPERATURE BodyTemperatureRecord βœ… βœ…
RESPIRATORY_RATE RespiratoryRateRecord βœ… βœ…
VO2_MAX Vo2MaxRecord βœ… ❌
WALKING_SPEED / RUNNING_SPEED SpeedRecord (samples) βœ… ❌ m/s
RUNNING_POWER / CYCLING_POWER PowerRecord (samples) βœ… ❌ watts
DIETARY_WATER HydrationRecord βœ… βœ… litres
SLEEP_ANALYSIS SleepSessionRecord.stages βœ… βœ… stages unrolled into one sample each
MENSTRUAL_FLOW / INTERMENSTRUAL_BLEEDING / OVULATION_TEST_RESULT / CERVICAL_MUCUS_QUALITY / SEXUAL_ACTIVITY matching HC records βœ… partial
Workouts (getWorkouts) ExerciseSessionRecord βœ… ❌ workoutActivityType aligned with HKWorkoutActivityType raw values
Apple-only (APPLE_EXERCISE_TIME, APPLE_STAND_HOUR, MINDFUL_SESSION, HANDWASHING_EVENT, …) β€” β›” β›” returns empty list + logs warning. Use ExerciseSessionRecord for active time.

Reading an Apple-only type or a type Health Connect doesn't model returns [] (and writeQuantityData returns false) β€” the module never throws for unsupported types so cross-platform code keeps working.

πŸŒ™ Background sync

import { HealthKitModule } from 'react-native-nitro-healthkit';

// After login β€” the native job pulls deltas periodically and POSTs them.
await HealthKitModule.registerBackgroundSync({
  apiBaseUrl: 'https://api.example.com',
  jwtToken: '<user JWT>',
  intervalMinutes: 15,
  types: ['HKQuantityTypeIdentifierStepCount', 'HKQuantityTypeIdentifierHeartRate'],
  syncPath: '/users/health-data',
});

// On logout β€” stops the job AND wipes the stored credentials at rest.
await HealthKitModule.unregisterBackgroundSync();

// Optional: is a sync currently registered?
const active = await HealthKitModule.isBackgroundSyncRegistered();

Both platforms POST {apiBaseUrl}{syncPath} with Authorization: Bearer <jwt> and a body of { source, syncedAt, entries: [{ type, samples: [...] }] } containing the new samples since the last successful checkpoint, and clear the stored credentials on a 401/403 response.

  • Android: a WorkManager PeriodicWorkRequest (floor of 15 min). Credentials live in EncryptedSharedPreferences (AES-256-GCM, MasterKey backed by the Android Keystore).
  • iOS: a BGTaskScheduler app-refresh task (identifier com.nitrohealthkit.sync). Credentials live in the Keychain (kSecAttrAccessibleAfterFirstUnlock). Requires the one-time launch registration and Info.plist entries described in iOS Setup β€” without them the call stores credentials but the OS never runs the task. iOS schedules opportunistically, so runs are best-effort, not guaranteed at a fixed interval.

πŸ‘€ Observers

const sub = await HealthKitModule.observeQuantityChanges(
  HealthKitQuantityType.STEPS,
  (token) => {
    // Re-fetch what you need; the token is opaque (Health Connect's changes cursor).
    void HealthKitModule.getQuantityData(/* ... */);
  },
);

// later …
await HealthKitModule.removeObserver(sub);

// or drop every active subscription at once
await HealthKitModule.removeAllObservers();

The token passed to your callback is opaque and platform-specific (Health Connect's changes cursor on Android, a serialized HKQueryAnchor on iOS) β€” treat it as a "something changed" signal and re-fetch what you need. On iOS, observers use HKObserverQuery with background delivery (add the com.apple.developer.healthkit.background-delivery entitlement to keep them firing while backgrounded). On Android, Health Connect has no push channel, so the Kotlin side runs a 30-second polling coroutine per subscription.

πŸ§ͺ Testing

An example app is included in the example/ directory:

cd example/my-app
npm install
npx expo run:ios     # iOS
npx expo run:android # Android (Health Connect must be installed/active on the device)

From the repo root the Makefile exposes:

make test            # TS (jest) + Kotlin (gradle)
make test-ts
make test-android    # ./gradlew :react-native-nitro-healthkit:test

CI runs both suites on every push/PR β€” see .github/workflows/ci.yml. After editing src/specs/Example.nitro.ts, regenerate the Swift/Kotlin/C++ stubs with make nitrogen (or npm run specs inside packages/).

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

πŸ“„ License

MIT Β© N0ku β€” see LICENSE.

πŸ™ Acknowledgments

πŸ“ž Support

About

React Native Nitro module for reading and writing health data: one TypeScript API over Apple HealthKit (iOS) and Android Health Connect. Fast Swift/Kotlin/C++ interop, cache, observers, and background sync.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors