Bläddra i källkod

Display all SwiftMessages as APNs when enabled; batch INFO type messages when APN is off

kskandis 1 år sedan
förälder
incheckning
9840457056

+ 4 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -290,6 +290,7 @@
 		6EADD581738D64431902AC0A /* (null) in Sources */ = {isa = PBXBuildFile; };
 		6FFAE524D1D9C262F2407CAE /* SnoozeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAE81192B118804DCD23034 /* SnoozeProvider.swift */; };
 		711C0CB42CAABE788916BC9D /* ManualTempBasalDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96653287EDB276A111288305 /* ManualTempBasalDataFlow.swift */; };
+		71D44AAB2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D44AAA2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift */; };
 		72F1BD388F42FCA6C52E4500 /* ConfigEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44080E4709E3AE4B73054563 /* ConfigEditorProvider.swift */; };
 		7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */; };
 		7F7B756BE8543965D9FDF1A2 /* DataTableDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A401509D21F7F35D4E109EDA /* DataTableDataFlow.swift */; };
@@ -939,6 +940,7 @@
 		6B1A8D252B14D91700E76752 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		6B1A8D2D2B156EEF00E76752 /* LiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityBridge.swift; sourceTree = "<group>"; };
 		6BCF84DC2B16843A003AD46E /* LiveActitiyAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActitiyAttributes.swift; sourceTree = "<group>"; };
+		71D44AAA2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = "<group>"; };
 		79BDA519C9B890FD9A5DFCF3 /* ISFEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorDataFlow.swift; sourceTree = "<group>"; };
 		7E22146D3DF4853786C78132 /* CarbRatioEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarbRatioEditorDataFlow.swift; sourceTree = "<group>"; };
 		8782B44544F38F2B2D82C38E /* NightscoutConfigRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigRootView.swift; sourceTree = "<group>"; };
@@ -2020,6 +2022,7 @@
 		38B4F3C425E5016800E76A18 /* Notifications */ = {
 			isa = PBXGroup;
 			children = (
+				71D44AAA2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift */,
 				38B4F3CC25E5031100E76A18 /* Broadcaster.swift */,
 				38B4F3C525E5017E00E76A18 /* NotificationCenter.swift */,
 				38B4F3C725E502C000E76A18 /* SwiftNotificationCenter */,
@@ -3540,6 +3543,7 @@
 				DDF847E42C5C288F0049BB3B /* LiveActivitySettingsRootView.swift in Sources */,
 				DD88C8E22C50420800F2D558 /* DefinitionRow.swift in Sources */,
 				B7C465E9472624D8A2BE2A6A /* (null) in Sources */,
+				71D44AAB2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift in Sources */,
 				320D030F724170A637F06D50 /* (null) in Sources */,
 				CE94598729E9E4110047C9C6 /* WatchConfigRootView.swift in Sources */,
 				19E1F7E829D082D0005C8D20 /* IconConfigDataFlow.swift in Sources */,

+ 1 - 0
FreeAPS/Sources/Application/FreeAPSApp.swift

@@ -51,6 +51,7 @@ import Swinject
         _ = resolver.resolve(HealthKitManager.self)!
         _ = resolver.resolve(BluetoothStateManager.self)!
         _ = resolver.resolve(PluginManager.self)!
+        _ = resolver.resolve(AlertPermissionsChecker.self)!
         if #available(iOS 16.2, *) {
             _ = resolver.resolve(LiveActivityBridge.self)!
         }

+ 1 - 1
FreeAPS/Sources/Assemblies/ServiceAssembly.swift

@@ -20,7 +20,7 @@ final class ServiceAssembly: Assembly {
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(GarminManager.self) { r in BaseGarminManager(resolver: r) }
-
+        container.register(AlertPermissionsChecker.self) { r in AlertPermissionsChecker(resolver: r) }
         if #available(iOS 16.2, *) {
             container.register(LiveActivityBridge.self) { r in
                 LiveActivityBridge(resolver: r)

+ 95 - 87
FreeAPS/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift

@@ -14,6 +14,7 @@ extension GlucoseNotificationSettings {
         @State var hintLabel: String?
         @State private var decimalPlaceholder: Decimal = 0.0
         @State private var booleanPlaceholder: Bool = false
+        @State var notificationsDisabled = false
 
         private var glucoseFormatter: NumberFormatter {
             let formatter = NumberFormatter()
@@ -54,24 +55,26 @@ extension GlucoseNotificationSettings {
 
         var body: some View {
             Form {
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.glucoseBadge,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0
-                            hintLabel = "Show Glucose App Badge"
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: "Show Glucose App Badge",
-                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
-                    verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
-                    headerText: "Various Glucose Notifications"
-                )
+                if !notificationsDisabled {
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.glucoseBadge,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Show Glucose App Badge"
+                            }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Show Glucose App Badge",
+                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        headerText: "Various Glucose Notifications"
+                    )
+                }
 
                 SettingInputSection(
                     decimalValue: $decimalPlaceholder,
@@ -91,81 +94,83 @@ extension GlucoseNotificationSettings {
                     verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
                 )
 
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.useAlarmSound,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0
-                            hintLabel = "Play Alarm Sound"
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: "Play Alarm Sound",
-                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
-                    verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
-                )
+                if !notificationsDisabled {
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.useAlarmSound,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Play Alarm Sound"
+                            }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Play Alarm Sound",
+                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
+                    )
 
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.addSourceInfoToGlucoseNotifications,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0
-                            hintLabel = "Add Glucose Source to Alarm"
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: "Add Glucose Source to Alarm",
-                    miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
-                    verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
-                )
+                    SettingInputSection(
+                        decimalValue: $decimalPlaceholder,
+                        booleanValue: $state.addSourceInfoToGlucoseNotifications,
+                        shouldDisplayHint: $shouldDisplayHint,
+                        selectedVerboseHint: Binding(
+                            get: { selectedVerboseHint },
+                            set: {
+                                selectedVerboseHint = $0
+                                hintLabel = "Add Glucose Source to Alarm"
+                            }
+                        ),
+                        units: state.units,
+                        type: .boolean,
+                        label: "Add Glucose Source to Alarm",
+                        miniHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
+                        verboseHint: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr."
+                    )
 
-                Section {
-                    HStack {
-                        Text("Low Glucose Alarm Limit")
-                        Spacer()
-                        TextFieldWithToolBar(text: $state.lowGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
-                        Text(state.units.rawValue).foregroundColor(.secondary)
-                    }.padding(.top)
+                    Section {
+                        HStack {
+                            Text("Low Glucose Alarm Limit")
+                            Spacer()
+                            TextFieldWithToolBar(text: $state.lowGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
+                            Text(state.units.rawValue).foregroundColor(.secondary)
+                        }.padding(.top)
 
-                    HStack {
-                        Text("High Glucose Alarm Limit")
-                        Spacer()
-                        TextFieldWithToolBar(text: $state.highGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
-                        Text(state.units.rawValue).foregroundColor(.secondary)
-                    }
+                        HStack {
+                            Text("High Glucose Alarm Limit")
+                            Spacer()
+                            TextFieldWithToolBar(text: $state.highGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
+                            Text(state.units.rawValue).foregroundColor(.secondary)
+                        }
 
-                    HStack(alignment: .top) {
-                        Text(
-                            "Set the lower and upper limit for glucose alarms. See hint for more details."
-                        )
-                        .font(.footnote)
-                        .foregroundColor(.secondary)
-                        .lineLimit(nil)
-                        Spacer()
-                        Button(
-                            action: {
-                                hintLabel = "Low and High Glucose Alarm Limits"
-                                selectedVerboseHint =
-                                    "These two settings limit the range outside of which you will be notified via push notifications. If your CGM readings are below 'Low' or above 'High', you will receive a glucose alarm."
-                                shouldDisplayHint.toggle()
-                            },
-                            label: {
-                                HStack {
-                                    Image(systemName: "questionmark.circle")
+                        HStack(alignment: .top) {
+                            Text(
+                                "Set the lower and upper limit for glucose alarms. See hint for more details."
+                            )
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                            Button(
+                                action: {
+                                    hintLabel = "Low and High Glucose Alarm Limits"
+                                    selectedVerboseHint =
+                                        "These two settings limit the range outside of which you will be notified via push notifications. If your CGM readings are below 'Low' or above 'High', you will receive a glucose alarm."
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
                                 }
-                            }
-                        ).buttonStyle(BorderlessButtonStyle())
-                    }.padding(.vertical)
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.vertical)
+                    }
+                    .listRowBackground(Color.chart)
                 }
-                .listRowBackground(Color.chart)
             }
             .sheet(isPresented: $shouldDisplayHint) {
                 SettingInputHintView(
@@ -176,6 +181,9 @@ extension GlucoseNotificationSettings {
                     sheetTitle: "Help"
                 )
             }
+            .onReceive(resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled, perform: {
+                notificationsDisabled = $0
+            })
             .scrollContentBackground(.hidden).background(color)
             .onAppear(perform: configureView)
             .navigationBarTitle("Glucose Notifications")

+ 25 - 0
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -16,6 +16,7 @@ extension Home {
         @State var selectedTab: Int = 0
         @State private var statusTitle: String = ""
         @State var showPumpSelection: Bool = false
+        @State var notificationsDisabled = false
 
         struct Buttons: Identifiable {
             let label: String
@@ -128,6 +129,22 @@ extension Home {
             }
         }
 
+        private func sendSafetyNotification() {
+            let messageCont = MessageContent(
+                content: NSLocalizedString(
+                    "Fix now by turning Notifications ON.",
+                    comment: "Secondary text for alerts disabled warning, which appears on the main status screen."
+                ),
+                type: MessageType.alertPermissionWarning,
+                title: NSLocalizedString(
+                    "⚠️ Safety Notifications are OFF", // \u{26A0}
+                    comment: "Warning text for when Notifications or Critical Alerts Permissions is disabled"
+                ),
+                useAPN: false
+            )
+            router.alertMessage.send(messageCont)
+        }
+
         var glucoseView: some View {
             CurrentGlucoseView(
                 timerDate: $state.timerDate,
@@ -725,6 +742,14 @@ extension Home {
                     highlightButtons()
                 }
             }
+            .onReceive(resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled, perform: {
+                if notificationsDisabled != $0 {
+                    notificationsDisabled = $0
+                    if notificationsDisabled {
+                        sendSafetyNotification()
+                    }
+                }
+            })
             .navigationTitle("Home")
             .navigationBarHidden(true)
             .ignoresSafeArea(.keyboard)

+ 207 - 69
FreeAPS/Sources/Modules/Main/MainStateModel.swift

@@ -1,3 +1,4 @@
+import Combine
 import LoopKitUI
 import SwiftMessages
 import SwiftUI
@@ -5,11 +6,216 @@ import Swinject
 
 extension Main {
     final class StateModel: BaseStateModel<Provider> {
+        @Injected() var alertPermissionsChecker: AlertPermissionsChecker!
+        @Injected() var broadcaster: Broadcaster!
         private(set) var modal: Modal?
         @Published var isModalPresented = false
         @Published var isSecondaryModalPresented = false
         @Published var secondaryModalView: AnyView? = nil
 
+        private var storedMessages: [MessageContent] = []
+        private let maxStoredMessages = 3
+        private let maxNotificationsPerMinute = 3
+        private var lastMessageTimestamp: Date?
+        private var timer: AnyCancellable?
+        private var timeInterval: TimeInterval = 1
+        private let limitInterval: TimeInterval = 20
+        private var lastNotificationTime: TimeInterval = 0
+        private var sentNotifications: [TimeInterval] = []
+
+        // Method to queue new message and check if it matches the "NOTE-*" pattern
+        func queueMessageIfNeeded(_ message: MessageContent) {
+            if message.type != MessageType.info {
+                showAlertMessage(message)
+                return
+            }
+            if !storedMessages.filter({ $0.content == message.content && $0.title == message.title }).isEmpty { return }
+
+            storedMessages.append(message)
+            lastMessageTimestamp = Date()
+
+            // If we have accumulated messages, concatenate and display
+            if storedMessages.count >= maxStoredMessages {
+                checkAndDisplayStoredMessages()
+            } else {
+                startTimer()
+            }
+        }
+
+        // Start or restart the timer that checks for the 1-minute interval
+        private func startTimer() {
+            timer = Timer.publish(every: timeInterval, on: .main, in: .common)
+                .autoconnect()
+                .sink { [weak self] _ in
+                    self?.checkAndDisplayStoredMessages()
+                }
+        }
+
+        // Method to check the stored messages and show them after 1 minute
+        private func checkAndDisplayStoredMessages() {
+            guard !storedMessages.isEmpty else { return }
+
+            // Ensure rate limit is not exceeded
+            let currentTime = Date().timeIntervalSince1970
+            pruneOldNotifications(currentTime: currentTime)
+
+            // Ensure we do not exceed maxNotificationsPerMinute
+            if sentNotifications.count < maxNotificationsPerMinute {
+                // If below the limit, send the next notification in the queue
+                if !alertPermissionsChecker.notificationsDisabled {
+                    let request = storedMessages.removeFirst()
+                    showAlertMessage(request)
+                    sentNotifications.append(currentTime)
+                } else {
+                    let max = storedMessages.count >= maxStoredMessages ? maxStoredMessages : storedMessages.count
+                    var content = ""
+                    for _ in 1 ... max {
+                        let request = storedMessages.removeFirst()
+                        sentNotifications.append(currentTime)
+                        content = content + request.content + "\n"
+                    }
+                    if content != "" {
+                        let messageCont = MessageContent(
+                            content: content,
+                            type: MessageType.other
+                        )
+                        showAlertMessage(messageCont)
+                    }
+                }
+            }
+        }
+
+        // Remove notifications from the sent list that are older than `limitInterval`
+        private func pruneOldNotifications(currentTime: TimeInterval) {
+            // Remove any notifications older than `limitInterval`
+            sentNotifications = sentNotifications.filter { currentTime - $0 < limitInterval }
+        }
+
+        private func showAlertMessage(_ message: MessageContent) {
+            if message.useAPN, !alertPermissionsChecker.notificationsDisabled, message.type != MessageType.pumpConfig {
+                showAPN(message)
+            } else {
+                showSwiftMessage(message)
+            }
+        }
+
+        private func showAPN(_ message: MessageContent) {
+            let messageCont = MessageContent(content: message.content, type: message.type)
+            switch message.type {
+            case .pumpConfig:
+                if let pump = provider.deviceManager.pumpManager,
+                   let bluetooth = provider.bluetoothProvider
+                {
+                    let view = PumpConfig.PumpSettingsView(
+                        pumpManager: pump,
+                        bluetoothManager: bluetooth,
+                        completionDelegate: self
+                    ).asAny()
+                    router.mainSecondaryModalView.send(view)
+                }
+            default:
+                DispatchQueue.main.async {
+                    self.broadcaster.notify(alertMessageNotificationObserver.self, on: .main) {
+                        $0.alertMessageNotification(messageCont)
+                    }
+                }
+            }
+        }
+
+        private func showSwiftMessage(_ message: MessageContent) {
+            // SwiftMessages.pauseBetweenMessages = 1.0
+            var config = SwiftMessages.defaultConfig
+            let view = MessageView.viewFromNib(layout: .cardView)
+
+            let titleContent: String
+
+            view.configureContent(
+                title: "title",
+                body: NSLocalizedString(message.content, comment: "Info message"),
+                iconImage: nil,
+                iconText: nil,
+                buttonImage: nil,
+                buttonTitle: nil,
+                buttonTapHandler: nil
+            )
+
+            switch message.type {
+            case .info,
+                 .other:
+                view.backgroundColor = .secondarySystemGroupedBackground
+                config.duration = .automatic
+                titleContent = message.title != "" ? message.title : NSLocalizedString("Info", comment: "Info title")
+            case .warning:
+                view.configureTheme(.warning, iconStyle: .subtle)
+                config.duration = .forever
+                view.button?.setImage(Icon.warningSubtle.image, for: .normal)
+                titleContent = message.title != "" ? message
+                    .title : NSLocalizedString("Warning", comment: "Warning title")
+                view.buttonTapHandler = { _ in
+                    SwiftMessages.hide()
+                }
+            case .errorPump:
+                view.configureTheme(.error, iconStyle: .subtle)
+                config.duration = .forever
+                view.button?.setImage(Icon.errorSubtle.image, for: .normal)
+                titleContent = message.title != "" ? message
+                    .title : NSLocalizedString("Error", comment: "Error title")
+                view.buttonTapHandler = { _ in
+                    SwiftMessages.hide()
+                    // display the pump configuration immediatly
+                    if let pump = self.provider.deviceManager.pumpManager,
+                       let bluetooth = self.provider.bluetoothProvider
+                    {
+                        let view = PumpConfig.PumpSettingsView(
+                            pumpManager: pump,
+                            bluetoothManager: bluetooth,
+                            completionDelegate: self
+                        ).asAny()
+                        self.router.mainSecondaryModalView.send(view)
+                    }
+                }
+            case .alertPermissionWarning:
+                view.configureTheme(.error, iconStyle: .none)
+                config.duration = .forever
+
+                view.iconLabel = nil
+                view.iconImageView = nil
+                let disclosureIndicator = UIImage(systemName: "chevron.right")?.withTintColor(.white)
+                view.button?.setImage(disclosureIndicator, for: .normal)
+                view.button?.backgroundColor = UIColor.red
+                view.button?.tintColor = UIColor.white
+
+                titleContent = message.title != "" ? message
+                    .title : NSLocalizedString("Error", comment: "Error title")
+                view.buttonTapHandler = { _ in
+                    SwiftMessages.hide()
+                    UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
+                }
+            case .pumpConfig:
+                titleContent = ""
+                if let pump = provider.deviceManager.pumpManager,
+                   let bluetooth = provider.bluetoothProvider
+                {
+                    let view = PumpConfig.PumpSettingsView(
+                        pumpManager: pump,
+                        bluetoothManager: bluetooth,
+                        completionDelegate: self
+                    ).asAny()
+                    router.mainSecondaryModalView.send(view)
+                }
+            }
+
+            if message.type != .pumpConfig
+            {
+                view.titleLabel?.text = titleContent
+                config.dimMode = .gray(interactive: true)
+                // Show if not hidden
+                if !view.isHidden {
+                    SwiftMessages.show(config: config, view: view)
+                }
+            }
+        }
+
         override func subscribe() {
             router.mainModalScreen
                 .map { $0?.modal(resolver: self.resolver!) }
@@ -31,75 +237,7 @@ extension Main {
             router.alertMessage
                 .receive(on: DispatchQueue.main)
                 .sink { message in
-                    var config = SwiftMessages.defaultConfig
-                    let view = MessageView.viewFromNib(layout: .cardView)
-
-                    let titleContent: String
-
-                    view.configureContent(
-                        title: "title",
-                        body: NSLocalizedString(message.content, comment: "Info message"),
-                        iconImage: nil,
-                        iconText: nil,
-                        buttonImage: nil,
-                        buttonTitle: nil,
-                        buttonTapHandler: nil
-                    )
-
-                    switch message.type {
-                    case .info:
-                        view.backgroundColor = .secondarySystemGroupedBackground
-                        config.duration = .automatic
-
-                        titleContent = NSLocalizedString("Info", comment: "Info title")
-                    case .warning:
-                        view.configureTheme(.warning, iconStyle: .subtle)
-                        config.duration = .forever
-                        view.button?.setImage(Icon.warningSubtle.image, for: .normal)
-                        titleContent = NSLocalizedString("Warning", comment: "Warning title")
-                        view.buttonTapHandler = { _ in
-                            SwiftMessages.hide()
-                        }
-                    case .errorPump:
-                        view.configureTheme(.error, iconStyle: .subtle)
-                        config.duration = .forever
-                        view.button?.setImage(Icon.errorSubtle.image, for: .normal)
-                        titleContent = NSLocalizedString("Error", comment: "Error title")
-                        view.buttonTapHandler = { _ in
-                            SwiftMessages.hide()
-                            // display the pump configuration immediatly
-                            if let pump = self.provider.deviceManager.pumpManager,
-                               let bluetooth = self.provider.bluetoothProvider
-                            {
-                                let view = PumpConfig.PumpSettingsView(
-                                    pumpManager: pump,
-                                    bluetoothManager: bluetooth,
-                                    completionDelegate: self
-                                ).asAny()
-                                self.router.mainSecondaryModalView.send(view)
-                            }
-                        }
-                    case .pumpConfig:
-                        titleContent = ""
-                        if let pump = self.provider.deviceManager.pumpManager,
-                           let bluetooth = self.provider.bluetoothProvider
-                        {
-                            let view = PumpConfig.PumpSettingsView(
-                                pumpManager: pump,
-                                bluetoothManager: bluetooth,
-                                completionDelegate: self
-                            ).asAny()
-                            self.router.mainSecondaryModalView.send(view)
-                        }
-                    }
-
-                    if message.type != .pumpConfig
-                    {
-                        view.titleLabel?.text = titleContent
-                        config.dimMode = .gray(interactive: true)
-
-                        SwiftMessages.show(config: config, view: view)
-                    }
+                    self.queueMessageIfNeeded(message)
                 }
                 .store(in: &lifetime)
 

+ 47 - 0
FreeAPS/Sources/Modules/Settings/View/Subviews/NotificationsView.swift

@@ -5,6 +5,7 @@
 //  Created by Deniz Cengiz on 26.07.24.
 //
 import Foundation
+import LoopKitUI
 import SwiftUI
 import Swinject
 
@@ -12,7 +13,9 @@ struct NotificationsView: BaseView {
     let resolver: Resolver
 
     @ObservedObject var state: Settings.StateModel
+    @State var notificationsDisabled = false
 
+    @Environment(\.appName) private var appName
     @Environment(\.colorScheme) var colorScheme
     var color: LinearGradient {
         colorScheme == .dark ? LinearGradient(
@@ -36,6 +39,15 @@ struct NotificationsView: BaseView {
             Section(
                 header: Text("Notification Center"),
                 content: {
+                    Section(footer: DescriptiveText(label: String(format: NSLocalizedString("""
+                    Notifications give you important %1$@ app information without requiring you to open the app.
+
+                    Keep these turned ON in your phone’s settings to ensure you receive %1$@ Notifications, Critical Alerts, and Time Sensitive Notifications.
+                    """, comment: "Alert Permissions descriptive text (1: app name)"), appName)))
+                        {
+                            manageNotifications
+                            notificationsEnabledStatus
+                        }
                     Text("Glucose Notifications").navigationLink(to: .glucoseNotificationSettings, from: self)
 
                     if #available(iOS 16.2, *) {
@@ -47,8 +59,43 @@ struct NotificationsView: BaseView {
             )
             .listRowBackground(Color.chart)
         }
+        .onReceive(resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled, perform: {
+            notificationsDisabled = $0
+        })
         .scrollContentBackground(.hidden).background(color)
         .navigationTitle("Notifications")
         .navigationBarTitleDisplayMode(.automatic)
     }
 }
+
+extension NotificationsView {
+    @ViewBuilder private func onOff(_ val: Bool) -> some View {
+        if val {
+            Text(NSLocalizedString("On", comment: "Notification Setting Status is On"))
+        } else {
+            HStack {
+                Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.critical)
+                Text(NSLocalizedString("Off", comment: "Notification Setting Status is Off"))
+            }
+        }
+    }
+
+    private var manageNotifications: some View {
+        Button(action: { UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) }) {
+            HStack {
+                Text(NSLocalizedString("Manage Permissions in Settings", comment: "Manage Permissions in Settings button text"))
+                Spacer()
+                Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote)
+            }
+        }
+        .accentColor(.primary)
+    }
+
+    private var notificationsEnabledStatus: some View {
+        HStack {
+            Text(NSLocalizedString("Notifications", comment: "Notifications Status text"))
+            Spacer()
+            onOff(!notificationsDisabled)
+        }
+    }
+}

+ 4 - 0
FreeAPS/Sources/Router/Router.swift

@@ -7,11 +7,15 @@ enum MessageType {
     case warning
     case errorPump
     case pumpConfig
+    case alertPermissionWarning
+    case other
 }
 
 struct MessageContent {
     var content: String
     var type: MessageType = .info
+    var title: String = ""
+    var useAPN: Bool = true
 }
 
 protocol Router {

+ 70 - 0
FreeAPS/Sources/Services/Notifications/AlertPermissionsChecker.swift

@@ -0,0 +1,70 @@
+import Combine
+import Foundation
+import LoopKit
+import SwiftUI
+import Swinject
+
+protocol AlertPermissionsCheckerDelegate: AnyObject {
+    func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool)
+}
+
+public class AlertPermissionsChecker: ObservableObject, Injectable {
+    @Environment(\.appName) private var appName
+    private lazy var cancellables = Set<AnyCancellable>()
+    private var listeningToNotificationCenter = false
+
+    @Injected() private var apsManager: APSManager!
+    @Injected() private var router: Router!
+    @Published var notificationsDisabled: Bool = false
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+
+        Foundation.NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
+            .sink { [weak self] _ in
+                self?.check()
+            }
+            .store(in: &cancellables)
+
+        Foundation.NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
+            .sink { [weak self] _ in
+                self?.check()
+            }
+            .store(in: &cancellables)
+    }
+
+    func checkNow() {
+        check {
+            // Note: we do this, instead of calling notificationCenterSettingsChanged directly, so that we only
+            // get called when it _changes_.
+            self.listenToNotificationCenter()
+        }
+    }
+
+    private func check(then completion: (() -> Void)? = nil) {
+        UNUserNotificationCenter.current().getNotificationSettings { settings in
+            DispatchQueue.main.async {
+                self.notificationsDisabled = settings.alertSetting == .disabled
+                completion?()
+            }
+        }
+    }
+}
+
+extension AlertPermissionsChecker {
+    private func listenToNotificationCenter() {
+        if !listeningToNotificationCenter {
+            $notificationsDisabled
+                .receive(on: RunLoop.main)
+                .removeDuplicates()
+                .sink(receiveValue: notificationCenterSettingsChanged)
+                .store(in: &cancellables)
+            listeningToNotificationCenter = true
+        }
+    }
+
+    private func notificationCenterSettingsChanged(_: Bool) {
+        // TODO: Add processing for other actions in delegate AlertManager, InAppAlertScheduler, etc., from Loop
+        debug(.default, "notificationCenterSettingsChanged")
+    }
+}

+ 169 - 128
FreeAPS/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -27,6 +27,10 @@ protocol BolusFailureObserver {
     func bolusDidFail()
 }
 
+protocol alertMessageNotificationObserver {
+    func alertMessageNotification(_ message: MessageContent)
+}
+
 protocol pumpNotificationObserver {
     func pumpNotification(alert: AlertEntry)
     func pumpRemoveNotification()
@@ -40,8 +44,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         case noLoopSecondNotification = "FreeAPS.noLoopSecondNotification"
         case bolusFailedNotification = "FreeAPS.bolusFailedNotification"
         case pumpNotification = "FreeAPS.pumpNotification"
+        case alertMessageNotification = "FreeAPS.alertMessageNotification"
     }
 
+    @Injected() var alertPermissionsChecker: AlertPermissionsChecker!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var glucoseStorage: GlucoseStorage!
@@ -75,6 +81,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         broadcaster.register(DeterminationObserver.self, observer: self)
         broadcaster.register(BolusFailureObserver.self, observer: self)
         broadcaster.register(pumpNotificationObserver.self, observer: self)
+        broadcaster.register(alertMessageNotificationObserver.self, observer: self)
         requestNotificationPermissionsIfNeeded()
         Task {
             await sendGlucoseNotification()
@@ -138,89 +145,81 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         guard Decimal(carbs) >= settingsManager.settings.carbsRequiredThreshold,
               settingsManager.settings.showCarbsRequiredBadge else { return }
 
-        ensureCanSendNotification {
-            var titles: [String] = []
-
-            let content = UNMutableNotificationContent()
+        var titles: [String] = []
 
-            if self.snoozeUntilDate > Date() {
-                titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
-            } else {
-                content.sound = .default
-                self.playSoundIfNeeded()
-            }
+        let content = UNMutableNotificationContent()
 
-            titles.append(String(format: NSLocalizedString("Carbs required: %d g", comment: "Carbs required"), carbs))
+        if snoozeUntilDate > Date() {
+            titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
+        } else {
+            content.sound = .default
+            playSoundIfNeeded()
+        }
 
-            content.title = titles.joined(separator: " ")
-            content.body = String(
-                format: NSLocalizedString(
-                    "To prevent LOW required %d g of carbs",
-                    comment: "To prevent LOW required %d g of carbs"
-                ),
-                carbs
-            )
+        titles.append(String(format: NSLocalizedString("Carbs required: %d g", comment: "Carbs required"), carbs))
 
-            self.addRequest(identifier: .carbsRequiredNotification, content: content, deleteOld: true)
-        }
+        content.title = titles.joined(separator: " ")
+        content.body = String(
+            format: NSLocalizedString(
+                "To prevent LOW required %d g of carbs",
+                comment: "To prevent LOW required %d g of carbs"
+            ),
+            carbs
+        )
+        addRequest(identifier: .carbsRequiredNotification, content: content, deleteOld: true)
     }
 
     private func scheduleMissingLoopNotifiactions(date _: Date) {
-        ensureCanSendNotification {
-            let title = NSLocalizedString("Trio Not Active", comment: "Trio Not Active")
-            let body = NSLocalizedString("Last loop was more than %d min ago", comment: "Last loop was more than %d min ago")
-
-            let firstInterval = 20 // min
-            let secondInterval = 40 // min
-
-            let firstContent = UNMutableNotificationContent()
-            firstContent.title = title
-            firstContent.body = String(format: body, firstInterval)
-            firstContent.sound = .default
-
-            let secondContent = UNMutableNotificationContent()
-            secondContent.title = title
-            secondContent.body = String(format: body, secondInterval)
-            secondContent.sound = .default
-
-            let firstTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(firstInterval), repeats: false)
-            let secondTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(secondInterval), repeats: false)
-
-            self.addRequest(
-                identifier: .noLoopFirstNotification,
-                content: firstContent,
-                deleteOld: true,
-                trigger: firstTrigger
-            )
-            self.addRequest(
-                identifier: .noLoopSecondNotification,
-                content: secondContent,
-                deleteOld: true,
-                trigger: secondTrigger
-            )
-        }
+        let title = NSLocalizedString("Trio Not Active", comment: "Trio Not Active")
+        let body = NSLocalizedString("Last loop was more than %d min ago", comment: "Last loop was more than %d min ago")
+
+        let firstInterval = 20 // min
+        let secondInterval = 40 // min
+
+        let firstContent = UNMutableNotificationContent()
+        firstContent.title = title
+        firstContent.body = String(format: body, firstInterval)
+        firstContent.sound = .default
+
+        let secondContent = UNMutableNotificationContent()
+        secondContent.title = title
+        secondContent.body = String(format: body, secondInterval)
+        secondContent.sound = .default
+
+        let firstTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(firstInterval), repeats: false)
+        let secondTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(secondInterval), repeats: false)
+
+        addRequest(
+            identifier: .noLoopFirstNotification,
+            content: firstContent,
+            deleteOld: true,
+            trigger: firstTrigger
+        )
+        addRequest(
+            identifier: .noLoopSecondNotification,
+            content: secondContent,
+            deleteOld: true,
+            trigger: secondTrigger
+        )
     }
 
     private func notifyBolusFailure() {
-        ensureCanSendNotification {
-            let title = NSLocalizedString("Bolus failed", comment: "Bolus failed")
-            let body = NSLocalizedString(
-                "Bolus failed or inaccurate. Check pump history before repeating.",
-                comment: "Bolus failed or inaccurate. Check pump history before repeating."
-            )
-
-            let content = UNMutableNotificationContent()
-            content.title = title
-            content.body = body
-            content.sound = .default
-
-            self.addRequest(
-                identifier: .noLoopFirstNotification,
-                content: content,
-                deleteOld: true,
-                trigger: nil
-            )
-        }
+        let title = NSLocalizedString("Bolus failed", comment: "Bolus failed")
+        let body = NSLocalizedString(
+            "Bolus failed or inaccurate. Check pump history before repeating.",
+            comment: "Bolus failed or inaccurate. Check pump history before repeating."
+        )
+        let content = UNMutableNotificationContent()
+        content.title = title
+        content.body = body
+        content.sound = .default
+
+        addRequest(
+            identifier: .noLoopFirstNotification,
+            content: content,
+            deleteOld: true,
+            trigger: nil
+        )
     }
 
     private func fetchGlucoseIDs() async -> [NSManagedObjectID] {
@@ -256,45 +255,43 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
 
             guard glucoseStorage.alarm != nil || settingsManager.settings.glucoseNotificationsAlways else { return }
 
-            ensureCanSendNotification {
-                var titles: [String] = []
-                var notificationAlarm = false
-
-                switch self.glucoseStorage.alarm {
-                case .none:
-                    titles.append(NSLocalizedString("Glucose", comment: "Glucose"))
-                case .low:
-                    titles.append(NSLocalizedString("LOWALERT!", comment: "LOWALERT!"))
-                    notificationAlarm = true
-                case .high:
-                    titles.append(NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!"))
-                    notificationAlarm = true
-                }
+            var titles: [String] = []
+            var notificationAlarm = false
+
+            switch glucoseStorage.alarm {
+            case .none:
+                titles.append(NSLocalizedString("Glucose", comment: "Glucose"))
+            case .low:
+                titles.append(NSLocalizedString("LOWALERT!", comment: "LOWALERT!"))
+                notificationAlarm = true
+            case .high:
+                titles.append(NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!"))
+                notificationAlarm = true
+            }
+
+            let delta = glucoseObjects.count >= 2 ? lastReading - secondLastReading : nil
+            let body = glucoseText(
+                glucoseValue: Int(lastReading),
+                delta: Int(delta ?? 0),
+                direction: lastDirection
+            ) + infoBody()
 
-                let delta = glucoseObjects.count >= 2 ? lastReading - secondLastReading : nil
-                let body = self.glucoseText(
-                    glucoseValue: Int(lastReading),
-                    delta: Int(delta ?? 0),
-                    direction: lastDirection
-                ) + self.infoBody()
-
-                if self.snoozeUntilDate > Date() {
-                    titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
-                    notificationAlarm = false
-                } else {
-                    titles.append(body)
-                    let content = UNMutableNotificationContent()
-                    content.title = titles.joined(separator: " ")
-                    content.body = body
-
-                    if notificationAlarm {
-                        self.playSoundIfNeeded()
-                        content.sound = .default
-                        content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
-                    }
-
-                    self.addRequest(identifier: .glucocoseNotification, content: content, deleteOld: true)
+            if snoozeUntilDate > Date() {
+                titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
+                notificationAlarm = false
+            } else {
+                titles.append(body)
+                let content = UNMutableNotificationContent()
+                content.title = titles.joined(separator: " ")
+                content.body = body
+
+                if notificationAlarm {
+                    playSoundIfNeeded()
+                    content.sound = .default
+                    content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
                 }
+
+                addRequest(identifier: .glucocoseNotification, content: content, deleteOld: true)
             }
         } catch {
             debugPrint(
@@ -396,9 +393,27 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         identifier: Identifier,
         content: UNMutableNotificationContent,
         deleteOld: Bool = false,
-        trigger: UNNotificationTrigger? = nil
+        trigger: UNNotificationTrigger? = nil,
+        messageType: MessageType = MessageType.other
     ) {
-        let request = UNNotificationRequest(identifier: identifier.rawValue, content: content, trigger: trigger)
+        if alertPermissionsChecker.notificationsDisabled, trigger == nil {
+            if trigger != nil {
+                debug(.default, "TODO: Triggers are not supported by alertMessage")
+                return
+            }
+            let messageCont = MessageContent(
+                content: content.body,
+                type: messageType,
+                title: content.title,
+                useAPN: false
+            )
+            router.alertMessage.send(messageCont)
+            return
+        }
+        let timestamp = Date().timeIntervalSince1970
+        let uniqueIdentifier = "\(identifier.rawValue)_\(timestamp)"
+        content.threadIdentifier = String(describing: messageType)
+        let request = UNNotificationRequest(identifier: uniqueIdentifier, content: content, trigger: trigger)
 
         if deleteOld {
             DispatchQueue.main.async {
@@ -464,24 +479,50 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     }
 }
 
+extension BaseUserNotificationsManager: alertMessageNotificationObserver {
+    func alertMessageNotification(_ message: MessageContent) {
+        let content = UNMutableNotificationContent()
+
+        switch message.type {
+        case .info:
+            content.title = NSLocalizedString("Info", comment: "Info title")
+        case .warning:
+            content.title = NSLocalizedString("Warning", comment: "Warning title")
+        case .errorPump:
+            content.title = NSLocalizedString("Error", comment: "Error title")
+        default:
+            content.title = message.title
+        }
+
+        content.body = NSLocalizedString(message.content, comment: "Info message")
+        content.sound = .default
+        addRequest(
+            identifier: .alertMessageNotification,
+            content: content,
+            deleteOld: true,
+            trigger: nil,
+            messageType: message.type
+        )
+    }
+}
+
 extension BaseUserNotificationsManager: pumpNotificationObserver {
     func pumpNotification(alert: AlertEntry) {
-        ensureCanSendNotification {
-            let content = UNMutableNotificationContent()
-            let alertUp = alert.alertIdentifier.uppercased()
-            if alertUp.contains("FAULT") || alertUp.contains("ERROR") {
-                content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue
-            }
-            content.title = alert.contentTitle ?? "Unknown"
-            content.body = alert.contentBody ?? "Unknown"
-            content.sound = .default
-            self.addRequest(
-                identifier: .pumpNotification,
-                content: content,
-                deleteOld: true,
-                trigger: nil
-            )
+        let content = UNMutableNotificationContent()
+        let alertUp = alert.alertIdentifier.uppercased()
+        if alertUp.contains("FAULT") || alertUp.contains("ERROR") {
+            content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue
         }
+        content.title = alert.contentTitle ?? "Unknown"
+        content.body = alert.contentBody ?? "Unknown"
+        content.sound = .default
+        addRequest(
+            identifier: .pumpNotification,
+            content: content,
+            deleteOld: true,
+            trigger: nil,
+            messageType: MessageType.errorPump
+        )
     }
 
     func pumpRemoveNotification() {
@@ -516,7 +557,7 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
         case Identifier.pumpNotification.rawValue:
             completionHandler([.banner, .badge, .sound, .list])
         default:
-            completionHandler([.banner, .badge, .sound])
+            completionHandler([.banner, .badge, .sound, .list])
         }
     }