From c34a8df9bc9ad21348ccb9eb837fb83dfc2ef6f9 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 15 Sep 2026 22:07:23 -0500 Subject: [PATCH 1/5] Play the configured alert sound on AlarmKit critical alarms Builds without the Critical Alerts entitlement raise urgent glucose alarms through AlarmKit on iOS 26, and the alarm was scheduled with no sound, so it always played AlarmKit's default tone regardless of the sound chosen in Loop. The notification path had been passing the configured sound all along; it just isn't what makes the noise on those builds. Pass the alert's sound by name. The bundled alarm sounds are IMA4 .caf files under 30 seconds at the bundle root, which are the same constraints a notification sound has, so AlarmKit can play them directly. --- .../Alerts/CriticalAlertAlarmScheduler.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift b/Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift index 6182232724..e089bfbcfa 100644 --- a/Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift +++ b/Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift @@ -19,6 +19,7 @@ import LoopKit import os.log #if canImport(AlarmKit) +import ActivityKit import AlarmKit import AppIntents import struct SwiftUI.Color // Color only; `import SwiftUI` would make `Alert` ambiguous with LoopKit.Alert @@ -92,14 +93,19 @@ final class CriticalAlertAlarmScheduler { let attributes = AlarmAttributes(presentation: presentation, tintColor: .red) // Fire immediately. No countdownDuration (preAlert nil) → alert-only, - // so no Widget Extension / Live Activity is required. Default alarm - // sound (our .caf alarm sounds aren't guaranteed AlarmKit-compatible). - // The stop button runs StopCriticalAlertIntent, which acknowledges the - // corresponding Loop alert (in addition to AlarmKit stopping the alarm). + // so no Widget Extension / Live Activity is required. The stop button + // runs StopCriticalAlertIntent, which acknowledges the corresponding + // Loop alert (in addition to AlarmKit stopping the alarm). + // + // The alert's configured sound is a bundled IMA4 .caf under 30s, which + // is the same constraint as a notification sound, so AlarmKit can play + // it by name from the main bundle. + let sound: AlertConfiguration.AlertSound = alert.sound?.filename.map { .named($0) } ?? .default let configuration = AlarmManager.AlarmConfiguration.alarm( schedule: .fixed(Date().addingTimeInterval(Self.immediateFireDelay)), attributes: attributes, - stopIntent: StopCriticalAlertIntent(identifier: alert.identifier) + stopIntent: StopCriticalAlertIntent(identifier: alert.identifier), + sound: sound ) let id = UUID() From 6072aedf995b48fdcf6abf3b716ebbe597b456dc Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 15 Sep 2026 23:15:18 -0500 Subject: [PATCH 2/5] Don't re-evaluate glucose alerts from a backfill older than the last reading CGMs deliver the live reading and then backfill the gap behind it, each as its own batch. evaluate() re-ran on the backfill, which carries a sample no newer than the one just evaluated, and re-decided the alert from it. Seen with the G7: a live reading raised an urgent low at 22:58:59 and the backfill of the same period retracted it at 22:59:00, which also stopped the in-process alarm audio one second after it started. Skip any batch whose newest sample is not newer than the last evaluated reading. Backfill still reaches the glucose store; it just cannot override an alert decision made on a newer reading. (cherry picked from commit 125a01bc4e9d1d7e8ac32725858b27f73e05ae63) --- Loop/Managers/Alerts/GlucoseAlertManager.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Loop/Managers/Alerts/GlucoseAlertManager.swift b/Loop/Managers/Alerts/GlucoseAlertManager.swift index 45b3ce8b38..aa04e34817 100644 --- a/Loop/Managers/Alerts/GlucoseAlertManager.swift +++ b/Loop/Managers/Alerts/GlucoseAlertManager.swift @@ -559,6 +559,13 @@ final class GlucoseAlertManager: ObservableObject { os_log("Skipping stale sample", log: log, type: .debug) return } + // CGMs deliver the live reading and then backfill. A batch whose newest + // sample is no newer than the one already evaluated must not re-decide + // — and in particular must not retract — the alert that reading raised. + if let evaluated = latestReading, latest.date <= evaluated.date { + os_log("Skipping batch older than the latest evaluated reading", log: log, type: .debug) + return + } latestReading = (latest.quantity.doubleValue(for: .milligramsPerDeciliter), latest.date) let config = activeConfiguration(at: now) let mgdl: Double From 51d0b3eaa0f376465813da730a1d7eadef58c80a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 15 Sep 2026 23:21:57 -0500 Subject: [PATCH 3/5] Show AlarmKit authorization under iOS Permissions on builds without Critical Alerts On builds without the Critical Alerts entitlement, AlarmKit is the audible channel for urgent alarms, and whether the user has allowed alarms was not visible anywhere: the permissions screen showed a "Critical Alerts: On" row that the checker never populates on those builds, so it read On regardless. A user who dismissed the authorization prompt had no way to see that their urgent low would fire silently. Report AlarmManager's authorization state through the permissions checker as a new flag, show it as an "Alarms" row in place of the Critical Alerts row where the entitlement is absent, and badge the iOS Permissions entry when alarms are not allowed. The checker already re-checks on foreground, so the row updates on return from Settings. Not added to requiresRiskMitigation, so it does not raise the unsafe-permissions modal. --- Loop/Managers/AlertPermissionsChecker.swift | 12 ++++++++++++ .../Alerts/CriticalAlertAlarmScheduler.swift | 10 ++++++++++ Loop/Views/AlertManagementView.swift | 3 ++- ...otificationsCriticalAlertPermissionsView.swift | 15 ++++++++++++++- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index d691048196..13d7b074d1 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -73,6 +73,9 @@ public class AlertPermissionsChecker: ObservableObject { newSettings.notificationsDisabled = settings.alertSetting == .disabled if FeatureFlags.criticalAlertsEnabled { newSettings.criticalAlertsDisabled = settings.criticalAlertSetting == .disabled + } else if let alarmsAuthorized = CriticalAlertAlarmScheduler.alarmsAuthorized { + // Without the Critical Alerts entitlement, AlarmKit is the audible channel. + newSettings.alarmsDisabled = !alarmsAuthorized } newSettings.scheduledDeliveryEnabled = settings.scheduledDeliverySetting == .enabled newSettings.timeSensitiveDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled @@ -319,6 +322,7 @@ struct NotificationCenterSettingsFlags: OptionSet { static let criticalAlertsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 1) static let timeSensitiveDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 2) static let scheduledDeliveryEnabled = NotificationCenterSettingsFlags(rawValue: 1 << 3) + static let alarmsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 4) static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveDisabled ] } @@ -356,6 +360,14 @@ extension NotificationCenterSettingsFlags { update(.scheduledDeliveryEnabled, newValue) } } + var alarmsDisabled: Bool { + get { + contains(.alarmsDisabled) + } + set { + update(.alarmsDisabled, newValue) + } + } var requiresRiskMitigation: Bool { !self.intersection(.requiresRiskMitigation).isEmpty } diff --git a/Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift b/Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift index e089bfbcfa..f0b439d24d 100644 --- a/Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift +++ b/Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift @@ -33,6 +33,16 @@ final class CriticalAlertAlarmScheduler { /// alarm can be cancelled when the alert is acknowledged or retracted. private var alarmsByAlert: [Alert.Identifier: UUID] = [:] + /// Whether the user has allowed alarms. nil where AlarmKit doesn't exist (below iOS 26). + static var alarmsAuthorized: Bool? { + guard #available(iOS 26, *) else { return nil } + #if canImport(AlarmKit) + return AlarmManager.shared.authorizationState == .authorized + #else + return nil + #endif + } + /// True only if AlarmKit is available (iOS 26+) AND the user authorized it. /// When false, callers should use the CriticalAlertAudioPlayer fallback. var isAuthorizedAndAvailable: Bool { diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index 4fce6bde7c..eecd940d57 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -108,7 +108,8 @@ struct AlertManagementView: View { HStack { Text(NSLocalizedString("iOS Permissions", comment: "iOS Permissions button text")) if checker.showWarning || - checker.notificationCenterSettings.scheduledDeliveryEnabled { + checker.notificationCenterSettings.scheduledDeliveryEnabled || + checker.notificationCenterSettings.alarmsDisabled { Spacer() Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.critical) diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index af6bf95491..4fd709c9a5 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -56,7 +56,11 @@ public struct NotificationsCriticalAlertPermissionsView: View { if !checker.notificationCenterSettings.notificationsDisabled { notificationDelivery } - criticalAlertsStatus + if FeatureFlags.criticalAlertsEnabled { + criticalAlertsStatus + } else if CriticalAlertAlarmScheduler.alarmsAuthorized != nil { + alarmsStatus + } if !checker.notificationCenterSettings.notificationsDisabled { timeSensitiveStatus } @@ -110,6 +114,15 @@ extension NotificationsCriticalAlertPermissionsView { !checker.notificationCenterSettings.criticalAlertsDisabled ? "settingsViewAlertManagementAlertPermissionsCriticalAlertsEnabled" : "settingsViewAlertManagementAlertPermissionsCriticalAlertsDisabled" } + private var alarmsStatus: some View { + HStack { + Text("Alarms", comment: "Alarms permission status text") + Spacer() + onOff(!checker.notificationCenterSettings.alarmsDisabled) + .accessibilityIdentifier(!checker.notificationCenterSettings.alarmsDisabled ? "settingsViewAlertManagementAlertPermissionsAlarmsEnabled" : "settingsViewAlertManagementAlertPermissionsAlarmsDisabled") + } + } + private var criticalAlertsStatus: some View { HStack { Text("Critical Alerts", comment: "Critical Alerts Status text") From 25b9f8d12532bba418fe740e09062836a7ca5ee5 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 15 Sep 2026 23:29:32 -0500 Subject: [PATCH 4/5] Keep a Critical Alerts row on builds without the entitlement Replacing the row with Alarms hid the fact that Critical Alerts are absent altogether. Show it as "Not Available" beneath the Alarms row, with a link to instructions for requesting the entitlement from Apple. The link is a placeholder for a loopdocs page; the URL is a single constant to swap. --- ...icationsCriticalAlertPermissionsView.swift | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index 4fd709c9a5..53a572ed2a 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -58,8 +58,12 @@ public struct NotificationsCriticalAlertPermissionsView: View { } if FeatureFlags.criticalAlertsEnabled { criticalAlertsStatus - } else if CriticalAlertAlarmScheduler.alarmsAuthorized != nil { - alarmsStatus + } else { + if CriticalAlertAlarmScheduler.alarmsAuthorized != nil { + alarmsStatus + } + criticalAlertsNotAvailable + requestCriticalAlertsLink } if !checker.notificationCenterSettings.notificationsDisabled { timeSensitiveStatus @@ -123,6 +127,28 @@ extension NotificationsCriticalAlertPermissionsView { } } + /// Placeholder until the walkthrough lives on loopdocs; swap the URL, nothing else. + private static let requestCriticalAlertsURL = URL(string: "https://loopkit.github.io/loopdocs/")! + + private var criticalAlertsNotAvailable: some View { + HStack { + Text("Critical Alerts", comment: "Critical Alerts Status text") + Spacer() + Text("Not Available", comment: "Critical Alerts status when the app was built without the entitlement") + .foregroundColor(.secondary) + } + } + + private var requestCriticalAlertsLink: some View { + Button(action: { UIApplication.shared.open(Self.requestCriticalAlertsURL) }) { + HStack { + Text(NSLocalizedString("How to request Critical Alerts", comment: "Button text linking to instructions for requesting the Critical Alerts entitlement from Apple")) + Spacer() + Image(systemName: "arrow.up.right.square").foregroundColor(.gray).font(.footnote) + } + } + } + private var criticalAlertsStatus: some View { HStack { Text("Critical Alerts", comment: "Critical Alerts Status text") From 9fd701190da7d100bb06ee364f03e2950577f22a Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 15 Sep 2026 23:51:34 -0500 Subject: [PATCH 5/5] Treat alarms turned off as an unsafe permission on builds without Critical Alerts On those builds AlarmKit is how an urgent low makes a sound, so alarms being off is as unsafe as Critical Alerts being off is elsewhere. Add it to the risk-mitigation set so it raises the status banner and the unsafe-permissions modal, with its own text and alert identifier. It takes precedence over the notification flags in the mapping, since it only exists where the entitlement is absent. The badge on the iOS Permissions entry now comes from showWarning like the other flags. --- Loop/Managers/AlertPermissionsChecker.swift | 23 ++++++++++++++++++++- Loop/Views/AlertManagementView.swift | 3 +-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index 13d7b074d1..5ff2e689bc 100644 --- a/Loop/Managers/AlertPermissionsChecker.swift +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -110,9 +110,12 @@ extension AlertPermissionsChecker { case timeSensitiveDisabled case criticalAlertsAndNotificationDisabled case criticalAlertsAndTimeSensitiveDisabled + case alarmsDisabled var alertTitle: String { switch self { + case .alarmsDisabled: + NSLocalizedString("Turn On Alarms", comment: "Alarms disabled alert title") case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled: NSLocalizedString("Turn On Critical Alerts and Time Sensitive Notifications", comment: "Both Critical Alerts and Time Sensitive Notifications disabled alert title") case .criticalAlertsDisabled: @@ -124,6 +127,8 @@ extension AlertPermissionsChecker { var notificationTitle: String { switch self { + case .alarmsDisabled: + NSLocalizedString("Turn On Alarms", comment: "Alarms disabled notification title") case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled: NSLocalizedString("Turn On Critical Alerts and Time Sensitive Notifications", comment: "Both Critical Alerts and Time Sensitive Notifications disabled notification title") case .criticalAlertsDisabled: @@ -135,6 +140,8 @@ extension AlertPermissionsChecker { var bannerTitle: String { switch self { + case .alarmsDisabled: + NSLocalizedString("Alarms are turned OFF", comment: "Alarms disabled banner title") case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled: NSLocalizedString("Critical Alerts and Time Sensitive Notifications are turned OFF", comment: "Both Critical Alerts and Time Sensitive Notifications disabled banner title") case .criticalAlertsDisabled: @@ -146,6 +153,8 @@ extension AlertPermissionsChecker { var alertBody: String { switch self { + case .alarmsDisabled: + NSLocalizedString("Alarms are turned OFF. Without Critical Alerts, Loop sounds urgent low and other critical safety alerts as alarms, so you may not hear them.\n\nTo fix the issue, tap ‘Settings’ and make sure Allow Alarms is turned ON.", comment: "Alarms disabled alert body") case .notificationsDisabled: NSLocalizedString("Time Sensitive Notifications are turned OFF. You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications are turned ON.", comment: "Notifications disabled alert body") case .criticalAlertsAndNotificationDisabled: @@ -161,6 +170,8 @@ extension AlertPermissionsChecker { var notificationBody: String { switch self { + case .alarmsDisabled: + NSLocalizedString("Alarms are turned OFF. Go to the App to fix the issue now.", comment: "Alarms disabled notification body") case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled: NSLocalizedString("Critical Alerts and Time Sensitive Notifications are turned OFF. Go to the App to fix the issue now.", comment: "Both Critical Alerts and Time Sensitive Notifications disabled notification body") case .criticalAlertsDisabled: @@ -172,6 +183,8 @@ extension AlertPermissionsChecker { var bannerBody: String { switch self { + case .alarmsDisabled: + NSLocalizedString("Fix now by turning Alarms ON.", comment: "Alarms disabled banner body") case .notificationsDisabled: NSLocalizedString("Fix now by turning Notifications ON.", comment: "Notifications disabled banner body") case .criticalAlertsAndNotificationDisabled: @@ -187,6 +200,8 @@ extension AlertPermissionsChecker { var alertIdentifier: LoopKit.Alert.Identifier { switch self { + case .alarmsDisabled: + Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeAlarmsPermissionsAlert") case .notificationsDisabled: Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeNotificationPermissionsAlert") case .criticalAlertsAndNotificationDisabled: @@ -239,6 +254,12 @@ extension AlertPermissionsChecker { notificationsDisabled & criticalAlertsDisabled & timeSensitiveDisabled & scheduledDeliveryEnabled = 15 (Not Possible) */ init?(permissions: NotificationCenterSettingsFlags) { + // Only set on builds without the Critical Alerts entitlement, where alarms are the + // audible channel; it outranks the notification flags there. + if permissions.contains(.alarmsDisabled) { + self = .alarmsDisabled + return + } switch permissions { case .notificationsDisabled, NotificationCenterSettingsFlags(rawValue: 9): self = .notificationsDisabled @@ -324,7 +345,7 @@ struct NotificationCenterSettingsFlags: OptionSet { static let scheduledDeliveryEnabled = NotificationCenterSettingsFlags(rawValue: 1 << 3) static let alarmsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 4) - static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveDisabled ] + static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveDisabled, .alarmsDisabled ] } extension NotificationCenterSettingsFlags { diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift index eecd940d57..4fce6bde7c 100644 --- a/Loop/Views/AlertManagementView.swift +++ b/Loop/Views/AlertManagementView.swift @@ -108,8 +108,7 @@ struct AlertManagementView: View { HStack { Text(NSLocalizedString("iOS Permissions", comment: "iOS Permissions button text")) if checker.showWarning || - checker.notificationCenterSettings.scheduledDeliveryEnabled || - checker.notificationCenterSettings.alarmsDisabled { + checker.notificationCenterSettings.scheduledDeliveryEnabled { Spacer() Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.critical)