mobile/src/features/health.
Permissions and build configuration
Frommobile/app.json:
The two local plugins in
mobile/plugins:
withHealthConnectPermissionDelegateeditsMainActivityto import and registerHealthConnectPermissionDelegateaftersuper.onCreate. The Health Connect permission dialog needs it.withHealthConnectPermissionsRationaleadds the permissions rationale entry Health Connect requires.
Loading native modules safely
Each provider loads its library with a dynamic import inside atry:
false, or an empty result). The same pattern guards expo-task-manager and expo-background-task in backgroundTask.ts. Follow it for any native-only import in this feature.
The aggregation rule
Both providers ask the platform for per-day statistics and never read individual samples.iOS
collect queries a statistics collection for HKQuantityTypeIdentifierStepCount, with a cumulative sum, a daily interval and a start date 30 days back.
- When the library offers
queryStatisticsCollectionForQuantitySeparateBySource, the app gets one bucket per day per source and keeps the highest source for each day withhighestSourcePerDay. - Otherwise it falls back to
queryStatisticsCollectionForQuantity, where HealthKit has already merged the sources.
Android
collect calls aggregateGroupByPeriod on the Steps record with a one-day slicer and reads COUNT_TOTAL.
- If the combined result names fewer than two data origins, the combined totals are used as they are.
- With two or more origins it re-runs the aggregate once per origin, using
dataOriginFilter, and keeps the highest origin per day.
highestSourcePerDay
localIsoDate), in YYYY-MM-DD form.
Sync
syncHealthData() in lib/sync.ts is the only routine that sends data. It returns a HealthSyncResult with a reason:
Steps:
- Check the provider is available, the app-side “connected” flag is set, and permission is granted.
- Make sure there is an auth token. A background run has no in-memory token, so it is loaded from secure storage.
- Load the stored cursor. A cursor whose
versionis notHEALTH_CURSOR_VERSION(currently 3) is discarded. provider.collect(cursor)returns day totals for the last 30 days (HEALTH_SYNC_LOOKBACK_DAYS).- Post them with
ingestHealthDays. - Store the new cursor, which records
lastSyncAt.
HEALTH_CURSOR_VERSION is how a release forces every install to forget its stored cursor.
Triggers
The background task is registered only when
provider.enableBackground() succeeds. On Android that means the trainee granted BackgroundAccessPermission. The app asks for it once (hasPromptedForBackground) and does not ask again after a refusal.
Connecting
Two paths lead to the same state, by design:- The Connect button on the profile card calls
connectHealth(). - The first launch after sign-in calls
promptForHealthAccessOnce().
grantAccess (request permission, then set the connected flag) and startSyncing (enable background, register the task, run a sync).
App-side state is in secure storage (healthStorage): connected, prompted, prompted for background, install offered, and the cursor.
The first-launch prompt
Whether to ask comes from the provider.
A trainee who declines is not asked again automatically. They can still connect from the profile.
Android version gate
permissionDialogMinRuntime() picks the minimum app version by Android API level:
runtimeIsAtLeast compares Constants.expoConfig.version against the minimum. An app below it never prompts on its own, though the profile card can still start the connection.
Android without Health Connect
On Android 13 and below, Health Connect is a separate app. WhengetSdkStatus() reports that the provider needs installing or updating:
offerInstallshows a one-time dialog (confirmHealthInstall).- On confirm it opens the Play Store listing, by
market://link first and the web URL as a fallback. whenAppReturnswaits for the app to come back to the foreground, then asks for access if Health Connect is now available.
Disconnecting
useHealthSync().disconnect clears the connected flag and unregisters the background task. It does not revoke the OS permission, and it does not delete anything on the server.
Backend
Routes are inapps/core-api/src/modules/trainee/trainee.routes.ts, behind trainee bearer auth and the app access check.
POST /v1/trainee/health/days
The live ingestion endpoint.
ingestHealthDaysBody: 1 to 90 entries (HEALTH_MAX_DAYS_PER_INGEST), date as YYYY-MM-DD, steps a non-negative integer up to 100000 (MAX_DAILY_STEPS).
ingestHealthDays in trainee.service.ts:
- Resolves each date in the trainee’s time zone.
- Silently skips days older than 90 days or more than one day in the future.
- When a date appears twice, the last one wins.
- Upserts
DailyMetric.stepsfor(clientId, date).
201 with { "days": N }, the number of days written.
Because it is an upsert, a later sync with a higher total for today simply replaces the earlier value. It also replaces a value the trainee typed by hand for that day.
GET /v1/trainee/health/summary
Query: optional from and to dates. Defaults to the last 7 days.
POST /v1/trainee/health/samples is retired
The first version of the sync sent raw samples. The route still exists so old app builds do not get an error: it validates the body with ingestHealthSamplesBody and answers 201 with a fixed result. It stores nothing.
The HealthSample table and the HealthMetricType and HealthSource enums remain in the schema from that version. No current code inserts into the table. The only code that touches it is the trainee account deletion, which clears it.
Where the steps are used
DailyMetric.steps feeds the movement summary on the trainee home screen and the coach’s view of the daily steps requirement. See the training plan requirement in the data model section for how a steps goal is defined.
Limits and gotchas
- Only steps are read. The
HEART_RATEenum value and the heart rate columns onDailyMetricare not populated by the app. - Step counts arrive at most 30 days back on a fresh connect.
- The server trusts the device’s day boundaries and the totals it sends. There is no server-side cross-check.
- Health Connect totals can differ from what a wearable’s own app shows, because the rule keeps the single highest source per day.