Skip to content

Commit 66a153d

Browse files
authored
AlarmKit critical alarms: configured sound, backfill-proof, and visible authorization state (#2520)
* 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. * 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 125a01b) * 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. * 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. * 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.
1 parent 455f756 commit 66a153d

4 files changed

Lines changed: 102 additions & 7 deletions

File tree

‎Loop/Managers/AlertPermissionsChecker.swift‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ public class AlertPermissionsChecker: ObservableObject {
7373
newSettings.notificationsDisabled = settings.alertSetting == .disabled
7474
if FeatureFlags.criticalAlertsEnabled {
7575
newSettings.criticalAlertsDisabled = settings.criticalAlertSetting == .disabled
76+
} else if let alarmsAuthorized = CriticalAlertAlarmScheduler.alarmsAuthorized {
77+
// Without the Critical Alerts entitlement, AlarmKit is the audible channel.
78+
newSettings.alarmsDisabled = !alarmsAuthorized
7679
}
7780
newSettings.scheduledDeliveryEnabled = settings.scheduledDeliverySetting == .enabled
7881
newSettings.timeSensitiveDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled
@@ -107,9 +110,12 @@ extension AlertPermissionsChecker {
107110
case timeSensitiveDisabled
108111
case criticalAlertsAndNotificationDisabled
109112
case criticalAlertsAndTimeSensitiveDisabled
113+
case alarmsDisabled
110114

111115
var alertTitle: String {
112116
switch self {
117+
case .alarmsDisabled:
118+
NSLocalizedString("Turn On Alarms", comment: "Alarms disabled alert title")
113119
case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled:
114120
NSLocalizedString("Turn On Critical Alerts and Time Sensitive Notifications", comment: "Both Critical Alerts and Time Sensitive Notifications disabled alert title")
115121
case .criticalAlertsDisabled:
@@ -121,6 +127,8 @@ extension AlertPermissionsChecker {
121127

122128
var notificationTitle: String {
123129
switch self {
130+
case .alarmsDisabled:
131+
NSLocalizedString("Turn On Alarms", comment: "Alarms disabled notification title")
124132
case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled:
125133
NSLocalizedString("Turn On Critical Alerts and Time Sensitive Notifications", comment: "Both Critical Alerts and Time Sensitive Notifications disabled notification title")
126134
case .criticalAlertsDisabled:
@@ -132,6 +140,8 @@ extension AlertPermissionsChecker {
132140

133141
var bannerTitle: String {
134142
switch self {
143+
case .alarmsDisabled:
144+
NSLocalizedString("Alarms are turned OFF", comment: "Alarms disabled banner title")
135145
case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled:
136146
NSLocalizedString("Critical Alerts and Time Sensitive Notifications are turned OFF", comment: "Both Critical Alerts and Time Sensitive Notifications disabled banner title")
137147
case .criticalAlertsDisabled:
@@ -143,6 +153,8 @@ extension AlertPermissionsChecker {
143153

144154
var alertBody: String {
145155
switch self {
156+
case .alarmsDisabled:
157+
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")
146158
case .notificationsDisabled:
147159
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")
148160
case .criticalAlertsAndNotificationDisabled:
@@ -158,6 +170,8 @@ extension AlertPermissionsChecker {
158170

159171
var notificationBody: String {
160172
switch self {
173+
case .alarmsDisabled:
174+
NSLocalizedString("Alarms are turned OFF. Go to the App to fix the issue now.", comment: "Alarms disabled notification body")
161175
case .criticalAlertsAndNotificationDisabled, .criticalAlertsAndTimeSensitiveDisabled:
162176
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")
163177
case .criticalAlertsDisabled:
@@ -169,6 +183,8 @@ extension AlertPermissionsChecker {
169183

170184
var bannerBody: String {
171185
switch self {
186+
case .alarmsDisabled:
187+
NSLocalizedString("Fix now by turning Alarms ON.", comment: "Alarms disabled banner body")
172188
case .notificationsDisabled:
173189
NSLocalizedString("Fix now by turning Notifications ON.", comment: "Notifications disabled banner body")
174190
case .criticalAlertsAndNotificationDisabled:
@@ -184,6 +200,8 @@ extension AlertPermissionsChecker {
184200

185201
var alertIdentifier: LoopKit.Alert.Identifier {
186202
switch self {
203+
case .alarmsDisabled:
204+
Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeAlarmsPermissionsAlert")
187205
case .notificationsDisabled:
188206
Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeNotificationPermissionsAlert")
189207
case .criticalAlertsAndNotificationDisabled:
@@ -236,6 +254,12 @@ extension AlertPermissionsChecker {
236254
notificationsDisabled & criticalAlertsDisabled & timeSensitiveDisabled & scheduledDeliveryEnabled = 15 (Not Possible)
237255
*/
238256
init?(permissions: NotificationCenterSettingsFlags) {
257+
// Only set on builds without the Critical Alerts entitlement, where alarms are the
258+
// audible channel; it outranks the notification flags there.
259+
if permissions.contains(.alarmsDisabled) {
260+
self = .alarmsDisabled
261+
return
262+
}
239263
switch permissions {
240264
case .notificationsDisabled, NotificationCenterSettingsFlags(rawValue: 9):
241265
self = .notificationsDisabled
@@ -319,8 +343,9 @@ struct NotificationCenterSettingsFlags: OptionSet {
319343
static let criticalAlertsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 1)
320344
static let timeSensitiveDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 2)
321345
static let scheduledDeliveryEnabled = NotificationCenterSettingsFlags(rawValue: 1 << 3)
346+
static let alarmsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 4)
322347

323-
static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveDisabled ]
348+
static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveDisabled, .alarmsDisabled ]
324349
}
325350

326351
extension NotificationCenterSettingsFlags {
@@ -356,6 +381,14 @@ extension NotificationCenterSettingsFlags {
356381
update(.scheduledDeliveryEnabled, newValue)
357382
}
358383
}
384+
var alarmsDisabled: Bool {
385+
get {
386+
contains(.alarmsDisabled)
387+
}
388+
set {
389+
update(.alarmsDisabled, newValue)
390+
}
391+
}
359392
var requiresRiskMitigation: Bool {
360393
!self.intersection(.requiresRiskMitigation).isEmpty
361394
}

‎Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import LoopKit
1919
import os.log
2020

2121
#if canImport(AlarmKit)
22+
import ActivityKit
2223
import AlarmKit
2324
import AppIntents
2425
import struct SwiftUI.Color // Color only; `import SwiftUI` would make `Alert` ambiguous with LoopKit.Alert
@@ -32,6 +33,16 @@ final class CriticalAlertAlarmScheduler {
3233
/// alarm can be cancelled when the alert is acknowledged or retracted.
3334
private var alarmsByAlert: [Alert.Identifier: UUID] = [:]
3435

36+
/// Whether the user has allowed alarms. nil where AlarmKit doesn't exist (below iOS 26).
37+
static var alarmsAuthorized: Bool? {
38+
guard #available(iOS 26, *) else { return nil }
39+
#if canImport(AlarmKit)
40+
return AlarmManager.shared.authorizationState == .authorized
41+
#else
42+
return nil
43+
#endif
44+
}
45+
3546
/// True only if AlarmKit is available (iOS 26+) AND the user authorized it.
3647
/// When false, callers should use the CriticalAlertAudioPlayer fallback.
3748
var isAuthorizedAndAvailable: Bool {
@@ -92,14 +103,19 @@ final class CriticalAlertAlarmScheduler {
92103
let attributes = AlarmAttributes<EmptyAlarmMetadata>(presentation: presentation, tintColor: .red)
93104

94105
// Fire immediately. No countdownDuration (preAlert nil) → alert-only,
95-
// so no Widget Extension / Live Activity is required. Default alarm
96-
// sound (our .caf alarm sounds aren't guaranteed AlarmKit-compatible).
97-
// The stop button runs StopCriticalAlertIntent, which acknowledges the
98-
// corresponding Loop alert (in addition to AlarmKit stopping the alarm).
106+
// so no Widget Extension / Live Activity is required. The stop button
107+
// runs StopCriticalAlertIntent, which acknowledges the corresponding
108+
// Loop alert (in addition to AlarmKit stopping the alarm).
109+
//
110+
// The alert's configured sound is a bundled IMA4 .caf under 30s, which
111+
// is the same constraint as a notification sound, so AlarmKit can play
112+
// it by name from the main bundle.
113+
let sound: AlertConfiguration.AlertSound = alert.sound?.filename.map { .named($0) } ?? .default
99114
let configuration = AlarmManager.AlarmConfiguration<EmptyAlarmMetadata>.alarm(
100115
schedule: .fixed(Date().addingTimeInterval(Self.immediateFireDelay)),
101116
attributes: attributes,
102-
stopIntent: StopCriticalAlertIntent(identifier: alert.identifier)
117+
stopIntent: StopCriticalAlertIntent(identifier: alert.identifier),
118+
sound: sound
103119
)
104120

105121
let id = UUID()

‎Loop/Managers/Alerts/GlucoseAlertManager.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,13 @@ final class GlucoseAlertManager: ObservableObject {
559559
os_log("Skipping stale sample", log: log, type: .debug)
560560
return
561561
}
562+
// CGMs deliver the live reading and then backfill. A batch whose newest
563+
// sample is no newer than the one already evaluated must not re-decide
564+
// — and in particular must not retract — the alert that reading raised.
565+
if let evaluated = latestReading, latest.date <= evaluated.date {
566+
os_log("Skipping batch older than the latest evaluated reading", log: log, type: .debug)
567+
return
568+
}
562569
latestReading = (latest.quantity.doubleValue(for: .milligramsPerDeciliter), latest.date)
563570
let config = activeConfiguration(at: now)
564571
let mgdl: Double

‎Loop/Views/NotificationsCriticalAlertPermissionsView.swift‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,15 @@ public struct NotificationsCriticalAlertPermissionsView: View {
5656
if !checker.notificationCenterSettings.notificationsDisabled {
5757
notificationDelivery
5858
}
59-
criticalAlertsStatus
59+
if FeatureFlags.criticalAlertsEnabled {
60+
criticalAlertsStatus
61+
} else {
62+
if CriticalAlertAlarmScheduler.alarmsAuthorized != nil {
63+
alarmsStatus
64+
}
65+
criticalAlertsNotAvailable
66+
requestCriticalAlertsLink
67+
}
6068
if !checker.notificationCenterSettings.notificationsDisabled {
6169
timeSensitiveStatus
6270
}
@@ -110,6 +118,37 @@ extension NotificationsCriticalAlertPermissionsView {
110118
!checker.notificationCenterSettings.criticalAlertsDisabled ? "settingsViewAlertManagementAlertPermissionsCriticalAlertsEnabled" : "settingsViewAlertManagementAlertPermissionsCriticalAlertsDisabled"
111119
}
112120

121+
private var alarmsStatus: some View {
122+
HStack {
123+
Text("Alarms", comment: "Alarms permission status text")
124+
Spacer()
125+
onOff(!checker.notificationCenterSettings.alarmsDisabled)
126+
.accessibilityIdentifier(!checker.notificationCenterSettings.alarmsDisabled ? "settingsViewAlertManagementAlertPermissionsAlarmsEnabled" : "settingsViewAlertManagementAlertPermissionsAlarmsDisabled")
127+
}
128+
}
129+
130+
/// Placeholder until the walkthrough lives on loopdocs; swap the URL, nothing else.
131+
private static let requestCriticalAlertsURL = URL(string: "https://loopkit.github.io/loopdocs/")!
132+
133+
private var criticalAlertsNotAvailable: some View {
134+
HStack {
135+
Text("Critical Alerts", comment: "Critical Alerts Status text")
136+
Spacer()
137+
Text("Not Available", comment: "Critical Alerts status when the app was built without the entitlement")
138+
.foregroundColor(.secondary)
139+
}
140+
}
141+
142+
private var requestCriticalAlertsLink: some View {
143+
Button(action: { UIApplication.shared.open(Self.requestCriticalAlertsURL) }) {
144+
HStack {
145+
Text(NSLocalizedString("How to request Critical Alerts", comment: "Button text linking to instructions for requesting the Critical Alerts entitlement from Apple"))
146+
Spacer()
147+
Image(systemName: "arrow.up.right.square").foregroundColor(.gray).font(.footnote)
148+
}
149+
}
150+
}
151+
113152
private var criticalAlertsStatus: some View {
114153
HStack {
115154
Text("Critical Alerts", comment: "Critical Alerts Status text")

0 commit comments

Comments
 (0)