src/features/health.
Endpoints
date is a local calendar date, YYYY-MM-DD.
Provider interface
Both platforms implement the same contract, so everything above it is platform neutral.getHealthProvider() returns null on web.
Both native libraries are loaded with a guarded dynamic import() inside loadHealthKit() and loadHealthConnect(). If the module is missing, every provider method returns a safe default and the feature reports itself unavailable.
Collecting steps
Each sync re-reads a full window and sends daily totals. There is no incremental cursor.
The stored cursor only records
version and lastSyncAt. Re-sending 30 days each time means late-arriving data, such as a watch that syncs hours later, corrects earlier days. The server upserts by day.
Never sum raw samples
A phone and a watch both record the same walk. Adding raw samples counts those steps twice. Both providers avoid that in two ways:- They ask the health store for a statistics aggregate per day instead of reading samples.
- When more than one source contributed, they take the highest single source for each day with
highestSourcePerDay().
iOS
healthKit.ts queries HKQuantityTypeIdentifierStepCount with the cumulativeSum statistic in one-day intervals, anchored at the start of the window.
- If
queryStatisticsCollectionForQuantitySeparateBySourceexists, it is used and reduced withhighestSourcePerDay. The source key is the bundle identifier. - Otherwise it falls back to
queryStatisticsCollectionForQuantity. - If neither function exists it returns no days.
localIsoDate, so days follow the device’s local calendar.
Android
healthConnect.ts calls aggregateGroupByPeriod for the Steps record with a one-day slicer.
- With fewer than two data origins in the result, the combined totals are used.
- With two or more, it runs one aggregate per origin with
dataOriginFilterand reduces withhighestSourcePerDay.
Sync
syncHealthData() in sync.ts is the single entry point. It never throws for expected conditions. It returns a HealthSyncResult:
- No provider, or the provider is unavailable:
unavailable. healthStorage.isConnected()is false:not-connected.provider.hasPermissions()is false:permission.ensureAuthToken()loads the bearer token from SecureStore if memory is empty. No token:auth.- Read the cursor. A cursor with a different
versionis treated as empty. provider.collect(cursor).- If there are days,
POST /v1/trainee/health/days. - Save the new cursor.
healthSyncFailure(stage, error), which returns network when the error is an ApiError with status 0 and error otherwise, and logs the stage in development.
Step 4 is why the token is stored with the after-first-unlock keychain class on iOS. A background run starts a fresh JS context with nothing in memory, possibly while the phone is locked. See Authentication.
When sync runs
Background sync
backgroundTask.ts defines the task at module scope:
try/catch so a runtime without TaskManager, such as Expo Go, simply has no background sync.
registerHealthBackgroundTask() loads expo-background-task dynamically and registers with that minimum interval. The operating system decides the real schedule.
Registration happens only after provider.enableBackground() returns true:
- iOS calls
enableBackgroundDeliveryfor step count with theimmediatefrequency. The entitlementscom.apple.developer.healthkitandcom.apple.developer.healthkit.background-deliveryare inapp.json. - Android needs the Health Connect background read permission. If it is not granted, the app asks once, records that in
perform_health_background_prompted_v1, and never asks again after a refusal.
Connecting
Two paths lead to the same state.Profile card
HealthConnectCard uses useHealthSync(), which exposes a status:
connectHealth() requests permission, sets perform_health_connected, enables background delivery and waits for the first sync so the card can report the result. The card maps reason to a toast: permission, unavailable, network, and a generic error for auth and error.
Disconnect clears the connected flag, the cursor and the background prompt flag, and unregisters the task. It cannot revoke the OS permission. The trainee does that in system settings.
While the status is install, the hook re-checks availability each time the app becomes active, so returning from the Play Store updates the card.
First-launch prompt
promptForHealthAccessOnce() is the second of the three startup prompts, after push permission and before the app update check. See Route tree.
Rules encoded here, each covered by scripts/test-health-first-launch-prompt.cjs:
- The prompt is marked as shown before the permission request, so a crash or a kill during the sheet does not cause a second prompt.
- The first-launch path does not wait for the sync. The profile button does.
- A trainee who declines is not asked again. They can still connect from the profile.
- The whole function is wrapped in
try/catch. The prompt must never break a launch.
iOS
shouldPromptForAccess() asks HealthKit with getRequestStatusForAuthorization. It prompts only while the status is shouldRequest, meaning the system sheet would still appear. canPromptAutomatically() is always true and there is never anything to install.
Android
The app allows one automatic prompt per install, tracked byperform_health_connect_prompted, and skips it when steps access is already granted.
canPromptAutomatically() is gated on the installed app version, because the permission screen depends on native setup that older binaries lack:
The version comes from
Constants.expoConfig.version through runtimeIsAtLeast(). This gate matters because a JS update can reach an older binary. See OTA updates.
needsInstall() is true only when the SDK status is SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED. The install offer is shown once (perform_health_connect_install_offered). openInstall() tries the Play Store deep link, then the web listing.
Native setup
iOS
app.json declares the HealthKit entitlements and NSHealthShareUsageDescription, and configures the @kingstinct/react-native-healthkit plugin with background: true.
Android
Details of the plugins and the patch are in Native modules and config plugins.
Stored state
Manual steps
/track/steps renders StepsScreen in the tracking feature, where a trainee types a step count by hand. It is separate from this sync. See Tracking and uploads.