Add opt-in "write all data to Apple Health, automatically" - #4
Open
BLABLA1000gg wants to merge 1 commit into
Open
Add opt-in "write all data to Apple Health, automatically"#4BLABLA1000gg wants to merge 1 commit into
BLABLA1000gg wants to merge 1 commit into
Conversation
The iOS HealthKit bridge already writes NOOP's core vitals (resting HR, HRV,
blood oxygen, respiratory rate) back into Apple Health on every sync, and it
already requests sleep-share permission in `writeTypes` — but it never actually
writes sleep. This adds an opt-in that closes that gap: turn it on and NOOP
also mirrors its strap-detected sleep stages into Apple Health automatically on
each sync (the scenePhase/live-delivery syncs already in place), so "all data"
NOOP can represent lands in Health without a manual step.
- StrandImport: `NoopHealthSleepExport`, a pure, platform-agnostic parser that
turns a session's `stagesJSON` into absolute-time stage intervals. Handles the
computed segment-array format ([{start,end,stage}], mapping wake/light/deep/rem)
and falls back to a single asleepUnspecified span for the imported minutes-dict
or any undecodable JSON, so every night still lands. Unit-tested on the host.
- HealthKitBridge: `writeSleepBack` emits `sleepAnalysis` category samples with
the same HKMetadataKeyExternalUUID dedup + HKSource.default() scoping as the
vitals write-back (re-sync refreshes, never duplicates, never touches another
app's sleep). Gated behind `writeAllData` (UserDefaults) and run inside the
same sync round-trip so a failure keeps `lastSync` honest. Sleep permission is
already requested, so no new consent prompt.
- AppleHealthView: a StrandDesign toggle in the authorized live-sync card,
persisted under the bridge's key; switching it on kicks an immediate sync.
Recovery and Strain are NOOP-only scores with no Apple Health type, so they stay
in NOOP; the toggle copy says so.
jaedonvs
pushed a commit
to jaedonvs/noop
that referenced
this pull request
Aug 15, 2026
…ingle-slot arbiter (ParthJadhav#5) ParthJadhav#4/#6 (iOS): the iOS smart alarm only armed the strap firmware buzz and posted a local notification AFTER the strap reported it fired, so a failed strap buzz or a suspended phone past day one meant NO wake at all. applySmartAlarm() now also schedules a REPEATING daily UNCalendarNotificationTrigger (the WindDownNudge idiom: lives in the notification center, survives relaunch) at the wake time per selected weekday, gated on the wrist-alerts master + notification auth, replaced on every re-arm and cancelled on disarm. HONEST: it is a best-effort backup, not a guaranteed loud alarm (a sideloaded build has no critical-alert entitlement, so Focus/silent can still mute it) , the honesty card now says so and still tells users to keep their Clock alarm. ParthJadhav#5 (Android): "Buzz WHOOP 4/5" and the "Strap wake-alarm" both drove the single firmware-alarm slot with no coordination, so toggling one off ran disableStrapAlarm() and killed the other. Introduced one reconcileStrapAlarm() as the SOLE caller of arm/disable: it computes both requested times and arms the slot to the EARLIEST when either is on, else disarms. All six call sites routed through it. Test: both on, turn one off, slot stays armed to the other.
jaedonvs
pushed a commit
to jaedonvs/noop
that referenced
this pull request
Aug 15, 2026
Backup & Sync: keep-count dropdown, default 7, ~1am daily, Settings discoverability
jaedonvs
pushed a commit
to jaedonvs/noop
that referenced
this pull request
Aug 15, 2026
From #34 (alarm not buzzing): the strap consistently reported a STALE armed time regardless of what NOOP sent (its clock was reset) — the #401 readback caught it, but nothing surfaced it and the log even claimed 'armed' while disconnected. ParthJadhav#3 — Alarm block in the debug export (both platforms): the configured wake + the last arm's sent-vs-strap-reports epochs with a MISMATCH flag, so a 'didn't buzz' report is decidable at a glance. armStrapAlarm persists the sent epoch (+ when + connected); the GET_ALARM_TIME readback persists what the strap reports; the export composes them (best-effort, guarded). ParthJadhav#4 — don't log 'Alarm: armed' when the strap isn't connected (the SET was dropped). Log 'queued — will send on next connect' instead, so the log stops claiming an arm that never transmitted. Both platforms (iOS BLEManager/FrameRouter/DebugDataDiagnostics; Android WhoopBleClient/ AndroidDiagnostics). Android compiles clean; iOS gated by CI.
jaedonvs
pushed a commit
to jaedonvs/noop
that referenced
this pull request
Aug 15, 2026
…ck skew Rounds out the Alarm block to cover the full alarm-failure matrix, not just 'didn't stick': - ParthJadhav#1 last fired: persist STRAP_DRIVEN_ALARM_EXECUTED → 'Last fired: Nh ago / never observed' (the 'did the strap actually buzz?' leg of the armed→reports→fired sequence). - ParthJadhav#3 model + 5/MG experimental gate → 'Model: 5.0/MG · experimental: off → NOT armed', the ParthJadhav#1 '5/MG alarm never fires' cause (silently gated off). - ParthJadhav#4 strap clock skew: persist the newest banked record → 'Strap clock: 446d behind (reset)', the actual #34 root cause, surfaced instead of buried in the CLOCK-WARNING log line. Now the block triages: didn't arm (model/experimental/connected), didn't stick (sent-vs- reports + clock), didn't fire (last-fired). Both platforms; persists at low-frequency points (fired event, GET_DATA_RANGE). Android compiles clean; iOS gated by CI.
jaedonvs
pushed a commit
to jaedonvs/noop
that referenced
this pull request
Aug 15, 2026
The iOS ParthJadhav#3 model line compared selectedWhoopModel against "whoop5", but the stored WhoopModel rawValue is "WHOOP 5.0 / MG" — so every iOS user (incl. 5/MG) showed "Model: WHOOP 4.0" and the experimental-gate hint never rendered. Compare against WhoopModel.whoop5mg.rawValue and print the real model name. Clock-skew (ParthJadhav#4) only flagged a clock BEHIND wall; a future-dated strap clock (the #928 case, equally breaks the alarm) showed "OK". Flag AHEAD too.
jaedonvs
pushed a commit
to jaedonvs/noop
that referenced
this pull request
Aug 15, 2026
…m time (#34) When a strap's clock/alarm register is corrupted it silently rejects SET_ALARM_TIME — the readback keeps reporting an old time — and the firmware alarm never fires. Today that mismatch is detectable only in the debug export, and only as a one-shot. Instrumentation (ParthJadhav#2 — observability only, no BLE command change): - FrameRouter: on each GET_ALARM_TIME readback, count CONSECUTIVE rejections (reported != last sent). A matching readback resets the streak, so a transient (first read stale, then correct) never trips it; only a persistent refusal climbs. - recordAlarmArm: persist the strap-clock skew AT ARM. Skew ~0 while the strap still rejects ⇒ a corrupted alarm register, not a clock problem — pins whether a re-clock could ever help. - Debug export Alarm block surfaces both (clock-at-arm on Last arm; "N in a row" on Strap reports). - disableStrapAlarm clears the streak (nothing armed to refuse once disarmed). User warning (ParthJadhav#4): - SmartAlarmView shows a card, ONLY when the alarm is on and the strap has refused >=2 arms in a row, telling the user the strap isn't accepting the alarm and to reset it via the official WHOOP app / keep a phone Clock alarm. Driven by @AppStorage so it appears/clears live. Not a fix for a corrupted strap (that's firmware-side, #34) — it turns a silent, export-only failure into a persistent signal and actionable on-device guidance.
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.
What this PR does
Adds an opt-in "Write all data to Apple Health, automatically" toggle to the iOS app.
Today the iOS
HealthKitBridgealready writes NOOP's core vitals (resting HR, HRV, blood oxygen, respiratory rate) back into Apple Health on every sync — and it already requests sleep-share permission inwriteTypes, but never actually writes sleep. This PR closes that gap: with the toggle on, NOOP additionally mirrors its strap-detected sleep stages into Apple Health, on the same automatic syncs that already run (scenePhase-active and the live-delivery observers). No new consent prompt is needed because the sleep share scope was already being requested.The toggle is off by default (privacy-first, matching the bridge's "strictly opt-in" ethos). When off, behaviour is unchanged — core vitals only. Recovery and Strain are NOOP-only scores with no Apple Health type, so they stay in NOOP; the toggle copy says so.
Changes
Packages/StrandImport/…/NoopHealthSleepExport.swift— a pure, platform-agnostic parser that turns a session'sstagesJSONinto absolute-time stage intervals. Handles the computed segment-array format ([{start,end,stage}], mappingwake/light/deep/rem) and falls back to a singleasleepUnspecifiedspan for the imported minutes-dict or any undecodable JSON, so every night still lands. Stage-name mapping mirrorsSleepView.decodeSegmentsexactly. Lives in StrandImport (not the iOS target) so it compiles everywhere and is unit-testable on the host.StrandiOS/Health/HealthKitBridge.swift—writeSleepBackemitssleepAnalysiscategory samples using the sameHKMetadataKeyExternalUUIDdedup +HKSource.default()scoping as the existing vitals write-back (re-syncing a night refreshes rather than duplicates, and never touches another app's sleep). It reads sleep from the computed (deviceId + "-noop") and imported ids and unions them, mirroring the vitals precedence. Gated behind a persistedwriteAllDataflag and run inside the samesync()round-trip, so a sleep-save failure surfaces inlastErrorand does not falsely advancelastSync.Strand/Screens/AppleHealthView.swift— aStrandDesigntoggle in the authorized live-sync card, persisted under the bridge's key; switching it on kicks an immediate sync so the choice takes effect right away.Type of change
How it was tested
Not a BLE-path change. Verified locally on macOS with Xcode 26.6 (iOS 26.5 SDK):
swift testinPackages/StrandImport— addedNoopHealthSleepExportTests(6 cases: segment-array decoding with absolute times + stage mapping, unknown-stage/zero-span skipping, imported-dict fallback, nil/empty/garbage JSON fallback, degenerate span). All pass.NOOPiOSapp module compiles against the real HealthKit SDK (swiftcemitsNOOP.swiftmodule, 0 errors, no new warnings). The whole SwiftPM dependency graph also compiles for iOS. (A local end-to-end simulator run wasn't possible on this machine — the freshly-installed simulator runtime wouldn't mount — but that's asset/link packaging, not the code; the App-build CI does the full build.)I don't have a paired WHOOP strap + iPhone to observe the samples land in the Health app end-to-end, so a maintainer with hardware confirming the sleep samples appear (and dedupe on re-sync) would be appreciated.
Checklist
swift testinPackages/StrandImport)android/(N/A — noandroid/changes)StrandDesigntokens — no hardcoded colors, fonts, or spacingdocs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues