The trainee app reads daily step counts from Apple Health on iOS and from Health Connect on Android, and sends one total per day to the backend. Steps are the only metric read today. All of the app code is in mobile/src/features/health.

Permissions and build configuration

From mobile/app.json: The two local plugins in mobile/plugins:
  • withHealthConnectPermissionDelegate edits MainActivity to import and register HealthConnectPermissionDelegate after super.onCreate. The Health Connect permission dialog needs it.
  • withHealthConnectPermissionsRationale adds the permissions rationale entry Health Connect requires.
Both libraries are native modules. A change to any of this needs a new native build. It cannot ship as an over-the-air update.

Loading native modules safely

Each provider loads its library with a dynamic import inside a try:
When the module is missing, as in Expo Go, every provider method returns a safe default (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

Never sum raw step samples. A phone and a watch both record the same walk, and each writes its own samples. Adding them double counts.
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 with highestSourcePerDay.
  • 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

The day’s total is the largest single source, not the sum across sources. Days with zero steps are dropped. Dates are the device’s local calendar day (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:
  1. Check the provider is available, the app-side “connected” flag is set, and permission is granted.
  2. Make sure there is an auth token. A background run has no in-memory token, so it is loaded from secure storage.
  3. Load the stored cursor. A cursor whose version is not HEALTH_CURSOR_VERSION (currently 3) is discarded.
  4. provider.collect(cursor) returns day totals for the last 30 days (HEALTH_SYNC_LOOKBACK_DAYS).
  5. Post them with ingestHealthDays.
  6. Store the new cursor, which records lastSyncAt.
The sync always re-sends the whole 30 day window. That is safe because the server upserts per day. Bumping 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().
Both end in 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. When getSdkStatus() reports that the provider needs installing or updating:
  1. offerInstall shows a one-time dialog (confirmHealthInstall).
  2. On confirm it opens the Play Store listing, by market:// link first and the web URL as a fallback.
  3. whenAppReturns waits for the app to come back to the foreground, then asks for access if Health Connect is now available.
When the health store is simply unavailable, the “prompted” flag is left unset on purpose, so a later OS update that adds it still gets one prompt.

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 in apps/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.
Validated by 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.steps for (clientId, date).
Response: 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.
The heart rate fields are always null. They are kept only so older app builds that expect them keep working.

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_RATE enum value and the heart rate columns on DailyMetric are 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.