diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift index d691048196..5ff2e689bc 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 @@ -107,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: @@ -121,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: @@ -132,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: @@ -143,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: @@ -158,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: @@ -169,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: @@ -184,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: @@ -236,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 @@ -319,8 +343,9 @@ 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 ] + static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveDisabled, .alarmsDisabled ] } extension NotificationCenterSettingsFlags { @@ -356,6 +381,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 6182232724..f0b439d24d 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 @@ -32,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 { @@ -92,14 +103,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() 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 diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift index af6bf95491..53a572ed2a 100644 --- a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -56,7 +56,15 @@ public struct NotificationsCriticalAlertPermissionsView: View { if !checker.notificationCenterSettings.notificationsDisabled { notificationDelivery } - criticalAlertsStatus + if FeatureFlags.criticalAlertsEnabled { + criticalAlertsStatus + } else { + if CriticalAlertAlarmScheduler.alarmsAuthorized != nil { + alarmsStatus + } + criticalAlertsNotAvailable + requestCriticalAlertsLink + } if !checker.notificationCenterSettings.notificationsDisabled { timeSensitiveStatus } @@ -110,6 +118,37 @@ 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") + } + } + + /// 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")