Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion Loop/Managers/AlertPermissionsChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
26 changes: 21 additions & 5 deletions Loop/Managers/Alerts/CriticalAlertAlarmScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -92,14 +103,19 @@ final class CriticalAlertAlarmScheduler {
let attributes = AlarmAttributes<EmptyAlarmMetadata>(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<EmptyAlarmMetadata>.alarm(
schedule: .fixed(Date().addingTimeInterval(Self.immediateFireDelay)),
attributes: attributes,
stopIntent: StopCriticalAlertIntent(identifier: alert.identifier)
stopIntent: StopCriticalAlertIntent(identifier: alert.identifier),
sound: sound
)

let id = UUID()
Expand Down
7 changes: 7 additions & 0 deletions Loop/Managers/Alerts/GlucoseAlertManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 40 additions & 1 deletion Loop/Views/NotificationsCriticalAlertPermissionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
Expand Down