Skip to content

fix(notifications): battery alerts never requested OS notification permission - #419

Open
vishk23 wants to merge 4 commits into
ryanbr:mainfrom
vishk23:fix-visible-notifications
Open

fix(notifications): battery alerts never requested OS notification permission#419
vishk23 wants to merge 4 commits into
ryanbr:mainfrom
vishk23:fix-visible-notifications

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

Root cause

Traced why no NOOP notification — including low-battery — has ever fired:

  • behavior.batteryAlerts defaults to true (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.
  • The only place in the app that ever called the OS requestAuthorization for 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) true never transitions, so that handler never ran.
  • Result: the system permission dialog was never shown, UNAuthorizationStatus stayed .notDetermined forever, and BatteryNotifier.post()'s guard authorizationStatus == .authorized silently dropped every low/full/predictive-runtime battery notification for the entire life of the install — no error, no log, just silence.
  • The BLE battery pipeline (LiveState.setBatteryAppModel's live.onBatteryUpdate hook → BatteryNotifier.onBatteryUpdate) and the crossing/hysteresis decision logic (BatteryAlertPolicy) that decide when to fire were already correct and already covered by BatteryAlertPolicyTests. The bug was entirely in the permission-request plumbing, not the battery math.
  • Smart Alarm's own backup-notification path already asks for permission correctly (on enabling the alarm, .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 existing WindDownNudge.setEnabled pattern. Backed by a small pure decision(for:) classification (UNAuthorizationStatus → proceed / must-ask / deny) so the mapping itself is unit-testable without a live UNUserNotificationCenter.
  • Wired into two places in AutomationsView's battery card:
    1. The existing onChange handler (now reacts to denial by reverting the toggle and showing the recovery alert — a direct response to the user's own action).
    2. A new .task that 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.
  • Reused the existing "Notifications are off" recovery-alert convention from SmartAlarmView (same title/copy style, deep-links to Settings) rather than inventing a new one. Hoisted the platform-specific Settings deep-link out of SmartAlarmView into a shared NotificationPresenter.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 the decision(for:) mapping for .authorized / .provisional / .notDetermined / .denied (and .ephemeral on iOS, where that case is available — @available(macOS, unavailable)).
  • Existing StrandTests/BatteryAlertPolicyTests.swift (unchanged, still passing) already covers the crossing/hysteresis decision this PR does not touch.
  • Full StrandTests suite run and green after the change (no regressions from the SmartAlarmView/NotificationPresenter de-duplication).
  • Not covered by these tests (device/OS-only, can't be exercised in CI or a simulator unit test): the actual system permission prompt appearing, and actual notification delivery/banner display. Verifying those requires a real device or manual simulator run.

Build evidence

  • xcodegen generate — regenerated cleanly from unmodified project.yml.
  • xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO buildBUILD SUCCEEDED.
  • xcodebuild -scheme NOOPiOS -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO buildBUILD SUCCEEDED.
  • xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO testTEST SUCCEEDED (full suite, including the 4 new tests).

Files touched

  • Strand/System/BatteryNotifier.swiftensureAuthorized/decision(for:)/EnableOutcome, replacing the old fire-and-forget requestAuthorization().
  • 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 shared openSystemSettings().
  • StrandTests/BatteryNotifierAuthDecisionTests.swift — new.

vishk23 added 2 commits July 13, 2026 21:14
…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.
@vishk23

vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Author

Merged current main (296 commits, no conflicts). Re-confirmed the bug is still live upstream before refreshing: batteryCard's .onChangeCompat(of: behavior.batteryAlerts) only fires on a genuine flip, and behavior.batteryAlerts defaults ON (#368) — so a fresh install never reaches BatteryNotifier.requestAuthorization(), permission is never asked, and every BatteryNotifier.post() silently no-ops forever. main still calls requestAuthorization() only from the transition, so nothing here has been superseded.

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 main is back to exactly the original 5 files.

Verified: this is app-target-only Swift, which no default CI job compiles (swift-packages.yml covers Packages/** only and app-build.yml is disabled), so I built it: xcodegen generate + xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO buildBUILD SUCCEEDED. The permission behaviour itself (first-appearance prompt, deny → revert + recovery alert) rests on a device/desktop run, as notification permission always does.

vishk23 added 2 commits July 27, 2026 00:35
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.
@vishk23

vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Author

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:

FAIL 3 NEW literal(s) absent from their target catalog:
  Strand/Screens/AutomationsView.swift:64: 'Notifications are off'
  Strand/Screens/AutomationsView.swift:65: 'Open Settings'
  Strand/Screens/AutomationsView.swift:68: 'Turn on notifications for NOOP in Settings to get your battery alerts.'

Worth noting why, since it looks odd: Notifications are off and Open Settings are absent from every catalog — SmartAlarmView has used both since before the gate existed, so they are grandfathered by Tools/i18n_audit_baseline.json. This PR's new call sites are what makes them "new".

Fixed by adding all three to Strand/Resources/Localizable.xcstrings in 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 (three English-only keys would have pushed each of those four past its allowance and failed a second way). 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 / Impostazioni / настройки / 设置 / 設定).

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: python3 Tools/i18n_audit.py --ci upstream/main exits 0 (no new un-extracted literals, focus locales complete, no new non-focus gaps), and the macOS app still builds — xcodegen generate + xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NOBUILD SUCCEEDED.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant