fix(notifications): battery alerts never requested OS notification permission - #419
fix(notifications): battery alerts never requested OS notification permission#419vishk23 wants to merge 4 commits into
Conversation
…rmission Root cause of "never seen a single NOOP notification, including low-battery": `behavior.batteryAlerts` defaults to true (#368), so the toggle in Automations is already "on" the first time a user ever sees it. The only place that asked for notification permission was an `onChange` handler on that toggle, which never fires on an unchanged default value — so `requestAuthorization` was never called, `UNAuthorizationStatus` stayed `.notDetermined` forever, and `BatteryNotifier.post()`'s `guard authorizationStatus == .authorized` silently dropped every low/full/predictive-runtime battery notification for the life of the install. The BLE battery pipeline and the crossing/hysteresis policy that decide WHEN to fire were already correct and already tested. Fix: `BatteryNotifier.ensureAuthorized(completion:)` checks status and asks only when undetermined (mirroring WindDownNudge.setEnabled), called both on the toggle's onChange AND once, defensively, the first time the Automations battery card appears while alerts are already on — closing the gap for a default-true toggle without an app-launch prompt. Denial from an explicit toggle-flip reverts the switch and surfaces the existing "Notifications are off" recovery alert (deep-links to Settings); denial from the passive first-appearance check just reflects reality without nagging. Extracted the status classification into a pure `decision(for:)` so it's unit-testable without a live UNUserNotificationCenter, and hoisted the Settings deep-link out of SmartAlarmView into a shared `NotificationPresenter.openSystemSettings()` used by both recovery alerts. Kept "Battery alerts" defaulting ON — it's low-noise (once per charge cycle) and high-value, and the actual bug was that the default-on state had no path to ask for permission, not that it defaulted on in the first place.
The root cause is still live on current `main`: `batteryCard`'s `.onChangeCompat` only fires on a genuine flip, and `behavior.batteryAlerts` defaults ON (#368), so a fresh install never reaches `BatteryNotifier.requestAuthorization()` and every `post()` silently no-ops. The 3-way merge resolved the two places where the old base diverged from main: upstream's `strainTargetCard` is preserved and the deleted `behavior.stressNudge` toggle is NOT reintroduced. The diff against current main is back to exactly the original 5 files. Verified: macOS app builds (`xcodegen generate` + `xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO` → BUILD SUCCEEDED) — this is app-target-only Swift that no default CI job compiles.
|
Merged current Two things the old base had drifted on, both correctly resolved by the 3-way merge — worth calling out since the raw branch-vs-main diff looked alarming before the merge:
The diff against current Verified: this is app-target-only Swift, which no default CI job compiles ( |
Upstream's #850 i18n regression gate (added while this PR was open) red-checked the branch: `Notifications are off`, `Open Settings` and `Turn on notifications for NOOP in Settings to get your battery alerts.` were new UI literals with no catalog entry. The first two are absent from every catalog — `SmartAlarmView`'s existing uses predate the gate and are grandfathered by `Tools/i18n_audit_baseline.json` — so this PR's new call sites are what trips it. Adds all three to `Strand/Resources/Localizable.xcstrings` across all eight shipped locales, not just the four focus ones: de/es/fr/pt-PT satisfy the completeness check, and it/ru/zh-Hans/zh-Hant keep the non-focus "ratcheting allowance" from regressing (3 new English-only keys would have pushed each of those four past its allowance). The battery sentence mirrors the wording of the existing wind-down twin in each language, and "Settings" uses each locale's established term (Einstellungen / Ajustes / Paramètres / Definições). Verified: `python3 Tools/i18n_audit.py --ci upstream/main` exits 0 — no new un-extracted literals, focus locales complete, no new non-focus gaps.
Upstream landed more i18n work while this was open, conflicting in `Strand/Resources/Localizable.xcstrings`. Rather than hand-resolving 27 marker regions in a 3200-key JSON file, the catalog is rebuilt from UPSTREAM's copy with only this PR's three new keys re-applied on top — so every upstream translation is taken verbatim and this PR's contribution stays exactly the three battery-alert strings. Verified: `python3 Tools/i18n_audit.py --ci upstream/main` exits 0.
|
Follow-up: upstream's #850 i18n regression gate (added while this PR sat open) red-checked the branch — three new UI literals with no catalog entry: Worth noting why, since it looks odd: Fixed by adding all three to Then upstream landed more i18n work and the catalog conflicted. Rather than hand-resolving 27 marker regions inside a 3,200-key JSON file, I rebuilt the catalog from upstream's copy and re-applied only these three keys on top — every upstream translation is taken verbatim, and this PR's contribution to the file is exactly the three new strings. Verified: |
Root cause
Traced why no NOOP notification — including low-battery — has ever fired:
behavior.batteryAlertsdefaults totrue(BehaviorStore.swift, fix(oura): export-file importer — stop storing the readiness resting_heart_rate SCORE as bpm (Swift + Kotlin twin of #365) #368), so the "Battery alerts" toggle in Automations is already ON the very first time anyone opens that screen.requestAuthorizationfor battery alerts was an.onChangeCompat(of: behavior.batteryAlerts)handler — which only fires on a genuine flip of the value. A toggle that starts (and stays)truenever transitions, so that handler never ran.UNAuthorizationStatusstayed.notDeterminedforever, andBatteryNotifier.post()'sguard authorizationStatus == .authorizedsilently dropped every low/full/predictive-runtime battery notification for the entire life of the install — no error, no log, just silence.LiveState.setBattery→AppModel'slive.onBatteryUpdatehook →BatteryNotifier.onBatteryUpdate) and the crossing/hysteresis decision logic (BatteryAlertPolicy) that decide when to fire were already correct and already covered byBatteryAlertPolicyTests. The bug was entirely in the permission-request plumbing, not the battery math..notDetermined→ request); Illness Watch does too. Both default OFF, so they only ever ask if the user explicitly opts in — which doesn't help a fresh install where Battery Alerts (default ON) is the one feature that's silently broken from minute one.Fix
BatteryNotifier.ensureAuthorized(completion:)(new): checks current authorization and asks only when.notDetermined, mirroring the existingWindDownNudge.setEnabledpattern. Backed by a small puredecision(for:)classification (UNAuthorizationStatus→ proceed / must-ask / deny) so the mapping itself is unit-testable without a liveUNUserNotificationCenter.AutomationsView's battery card:onChangehandler (now reacts to denial by reverting the toggle and showing the recovery alert — a direct response to the user's own action)..taskthat runs once when the card first appears while alerts are already on — this is what actually closes the gap for the default-true toggle. It's scoped to a settings screen the user is already looking at (not a launch-time ambush), and denial there is handled silently (toggle reflects reality, no extra alert) since the user didn't take an explicit action on that control.SmartAlarmView(same title/copy style, deep-links to Settings) rather than inventing a new one. Hoisted the platform-specific Settings deep-link out ofSmartAlarmViewinto a sharedNotificationPresenter.openSystemSettings()so both recovery alerts (wind-down nudge, battery alerts) call the same code.Toggle-default decision
Kept "Battery alerts" defaulting ON. It's low-noise (at most once per charge cycle, hysteresis-gated) and high-value (nobody wants a dead strap overnight), which is exactly the profile the task suggested defaulting on. The actual bug was that the default-on state had no path to ever request OS permission — not that defaulting on was itself wrong. Fixing the permission request was the correct lever, not flipping the default off.
Tests
StrandTests/BatteryNotifierAuthDecisionTests.swift(new): pins thedecision(for:)mapping for.authorized/.provisional/.notDetermined/.denied(and.ephemeralon iOS, where that case is available —@available(macOS, unavailable)).StrandTests/BatteryAlertPolicyTests.swift(unchanged, still passing) already covers the crossing/hysteresis decision this PR does not touch.StrandTestssuite run and green after the change (no regressions from theSmartAlarmView/NotificationPresenterde-duplication).Build evidence
xcodegen generate— regenerated cleanly from unmodifiedproject.yml.xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build— BUILD SUCCEEDED.xcodebuild -scheme NOOPiOS -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build— BUILD SUCCEEDED.xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO test— TEST SUCCEEDED (full suite, including the 4 new tests).Files touched
Strand/System/BatteryNotifier.swift—ensureAuthorized/decision(for:)/EnableOutcome, replacing the old fire-and-forgetrequestAuthorization().Strand/Screens/AutomationsView.swift— battery card wiring + recovery alert.Strand/Screens/SmartAlarmView.swift— de-duplicated Settings deep-link onto the shared helper (behavior unchanged).Strand/System/NotificationPresenter.swift— new sharedopenSystemSettings().StrandTests/BatteryNotifierAuthDecisionTests.swift— new.