iOS: write to Apple Health when an offload lands, not only on foreground entry (#1021) - #1024
Merged
Merged
Conversation
…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.
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.writeBackhas 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: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
refreshAfterCompletedBackfillis 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 onauth == .authorizedand the existingsyncingflag.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 becauseHealthKitBridgeis iOS-only whileAppModelis shared with macOS.StrandiOSAppwires 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.lastSyncis 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 #660 —
WhoopBleClientcallsHealthConnectWriter.writeonce the backfill completes, gated on thehcWritebackopt-in, with the comment "keep the opt-in Health Connect writeback fresh in background-only operation too." Same signal, same shape.Deliberately not included
writeBackopens withguard auth == .authorized else { return }, andrefreshAuthIfPreviouslyGrantedonly 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 nolastErrorset. 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 orrefreshAuthIfPreviouslyGranted()— and the sole caller of that wasscenePhase == .active.So for a backfill completing while the app was backgrounded, or in a process BLE relaunched that the user never opened,
authwas still.unknownand 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 unlessauthis.unknownand 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
requestNewReadTypesIfNeededalready 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:
live.$lastSyncedAtdebounced 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.StateObjectautoclosure to eager is benign: itsinitis three assignments plus two capability checks, no queries, no observers, no authorization.syncingflag withsync()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 foregroundsync()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:
ShortcutHealthExport(HRV settings in one place (deep sleep window now is in units) #155), which fires onscenePhase == .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.minimaland the target is Swift 5, so the[weak bridge]closure stored on the@MainActorAppModelis not a concurrency error. The existinginitalready constructs two@MainActortypes, which is what proves thatinitis 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.analyzeRecent()scores the freshly-offloaded night before the write runs, andWidgetSnapshot.publishgoes first, so the widget is never delayed behind a multi-second Health write.Verification
Honest about the limits here:
swiftc -parseclean on all three files;Tools/doc_comment_lint.pyclean;Tools/i18n_audit.py --ci mainclean (the error string reuses the existing catalogue entry, so no new literal).app-build.ymlis disabled, so nothing in CI builds app-target Swift, and this host has no macOS toolchain. Worth anxcodebuildbefore merge.StrandTestsruns 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.