The app reads the trainee’s daily step count from the phone and sends it to the server. It reads steps only and writes nothing back to the health store. iOS uses HealthKit. Android uses Health Connect. The code is 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:
  1. They ask the health store for a statistics aggregate per day instead of reading samples.
  2. 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 queryStatisticsCollectionForQuantitySeparateBySource exists, it is used and reduced with highestSourcePerDay. The source key is the bundle identifier.
  • Otherwise it falls back to queryStatisticsCollectionForQuantity.
  • If neither function exists it returns no days.
The bucket date is converted with 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 dataOriginFilter and reduces with highestSourcePerDay.

Sync

syncHealthData() in sync.ts is the single entry point. It never throws for expected conditions. It returns a HealthSyncResult:
Steps, in order:
  1. No provider, or the provider is unavailable: unavailable.
  2. healthStorage.isConnected() is false: not-connected.
  3. provider.hasPermissions() is false: permission.
  4. ensureAuthToken() loads the bearer token from SecureStore if memory is empty. No token: auth.
  5. Read the cursor. A cursor with a different version is treated as empty.
  6. provider.collect(cursor).
  7. If there are days, POST /v1/trainee/health/days.
  8. Save the new cursor.
A failure at any stage goes through 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:
The root layout imports the file for its side effect, because a task must be defined every time the JS bundle loads, including headless background launches. The definition is wrapped in 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 enableBackgroundDelivery for step count with the immediate frequency. The entitlements com.apple.developer.healthkit and com.apple.developer.healthkit.background-delivery are in app.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 by perform_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.