@appeeky/expo-healthkit
Apple HealthKit and Android Health Connect for Expo and React Native, built on the Expo Modules API.
One JavaScript API on both platforms. You pass Apple HealthKit type identifiers (HKQuantityTypeIdentifierStepCount, …), query or save samples, and the native layer talks to HealthKit on iOS and Health Connect on Android.
| iOS | Android | |
|---|---|---|
| Backend | HealthKit | Health Connect |
| App code | Same identifiers and methods | Same identifiers and methods |
| Availability | isAvailable() when HealthKit is present | isAvailable() when Health Connect is installed |
- Expo SDK 53+ / New Architecture (tested on SDK 57)
- Promise-based TypeScript API
- Config plugin for HealthKit entitlements and Health Connect permissions (
minSdk26) - iOS 16.4+, Android 8+ (API 26+). Web reports
isAvailable() === false
Health data APIs cannot run in Expo Go. Use a development build (
npx expo run:iosornpx expo run:android).
📋 Requirements
- Expo SDK 53 or later and a development build (not Expo Go)
- iOS 16.4+, physical iPhone or Simulator, with the HealthKit capability enabled on the App ID in the Apple Developer portal
- Android 8+ with Health Connect (built-in on Android 14+)
📦 Install
npx expo install @appeeky/expo-healthkit
From GitHub, if you are tracking main before a release:
npx expo install github:appeeky/expo-healthkit
Add the config plugin in app.json / app.config.ts:
{
"expo": {
"plugins": [
[
"@appeeky/expo-healthkit",
{
"healthSharePermission": "Allow $(PRODUCT_NAME) to read your health data",
"healthUpdatePermission": "Allow $(PRODUCT_NAME) to write health data",
"isBackgroundDeliveryEnabled": true
}
]
],
"ios": {
"bundleIdentifier": "com.example.app"
}
}
}
Then rebuild native code:
npx expo prebuild --clean
npx expo run:ios
# or
npx expo run:android
Enable the HealthKit capability on the App ID in the Apple Developer portal. The plugin writes the entitlement into the generated Xcode project; the capability still has to be allowed for that bundle ID.
On Android, install Health Connect if the OS does not already include it (built-in on Android 14+). The plugin declares the Health Connect permissions (all mapped types by default; narrow with healthConnectPermissions) and raises minSdk to 26. The module's own manifest declares none, so a bare React Native app that does not run the config plugin must add the android.permission.health.* uses-permission entries it needs to AndroidManifest.xml itself.
⚡️ Quick start
import * as HealthKit from '@appeeky/expo-healthkit';
if (!HealthKit.isAvailable()) {
return;
}
await HealthKit.requestAuthorization({
toRead: [
HealthKit.QuantityType.stepCount,
HealthKit.QuantityType.heartRate,
HealthKit.CategoryType.sleepAnalysis,
HealthKit.WorkoutType.workout,
],
toShare: [HealthKit.QuantityType.stepCount],
});
const today = new Date();
today.setHours(0, 0, 0, 0);
const steps = await HealthKit.queryStatistics({
type: HealthKit.QuantityType.stepCount,
unit: HealthKit.Unit.count,
from: today,
to: new Date(),
options: HealthKit.StatisticsOption.cumulativeSum,
});
const heartRate = await HealthKit.queryQuantitySamples({
type: HealthKit.QuantityType.heartRate,
unit: HealthKit.Unit.countPerMinute,
from: today,
to: new Date(),
limit: 20,
ascending: false,
});
📚 API
🔐 Availability and permissions
| Method | Notes |
|---|---|
isAvailable() | true on iOS when HealthKit is present, and on Android when Health Connect is installed |
getSupportedTypes() | Identifiers this platform can provide. Static, no prompt. iOS: what HealthKit resolves on this OS; Android: the Health Connect mapping; web: [] |
getUnsupportedTypes(identifiers) | The subset of identifiers this platform cannot provide |
requestAuthorization({ toRead, toShare, includeBackgroundRead }) | Shows the system permission sheet (HealthKit or Health Connect). includeBackgroundRead adds Health Connect's READ_HEALTH_DATA_IN_BACKGROUND (Android only; needs the plugin's isHealthConnectBackgroundReadEnabled) |
getAuthorizationStatus(type) | iOS: reliable for write types. Android: reflects granted Health Connect read or write access |
getRequestStatusForAuthorization({ toRead, toShare }) | Whether you still need to prompt |
getGrantedPermissions() | Android: granted android.permission.health.* strings. iOS: identifiers this app may write — HealthKit does not disclose read grants |
requestPermissions(permissions) | Android: request raw android.permission.health.* strings, resolves with the granted subset. iOS: throws ERR_HEALTHKIT_UNSUPPORTED |
revokeAllPermissions() | Android: in-app "disconnect Health Connect". The revoke takes effect when the app process ends, and Android may end it right away; until then getGrantedPermissions() still lists the grants. iOS: throws ERR_HEALTHKIT_UNSUPPORTED (HealthKit has no revoke) |
On iOS, read authorization is intentionally opaque: getAuthorizationStatus returning notDetermined / sharingDenied does not mean the user blocked reads.
ERR_HEALTHKIT_UNSUPPORTED is the iOS mirror of ERR_HEALTH_CONNECT_UNSUPPORTED: the method exists on both platforms so app code needs no Platform.OS fork, and the one backend that cannot do it says so.
📊 Samples and statistics
| Method | Use for | |
|---|---|---|
| ❤️ | queryQuantitySamples({ type, unit, from, to, limit, ascending }) | Heart rate, weight, glucose, … |
| 😴 | queryCategorySamples({ type, from, to, limit, ascending }) | Sleep, mindful sessions, … |
| 🏃 | queryWorkouts({ from, to, limit, ascending, activityType }) | Workouts |
| 🫀 | queryElectrocardiograms({ from, to, limit, ascending, includeVoltage }) | ECG samples. Voltage is omitted unless includeVoltage: true |
| 🔴 | queryActivitySummaries({ from, to }) | Move / Exercise / Stand rings |
| 🏥 | queryClinicalRecords({ type, from, to, limit, ascending }) | FHIR clinical records. Needs isClinicalDataEnabled |
| 👂 | queryAudiograms({ from, to, limit, ascending }) | Hearing-test audiograms |
| 📍 | queryWorkoutRoute({ workoutUUID }) | GPS locations for a workout |
| 🩺 | queryCorrelations({ type, from, to, limit, ascending }) | Blood pressure and food correlations |
| 💓 | queryHeartbeatSeries({ from, to, limit, ascending, includeBeats }) | Beat-to-beat series used for HRV |
| 📈 | queryStatistics({ type, unit, from, to, options }) | Totals / min / max / average for a range |
| 📅 | queryStatisticsCollection({ type, unit, from, to, interval, options }) | Daily (or hourly) buckets |
| 🔄 | queryAnchored({ type, unit, from, to, limit, anchor }) | Incremental sync. Store anchor, pass it back while hasMore |
limit defaults to HealthKit's unlimited query. Dates accept Date or ISO-8601 strings. Units are HealthKit unit strings (count, count/min, kcal, kg, m, %, …) also exported as Unit.
Source filters
Every query above (except activity summaries and workout routes) accepts sources and excludeSources. Both take iOS bundle identifiers / Android package names; 'self' in excludeSources is this app. Use it to skip your own writes when you sync:
const heartRate = await HealthKit.queryQuantitySamples({
type: HealthKit.QuantityType.heartRate,
unit: HealthKit.Unit.countPerMinute,
from: today,
limit: 100,
excludeSources: ['self'],
});
const watchOnly = await HealthKit.queryWorkouts({
sources: ['com.apple.health'],
});
The filters run natively, before limit, on both platforms: limit: 100 with excludeSources: ['self'] returns the 100 newest samples that other apps wrote, not 100 rows minus yours. On iOS they compile to an HKQuery.predicateForObjects(from:) predicate; on Android sources is the request's dataOriginFilter and excludeSources is applied to each page before the limit is counted. queryStatistics / queryStatisticsCollection on Android support sources only; excludeSources there throws ERR_HEALTH_CONNECT_UNSUPPORTED. queryAnchored filters added samples; deletions carry no source and are always returned.
Anchored sync
queryAnchored returns { added, deleted, anchor, hasMore }. Apply the page, persist anchor, and call again while hasMore is true. Treat added as upserts keyed by uuid: a sample can arrive twice.
let anchor = await loadAnchor(); // null on first sync
let hasMore = true;
while (hasMore) {
const page = await HealthKit.queryAnchored({ type, unit, limit: 500, anchor });
await applyPage(page.added, page.deleted);
anchor = page.anchor;
hasMore = page.hasMore;
await saveAnchor(anchor);
}
- The anchor is an opaque token, not a timestamp. On iOS it is an archived
HKQueryAnchor; on Android it wraps a Health Connect changes token (plus a page token during the first sync). It is not portable between platforms, between apps, or from another library — an anchor saved by@kingstinct/react-native-healthkitorreact-native-health-connect, or an ISO date from a timestamp-based cursor, is not a valid token here. Persist it per platform and per type, and when migrating from another cursor start withanchor: nulland dedupe byuuid. - Android, first sync. With no anchor, the existing records are read in pages of
limitrecords (a heart-rate record can expand to many samples; nolimitreads everything in one call). The changes token is taken before the first page, so records written or deleted meanwhile arrive as changes afterwards —hasMorestays true until those are read. - Android, expired or rejected anchor. Health Connect keeps its change log for 30 days, so a token unused for longer expires.
queryAnchoredthen throwsERR_HEALTH_CONNECT_ANCHOR_EXPIREDinstead of returning a delta: deletions made during the gap are lost, so restart withanchor: nulland reconcile your copy against the full read. Health Connect reports a token it does not recognise (for example one from another library) the same way. A malformed anchor throwsERR_INVALID_ANCHOR, and other Health Connect errors are thrown as-is. The restart's read is limited to the last 30 days unless the user grantedREAD_HEALTH_DATA_HISTORY(requested with everytoRead). - iOS. HealthKit does not say whether more results remain, so
hasMoreis true whenever a page is full; the next call may come back empty. A string that is not base64 is treated as "no anchor" and runs the initial fetch again (only a base64 string that is not anHKQueryAnchorarchive rejects).
ECG, activity rings, clinical records, audiograms, workout GPS routes, heartbeat series, and correlations are not quantity/category/workout samples. Request the matching identifier in toRead:
await HealthKit.requestAuthorization({
toRead: [
HealthKit.ElectrocardiogramType.electrocardiogram,
HealthKit.ActivitySummaryType.activitySummary,
HealthKit.ClinicalType.allergyRecord,
HealthKit.AudiogramType.audiogram,
HealthKit.WorkoutType.workout,
HealthKit.SeriesType.workoutRoute,
HealthKit.SeriesType.heartbeat,
HealthKit.CorrelationType.bloodPressure,
HealthKit.CorrelationType.food,
],
});
const rings = await HealthKit.queryActivitySummaries({ from: today });
const [workout] = await HealthKit.queryWorkouts({ limit: 1 });
const routes = workout
? await HealthKit.queryWorkoutRoute({ workoutUUID: workout.uuid })
: [];
const pressure = await HealthKit.queryCorrelations({
type: HealthKit.CorrelationType.bloodPressure,
limit: 20,
});
Clinical reads also need isClinicalDataEnabled: true on the config plugin.
✍️ Write / delete
await HealthKit.saveQuantitySample({
type: HealthKit.QuantityType.stepCount,
unit: HealthKit.Unit.count,
value: 100,
startDate: new Date(Date.now() - 60_000),
endDate: new Date(),
});
await HealthKit.saveCategorySample({
type: HealthKit.CategoryType.sleepAnalysis,
value: HealthKit.SleepAnalysisValue.asleepCore,
startDate,
endDate,
});
await HealthKit.saveWorkout({
activityType: HealthKit.WorkoutActivityType.running,
startDate,
endDate,
energyBurned: 400,
energyBurnedUnit: HealthKit.Unit.kilocalorie,
distance: 5000,
distanceUnit: HealthKit.Unit.meter,
});
await HealthKit.saveCorrelation({
type: HealthKit.CorrelationType.bloodPressure,
startDate: new Date(),
objects: [
{
type: HealthKit.QuantityType.bloodPressureSystolic,
unit: HealthKit.Unit.millimeterOfMercury,
value: 120,
},
{
type: HealthKit.QuantityType.bloodPressureDiastolic,
unit: HealthKit.Unit.millimeterOfMercury,
value: 80,
},
],
});
await HealthKit.deleteObjects({
type: HealthKit.QuantityType.stepCount,
uuid,
});
👤 Characteristics
getBiologicalSex(), getBloodType(), getDateOfBirth(), getFitzpatrickSkinType(), getWheelchairUse(). These are read-only HealthKit characteristics; include the matching CharacteristicType in toRead.
🔔 Background updates
await HealthKit.observe([HealthKit.QuantityType.stepCount]);
const subscription = HealthKit.addUpdateListener(async ({ type }) => {
// Re-query that type. Return a promise: iOS keeps the app alive and holds
// the HealthKit completion handler until it settles (~25s cap).
await syncSteps();
});
await HealthKit.enableBackgroundDelivery(
HealthKit.QuantityType.stepCount,
HealthKit.UpdateFrequency.hourly
);
useHealthKitUpdates() is a React hook around the same event.
Background delivery needs isBackgroundDeliveryEnabled: true on the config plugin and the HealthKit background mode.
Killed-app relaunch. Types passed to observe are persisted natively. When iOS relaunches the app in the background for a HealthKit delivery, the observer queries are re-registered in didFinishLaunching (via an Expo AppDelegate subscriber), before the JS runtime boots. The delivery is queued until JS calls addUpdateListener, then dispatched. Register your listener at module scope (or early in the root component) so it exists on cold start; getObservedTypes() tells you what is already being observed. clearObservers() stops and forgets them.
Apple limits. Step count, distance, energy, exercise/stand time and similar activity types are delivered at most once per hour regardless of UpdateFrequency; heart rate, sleep, workouts and body measurements are not capped. Each delivery gets ~30 s of background time; the library completes at 25 s if your listener hasn't. The Simulator never relaunches a killed app for HealthKit — test on a device.
Debugging. Native logs are under the expo-healthkit os_log subsystem (restore, delivery, complete reason=js|timeout). Full guide with a device test recipe: .cursor/skills/expo-healthkit/background-delivery.md.
🏷️ Identifiers
Pass Apple's raw identifier strings. Named constants are provided for autocomplete; unknown identifiers still work if HealthKit knows them.
HealthKit.QuantityType.stepCount
// 'HKQuantityTypeIdentifierStepCount'
HealthKit.queryQuantitySamples({
type: 'HKQuantityTypeIdentifierStepCount',
unit: 'count',
});
App code should keep using Apple identifier strings on both platforms. See Cross-platform for what Health Connect maps, what stays iOS-only, and how to handle those APIs without Platform.OS forks.
🌍 Cross-platform
JavaScript always speaks HealthKit. You never pass Health Connect record class names from app code. The native layer chooses the backend:
flowchart LR
App["App JS<br/>QuantityType.stepCount"] --> API["@appeeky/expo-healthkit"]
API --> iOS["iOS<br/>HealthKit"]
API --> Android["Android<br/>Health Connect"]
Mapped types (steps, heart rate, sleep, workouts, weight, blood pressure, nutrition, …) use the same method and identifier on both platforms. Unmapped identifiers are skipped in Android requestAuthorization, so an unsupported type and an empty one look alike; ask first:
const missing = HealthKit.getUnsupportedTypes([
HealthKit.QuantityType.vo2Max,
HealthKit.QuantityType.appleExerciseTime,
]);
// Android: ['HKQuantityTypeIdentifierAppleExerciseTime'] — say "not on this phone", not "no data"
Calling an Apple-only method on Android throws ERR_HEALTH_CONNECT_UNSUPPORTED instead of requiring if (Platform.OS === 'ios') on every query.
Hide Apple-only UI by catching that code:
try {
const rings = await HealthKit.queryActivitySummaries({ from: today });
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ERR_HEALTH_CONNECT_UNSUPPORTED') {
return;
}
throw error;
}
What works on both
| Area | Identifiers / methods |
|---|---|
| Activity | QuantityType.stepCount, walking/running/cycling/swimming/wheelchair distance, flightsClimbed, pushCount, activeEnergyBurned, basalEnergyBurned |
| Heart | heartRate, restingHeartRate, heartRateVariabilitySDNN, vo2Max, oxygenSaturation, respiratoryRate |
| Body | bodyMass, height, bodyFatPercentage, leanBodyMass, bodyTemperature, bloodGlucose |
| Blood pressure | CorrelationType.bloodPressure plus systolic / diastolic quantities |
| Nutrition | CorrelationType.food, dietaryWater, other HKQuantityTypeIdentifierDietary* types. A food correlation's HKFoodType metadata maps to NutritionRecord.name |
| Sleep | CategoryType.sleepAnalysis |
| Workouts | queryWorkouts / saveWorkout (WorkoutActivityType mapped onto Health Connect exercise types) |
| Aggregates | queryStatistics, queryStatisticsCollection, queryAnchored |
| Writes | saveQuantitySample, saveCategorySample, saveCorrelation, deleteObjects |
iOS-only
These throw ERR_HEALTH_CONNECT_UNSUPPORTED on Android. Health Connect has no equivalent (or no stable mapping yet):
| API | Why |
|---|---|
queryElectrocardiograms | Apple Watch ECG |
queryActivitySummaries | Move / Exercise / Stand rings |
queryClinicalRecords | FHIR health records |
queryAudiograms | Hearing-test charts |
queryWorkoutRoute | Workout GPS polylines |
queryHeartbeatSeries | Beat-to-beat series |
getBiologicalSex, getBloodType, getDateOfBirth, getFitzpatrickSkinType, getWheelchairUse | HealthKit characteristics |
observe, getObservedTypes, enableBackgroundDelivery | HealthKit observer queries / background delivery |
Behavioral differences
| iOS | Android | |
|---|---|---|
| Backend | HealthKit | Health Connect (connect-client 1.1) |
isAvailable() | HealthKit present | Health Connect installed (built-in on Android 14+; otherwise the Health Connect app) |
| Read permission | Apple does not disclose read grants; getGrantedPermissions lists write grants only | getAuthorizationStatus reflects granted Health Connect read or write; getGrantedPermissions returns the raw permission strings |
| Revoke | None (revokeAllPermissions throws ERR_HEALTHKIT_UNSUPPORTED) | revokeAllPermissions() |
| Background reads | Observer queries / background delivery | requestAuthorization({ includeBackgroundRead: true }) plus the plugin's isHealthConnectBackgroundReadEnabled |
Unmapped types in requestAuthorization | Forwarded if HealthKit knows them | Skipped |
| History window | Granted samples, any age | Often last 30 days unless the user also grants history access (the SDK requests it) |
| Anchored sync | HKQueryAnchor (opaque string) | Paged first sync, then Health Connect changes (opaque string). Expired tokens throw ERR_HEALTH_CONNECT_ANCHOR_EXPIRED — see Anchored sync. Do not reuse an iOS anchor on Android |
| HRV | SDNN. Samples carry statistic: 'sdnn' | Mapped to Health Connect RMSSD on the same heartRateVariabilitySDNN identifier — not the same statistic. Samples carry statistic: 'rmssd'; branch on it before charting the two together |
| Workout GPS | queryWorkoutRoute | Unsupported |
minSdk | iOS 16.4 | API 26 (plugin raises it) |
Do not fork mapped queries per platform. Fork only when you need different UI for an Apple-only feature.
🔌 Config plugin
| Prop | Default | Purpose |
|---|---|---|
healthSharePermission | "Allow $(PRODUCT_NAME) to read your health data" | NSHealthShareUsageDescription |
healthUpdatePermission | "Allow $(PRODUCT_NAME) to write health data" | NSHealthUpdateUsageDescription |
healthClinicalRecordsPermission | clinical default | Used when isClinicalDataEnabled is true |
isBackgroundDeliveryEnabled | false | HealthKit background delivery entitlement |
isClinicalDataEnabled | false | health-records access |
healthConnectPrivacyPolicyUrl | — | Shown on the Health Connect permission rationale screen |
healthConnectPermissions | every permission the module maps (47, incl. READ_HEALTH_DATA_HISTORY) | Health Connect uses-permission entries to declare. READ_STEPS or android.permission.health.READ_STEPS. When set, only these are declared |
isHealthConnectBackgroundReadEnabled | false | Declares READ_HEALTH_DATA_IN_BACKGROUND for requestAuthorization({ includeBackgroundRead: true }) |
The plugin also sets android.minSdkVersion to 26 (Health Connect’s floor) unless the project already uses a higher value.
The plugin is the only place Health Connect permissions are declared; the module's own AndroidManifest.xml declares none. Play Console asks you to justify every declared Health Connect data type and the permission sheet shows the user the full list, so narrow it to what you query:
["@appeeky/expo-healthkit", { "healthConnectPermissions": ["READ_STEPS", "READ_HEART_RATE", "READ_SLEEP", "READ_HEALTH_DATA_HISTORY"] }]
A type you query without declaring its permission is skipped at requestAuthorization and reads as empty. HEALTH_CONNECT_PERMISSIONS (exported from the plugin) is the default list.
Set a permission string to false to skip writing that Info.plist key (if you manage it yourself).
📱 Example app
cd example
npm install
npx expo run:ios
# or
npx expo run:android
The example is an Expo SDK 57 app that autolinks this module on iOS and Android. It will not work in Expo Go.
🤖 Agent skills
Coding agents (Cursor, Claude Code, Copilot, and similar) should load the bundled skill.
Copy .cursor/skills/expo-healthkit/ into your app’s .cursor/skills/expo-healthkit/ (or the agent’s skills folder):
| File | What agents get |
|---|---|
SKILL.md | When to use which query, auth rules, and plugin flags |
reference.md | Method signatures, units, identifiers |
examples.md | Copy-paste flows (steps, sleep, rings, blood pressure, food) |
The skill tells the agent to pass Apple identifier strings through one query/save API on iOS and Android, request every type in toRead, and rebuild native code after Swift, Kotlin, or plugin changes.
🚀 Releases
Versions follow semver. Pushing to main with a Conventional Commit runs .github/workflows/release.yml: bump package.json, update CHANGELOG.md, GitHub Release, and npm publish.
| Commit | npm bump |
|---|---|
fix: … | patch (0.1.0 → 0.1.1) |
feat: … | minor (0.1.0 → 0.2.0) |
feat!: … or BREAKING CHANGE: in the body | major (0.1.0 → 1.0.0) |
docs:, chore:, ci:, test: | no release |
Squash-merge PRs so the merge commit (or PR title) starts with fix: / feat:. chore: and ci: do not publish.
npm publishes from GitHub Actions via trusted publishing (no NPM_TOKEN). One-time setup: package Settings → Trusted Publisher → GitHub Actions, repository appeeky/expo-healthkit, workflow filename release.yml, and allow npm publish (not only staged publish).
🤝 Contributing
Issues and pull requests are welcome. Contributions are licensed under PolyForm Shield 1.0.0.
Checks
CI on main and every PR runs lint, typecheck, Jest, and an npm pack contents check (.github/workflows/ci.yml). Run the same locally before opening a PR:
npm ci
npm run lint
npm run typecheck
npm test -- --watchAll=false
npm run test:coverage
npm test opens Jest watch mode on a TTY. Pass --watchAll=false (as CI does) for a single run. test:coverage writes an HTML report to coverage/ and must stay green for the JavaScript that tests already cover (src/identifiers.ts, src/errors.ts, src/dates.ts).
What to test
| Change | Expected coverage |
|---|---|
| Identifiers, errors, date helpers, public JS types | Add or extend Jest in src/__tests__/. Keep npm run test:coverage passing |
| Config plugin | Typecheck plugin/; if you add pure helpers, put Jest next to them |
| Kotlin Health Connect mapping | Update the Cross-platform tables and .cursor/skills/expo-healthkit/. Run the example on an Android device with Health Connect |
| Swift HealthKit | Same skill/README update. Run the example on Simulator or a device with the HealthKit capability |
| Example UI | cd example && npx expo run:ios / npx expo run:android. Health APIs do not run in Expo Go |
Native HealthKit / Health Connect cannot run in Node. Device or Simulator verification is the coverage for Swift and Kotlin. Do not add Platform.OS branches in example/ for types that already map.
Native rebuild
Metro reload is not enough after Swift, Kotlin, or plugin edits:
npx expo prebuild --clean
npx expo run:ios
# or
npx expo run:android
Docs
If the public API or Android mapping changes, update README.md and .cursor/skills/expo-healthkit/ (SKILL.md, reference.md, examples.md) in the same PR. CHANGELOG.md is generated on release from conventional commits.
🔒 Privacy
Health data stays on-device unless your app uploads it. Request only the types you need, explain why in the iOS usage strings, declare the matching Health Connect data types in Play Console, and follow Apple HealthKit and Google Health Connect review guidelines. Android reads are often limited to the last 30 days unless the user also grants history access.
📄 License
PolyForm Shield 1.0.0. See LICENSE for the full terms.
You can install and use this SDK inside your own Expo / React Native apps, including commercial products.
You cannot copy this SDK and sell, publish, or otherwise offer a competing HealthKit / Health Connect library based on it.