Skip to content

iOS: write to Apple Health when an offload lands, not only on foreground entry (#1021) - #1024

Merged
ryanbr merged 2 commits into
mainfrom
fix/ios-health-writeback-after-offload
Aug 1, 2026
Merged

iOS: write to Apple Health when an offload lands, not only on foreground entry (#1021)#1024
ryanbr merged 2 commits into
mainfrom
fix/ios-health-writeback-after-offload

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Fixes #1021.

The gap

The reporter's night showed up in NOOP but not in Apple Health, and their own line — "normally the data would go to Apple Health but it didn't" — is the tell. The export isn't missing; HealthKitBridge.writeBack has written sleep stages, the 1-minute HR stream, workouts and nightly vitals since #249. The trigger is wrong.

There was one automatic trigger, on scenePhase == .active:

model.ble.requestSync(.foreground)   // starts the strap offload — seconds to minutes
Task {
    health.refreshAuthIfPreviouslyGranted()
    await health.sync()              // writes to Health, in parallel
}

Those run concurrently. Open NOOP after a night and it starts pulling the sleep off the strap and writes to Health at the same moment — so Health receives the state from before the offload. The sleep lands in the database a minute later and nothing pushes it onward. It arrives at the next app open, which is exactly why this usually looks like it works.

What changes

refreshAfterCompletedBackfill is the real "new data landed" signal, and #980 already publishes the widget from it for precisely this reason — a backfill routinely completes while the app is backgrounded, where no scenePhase trigger ever fires. Apple Health was simply missed there.

Three small pieces:

  • HealthKitBridge.writeBackAfterNewData() — write-only, guarded on auth == .authorized and the existing syncing flag.
  • AppModel.healthWriteBack — an optional closure, called inside the existing #if os(iOS) block next to the widget publish. A closure rather than a direct reference because HealthKitBridge is iOS-only while AppModel is shared with macOS.
  • StrandiOSApp wires the two together at construction.

Write-only, not sync(). A full pass would re-read 30 days out of Health and re-run the hydration / caffeine imports on every offload, to write the same rows. lastSync is left alone — it marks the last two-way pass, so a write-only run advancing it would misreport when NOOP last read from Health.

Parity

This is iOS catching up. Android has done it since #660WhoopBleClient calls HealthConnectWriter.write once the backfill completes, gated on the hcWriteback opt-in, with the comment "keep the opt-in Health Connect writeback fresh in background-only operation too." Same signal, same shape.

Deliberately not included

writeBack opens with guard auth == .authorized else { return }, and refreshAuthIfPreviouslyGranted only reaches that state when every core write type is .sharingAuthorized. One toggle off in Health → Sources stops the entire write-back — sleep included, even when sleep was granted — silently, with no lastError set. That is a real second defect and an independent way to produce the same symptom, but it is not what happened here (it would have been broken on every open, not just the first), and folding a permissions rework into a trigger fix would make both harder to judge.

Re-review: the first cut didn't work in the case it was written for

The write-back guards on auth == .authorized. That state is only reached via the connect button or refreshAuthIfPreviouslyGranted() — and the sole caller of that was scenePhase == .active.

So for a backfill completing while the app was backgrounded, or in a process BLE relaunched that the user never opened, auth was still .unknown and the new write was silently dropped. It would only have fired once the app had been foregrounded that process — which is close to the bug it was fixing. The resume now runs on the write path too; it no-ops unless auth is .unknown and only reads share status.

That resume has one prompting branch — the re-request for write types added since the user's grant — and it is now foreground-gated, for the same reason requestNewReadTypesIfNeeded already is: asking where no sheet can be presented spends the single request we get and the user is never actually asked.

Also verified while re-reviewing:

  • The hook rides live.$lastSyncedAt debounced 2s past the last slice (Community and team-chat notification parity: likely out of scope? #755), so it fires once per completed backfill, not per offload slice — no write storm on a heavy history.
  • Moving the bridge's construction from the StateObject autoclosure to eager is benign: its init is three assignments plus two capability checks, no queries, no observers, no authorization.
  • Sharing the syncing flag with sync() is deliberate, now documented — two write-backs must not interleave, because the vitals dedup deletes prior samples for a key before saving. The cost is a foreground sync() arriving mid-write skipping that one read pass; it picks up on the next open.

Third pass found nothing further to change, and cleared four more things:

  • Sideloaded builds are unaffected by this race. They can't reach HealthKit at all and use ShortcutHealthExport (HRV settings in one place (deep sleep window now is in units) #155), which fires on scenePhase == .background — app exit, after the offload — not on .active. It does go stale for a backfill that completes while backgrounded, but it carries a watermark so the next write catches the same span up, and it is opt-in and off by default. Left alone rather than folded in here.
  • Strict concurrency is minimal and the target is Swift 5, so the [weak bridge] closure stored on the @MainActor AppModel is not a concurrency error. The existing init already constructs two @MainActor types, which is what proves that init is main-actor isolated.
  • enableLiveDelivery(), now reachable from a background offload via the resume, is benign. It tears down any prior observer per type before re-registering — written for exactly the "both auth paths ran" case — and HealthKit's background delivery is persistent across launches, so any user who granted access already has it armed. No new background wakes.
  • Ordering inside the hook is right: analyzeRecent() scores the freshly-offloaded night before the write runs, and WidgetSnapshot.publish goes first, so the widget is never delayed behind a multi-second Health write.

Verification

Honest about the limits here:

  • swiftc -parse clean on all three files; Tools/doc_comment_lint.py clean; Tools/i18n_audit.py --ci main clean (the error string reuses the existing catalogue entry, so no new literal).
  • Not compile-verified. app-build.yml is disabled, so nothing in CI builds app-target Swift, and this host has no macOS toolchain. Worth an xcodebuild before merge.
  • No test. HealthKit needs a real device and the entitlement, StrandTests runs in no workflow, and the added logic is a guard plus a call — a test here would assert the mock, not the fix. The behaviour to confirm on device is: sync a night with the app open, background it, and check Health has the sleep without a second launch.

ryanbr added 2 commits August 1, 2026 12:55
…und entry (#1021)

The write-back had one automatic trigger: scenePhase == .active. That is the
same block that calls requestSync(.foreground), so the Health write ran in
parallel with the offload it was meant to publish. Open NOOP after a night and
it wrote the state from BEFORE the sleep was pulled off the strap; the sleep
reached Health on the next app open, which is why it usually looked like it
worked.

refreshAfterCompletedBackfill is the real "new data landed" signal, and #980
already publishes the widget from it for exactly this reason - a backfill
routinely completes while the app is backgrounded, where no scenePhase trigger
ever fires. Apple Health was missed there.

Android has done this since #660: WhoopBleClient calls HealthConnectWriter.write
after the backfill completes. This brings iOS in line.

Write-only rather than sync(): a full pass would re-read 30 days out of Health
and re-run the hydration/caffeine imports on every offload just to write the
same rows. lastSync stays put, since it marks the last two-way pass.

Not included: writeBack still returns silently when auth is not .authorized, so
one revoked share toggle stops every write with no error surfaced. Separate
defect, separate fix - it would not have changed this user's outcome.
The write-back guards on auth == .authorized, but auth only reaches that state
via the connect button or refreshAuthIfPreviouslyGranted, and the sole caller of
that is scenePhase == .active. So in the case this fix exists for - a backfill
completing while backgrounded, or in a process BLE relaunched and the user never
opened - auth was still .unknown and the write was silently dropped. It only
worked once the app had been foregrounded that process.

Resume it on the write path. The resume is idempotent (it no-ops unless auth is
.unknown) and only reads share status.

Its one prompting branch - the re-request for write types added since the user's
grant - is now foreground-gated, for the same reason requestNewReadTypesIfNeeded
already is: asking where no sheet can be presented spends the single request we
get and the user is never actually asked.

Also documents why the write shares the syncing flag with sync(): two
write-backs must not interleave, since the vitals dedup deletes prior samples
before saving.
@ryanbr
ryanbr merged commit 063e97b into main Aug 1, 2026
2 checks passed
@ryanbr
ryanbr deleted the fix/ios-health-writeback-after-offload branch August 1, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant