Przeglądaj źródła

Alerts: critical-audio fallback, snooze unification, forecasted-low

* CriticalAlertAudioPlayer + SystemVolumeBooster ported from Loop next-dev
  (AVAudioPlayer with .playback/.duckOthers/.mixWithOthers bypasses silent
  switch / Focus modes on builds without the Critical Alerts entitlement)
* Bundle Loop's 18 .caf alarm sounds in Trio/Resources/Sounds
* TrioAlertCategory: .glucoseUrgentLow (.critical), .glucoseLow / .glucoseHigh
  (.timeSensitive), .glucoseForecastedLow (.timeSensitive)
* TrioAlertManager: critical alerts pierce the snooze/mute window and trigger
  the audio fallback; ack/retract stops playback
* BaseUserNotificationsManager.applySnooze mirrors into AlertMuter
* ForecastedGlucoseEvaluator: blend min of available predBG curves at +20m
  (mirrors oref-swift's multi-curve selection; min instead of max since we
  alert on lows, not size SMB)
* ForecastedGlucoseAlertObserver: DeterminationObserver fires
  glucose.forecastedLow when blend < settings.lowGlucose, retracts on
  recovery + 10 mg/dL margin
Deniz Cengiz 4 tygodni temu
rodzic
commit
cdfbfd9330

+ 16 - 0
Trio.xcodeproj/project.pbxproj

@@ -182,7 +182,11 @@
 		BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179212F4E22C100F90001 /* TrioAlertManager.swift */; };
 		BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179212F4E22C100F90001 /* TrioAlertManager.swift */; };
 		BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */; };
 		BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */; };
 		BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; };
 		BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; };
+		BD1179322F4E22C100F90001 /* CriticalAlertAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179312F4E22C100F90001 /* CriticalAlertAudioPlayer.swift */; };
+		BD1179342F4E22C100F90001 /* Sounds in Resources */ = {isa = PBXBuildFile; fileRef = BD1179332F4E22C100F90001 /* Sounds */; };
 		BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */; };
 		BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */; };
+		BD1179402F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */; };
+		BD1179422F4E22C100F90001 /* ForecastedGlucoseAlertObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179432F4E22C100F90001 /* ForecastedGlucoseAlertObserver.swift */; };
 		38E989DD25F5021400C0CED0 /* PumpStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E989DC25F5021400C0CED0 /* PumpStatus.swift */; };
 		38E989DD25F5021400C0CED0 /* PumpStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E989DC25F5021400C0CED0 /* PumpStatus.swift */; };
 		38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1B25F52C9300C0CED0 /* Signpost.swift */; };
 		38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1B25F52C9300C0CED0 /* Signpost.swift */; };
 		38E98A2425F52C9300C0CED0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1C25F52C9300C0CED0 /* Logger.swift */; };
 		38E98A2425F52C9300C0CED0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1C25F52C9300C0CED0 /* Logger.swift */; };
@@ -1077,7 +1081,11 @@
 		BD1179212F4E22C100F90001 /* TrioAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertManager.swift; sourceTree = "<group>"; };
 		BD1179212F4E22C100F90001 /* TrioAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertManager.swift; sourceTree = "<group>"; };
 		BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertScheduler.swift; sourceTree = "<group>"; };
 		BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertScheduler.swift; sourceTree = "<group>"; };
 		BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioUserNotificationAlertScheduler.swift; sourceTree = "<group>"; };
 		BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioUserNotificationAlertScheduler.swift; sourceTree = "<group>"; };
+		BD1179312F4E22C100F90001 /* CriticalAlertAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalAlertAudioPlayer.swift; sourceTree = "<group>"; };
+		BD1179332F4E22C100F90001 /* Sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Sounds; sourceTree = "<group>"; };
 		BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertCategory.swift; sourceTree = "<group>"; };
 		BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertCategory.swift; sourceTree = "<group>"; };
+		BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastedGlucoseEvaluator.swift; sourceTree = "<group>"; };
+		BD1179432F4E22C100F90001 /* ForecastedGlucoseAlertObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastedGlucoseAlertObserver.swift; sourceTree = "<group>"; };
 		38E989DC25F5021400C0CED0 /* PumpStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatus.swift; sourceTree = "<group>"; };
 		38E989DC25F5021400C0CED0 /* PumpStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatus.swift; sourceTree = "<group>"; };
 		38E98A1B25F52C9300C0CED0 /* Signpost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Signpost.swift; sourceTree = "<group>"; };
 		38E98A1B25F52C9300C0CED0 /* Signpost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Signpost.swift; sourceTree = "<group>"; };
 		38E98A1C25F52C9300C0CED0 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
 		38E98A1C25F52C9300C0CED0 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
@@ -2170,6 +2178,9 @@
 		BD1179262F4E22C100F90001 /* Alerts */ = {
 		BD1179262F4E22C100F90001 /* Alerts */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
+				BD1179312F4E22C100F90001 /* CriticalAlertAudioPlayer.swift */,
+				BD1179432F4E22C100F90001 /* ForecastedGlucoseAlertObserver.swift */,
+				BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */,
 				BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */,
 				BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */,
 				BD1179212F4E22C100F90001 /* TrioAlertManager.swift */,
 				BD1179212F4E22C100F90001 /* TrioAlertManager.swift */,
 				BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */,
 				BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */,
@@ -2273,6 +2284,7 @@
 			children = (
 			children = (
 				388E597125AD9CF10019842D /* json */,
 				388E597125AD9CF10019842D /* json */,
 				388E596E25AD96040019842D /* javascript */,
 				388E596E25AD96040019842D /* javascript */,
+				BD1179332F4E22C100F90001 /* Sounds */,
 				3811DEC725C9DA7300A708ED /* Trio.entitlements */,
 				3811DEC725C9DA7300A708ED /* Trio.entitlements */,
 				388E596425AD948E0019842D /* Info.plist */,
 				388E596425AD948E0019842D /* Info.plist */,
 				8A91342B2D63D9A2007F8874 /* InfoPlist.xcstrings */,
 				8A91342B2D63D9A2007F8874 /* InfoPlist.xcstrings */,
@@ -4234,6 +4246,7 @@
 				19DA48E829CD339B00EEA1E7 /* Assets.xcassets in Resources */,
 				19DA48E829CD339B00EEA1E7 /* Assets.xcassets in Resources */,
 				8A91342A2D63D9A1007F8874 /* Localizable.xcstrings in Resources */,
 				8A91342A2D63D9A1007F8874 /* Localizable.xcstrings in Resources */,
 				388E596F25AD96040019842D /* javascript in Resources */,
 				388E596F25AD96040019842D /* javascript in Resources */,
+				BD1179342F4E22C100F90001 /* Sounds in Resources */,
 			);
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 			runOnlyForDeploymentPostprocessing = 0;
 		};
 		};
@@ -4611,7 +4624,10 @@
 				BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */,
 				BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */,
 				BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */,
 				BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */,
 				BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */,
 				BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */,
+				BD1179322F4E22C100F90001 /* CriticalAlertAudioPlayer.swift in Sources */,
 				BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */,
 				BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */,
+				BD1179402F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift in Sources */,
+				BD1179422F4E22C100F90001 /* ForecastedGlucoseAlertObserver.swift in Sources */,
 				DD3F1F902D9E153F00DCE7B3 /* NightscoutImportStepView.swift in Sources */,
 				DD3F1F902D9E153F00DCE7B3 /* NightscoutImportStepView.swift in Sources */,
 				CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */,
 				CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */,
 				DD1745372C55B74200211FAC /* AlgorithmSettings.swift in Sources */,
 				DD1745372C55B74200211FAC /* AlgorithmSettings.swift in Sources */,

BIN
Trio/Resources/Sounds/alarm.caf


BIN
Trio/Resources/Sounds/bloom.caf


BIN
Trio/Resources/Sounds/bloop.caf


BIN
Trio/Resources/Sounds/bright_alarm.caf


BIN
Trio/Resources/Sounds/chime.caf


BIN
Trio/Resources/Sounds/clear_chimes.caf


BIN
Trio/Resources/Sounds/critical.caf


BIN
Trio/Resources/Sounds/crying.caf


BIN
Trio/Resources/Sounds/dings.caf


BIN
Trio/Resources/Sounds/high_chimes.caf


BIN
Trio/Resources/Sounds/honk.caf


BIN
Trio/Resources/Sounds/minimal.caf


BIN
Trio/Resources/Sounds/mood_synth.caf


BIN
Trio/Resources/Sounds/simple.caf


BIN
Trio/Resources/Sounds/spring.caf


BIN
Trio/Resources/Sounds/synth.caf


BIN
Trio/Resources/Sounds/trill.caf


BIN
Trio/Resources/Sounds/urgent_low.caf


+ 1 - 0
Trio/Sources/Application/TrioApp.swift

@@ -77,6 +77,7 @@ extension Notification.Name {
         _ = resolver.resolve(FetchTreatmentsManager.self)!
         _ = resolver.resolve(FetchTreatmentsManager.self)!
         _ = resolver.resolve(CalendarManager.self)!
         _ = resolver.resolve(CalendarManager.self)!
         _ = resolver.resolve(UserNotificationsManager.self)!
         _ = resolver.resolve(UserNotificationsManager.self)!
+        _ = resolver.resolve(ForecastedGlucoseAlertObserver.self)!
         _ = resolver.resolve(WatchManager.self)!
         _ = resolver.resolve(WatchManager.self)!
         _ = resolver.resolve(ContactImageManager.self)!
         _ = resolver.resolve(ContactImageManager.self)!
         _ = resolver.resolve(HealthKitManager.self)!
         _ = resolver.resolve(HealthKitManager.self)!

+ 2 - 0
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -19,6 +19,8 @@ final class ServiceAssembly: Assembly {
         container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
         container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
         container.register(TrioAlertManager.self) { r in BaseTrioAlertManager(resolver: r) }
         container.register(TrioAlertManager.self) { r in BaseTrioAlertManager(resolver: r) }
             .inObjectScope(.container)
             .inObjectScope(.container)
+        container.register(ForecastedGlucoseAlertObserver.self) { r in ForecastedGlucoseAlertObserver(resolver: r) }
+            .inObjectScope(.container)
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(BolusCalculationManager.self) { r in BaseBolusCalculationManager(resolver: r) }
         container.register(BolusCalculationManager.self) { r in BaseBolusCalculationManager(resolver: r) }

+ 166 - 0
Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift

@@ -0,0 +1,166 @@
+import AudioToolbox
+import AVFoundation
+import MediaPlayer
+import os.log
+import UIKit
+
+@MainActor
+final class CriticalAlertAudioPlayer {
+    private let log = OSLog(subsystem: "org.nightscout.Trio", category: "CriticalAlertAudioPlayer")
+
+    private var player: AVAudioPlayer?
+    private var stopTimer: Timer?
+    private var vibrationTimer: Timer?
+
+    private let vibrationInterval: TimeInterval = 2.0
+    private let boostedVolume: Float = 1.0
+    private let volumeBooster = SystemVolumeBooster()
+    private let maxDuration: TimeInterval = 6
+
+    var isPlaying: Bool { player?.isPlaying ?? false }
+
+    /// Start (or restart) looping playback of a bundled critical alert sound.
+    /// Safe to call repeatedly — replaces any current playback.
+    func play(soundNamed soundName: String = "critical.caf") {
+        stop()
+
+        startVibration()
+        stopTimer = Timer.scheduledTimer(withTimeInterval: maxDuration, repeats: false) { [weak self] _ in
+            Task { @MainActor in self?.stop() }
+        }
+
+        let resource = (soundName as NSString).deletingPathExtension
+        let ext = (soundName as NSString).pathExtension.isEmpty ? "caf" : (soundName as NSString).pathExtension
+        let url = Bundle.main.url(forResource: resource, withExtension: ext, subdirectory: "Sounds")
+            ?? Bundle.main.url(forResource: "critical", withExtension: "caf", subdirectory: "Sounds")
+        guard let url else {
+            os_log(
+                "Neither %{public}@ nor critical.caf found in main bundle; audio fallback unavailable",
+                log: log,
+                type: .error,
+                soundName
+            )
+            return
+        }
+
+        do {
+            let session = AVAudioSession.sharedInstance()
+            // .playback ignores the silent switch and Focus modes. Must be
+            // mixable: iOS refuses to activate a non-mixable session from
+            // the background (AVAudioSessionErrorCodeCannotInterruptOthers,
+            // 560557684 "Session activation failed"). .duckOthers +
+            // .mixWithOthers ducks/mixes our alarm over other audio instead
+            // of failing outright. Mixable .playback still plays at full
+            // volume when nothing else is active and still bypasses the
+            // silent switch.
+            try session.setCategory(.playback, mode: .default, options: [.duckOthers, .mixWithOthers])
+            try session.setActive(true, options: [])
+            volumeBooster.boost(to: boostedVolume)
+
+            let p = try AVAudioPlayer(contentsOf: url)
+            p.numberOfLoops = -1
+            p.volume = 1.0
+            p.prepareToPlay()
+            guard p.play() else {
+                os_log("AVAudioPlayer.play() returned false for %{public}@", log: log, type: .error, url.lastPathComponent)
+                try? session.setActive(false, options: [.notifyOthersOnDeactivation])
+                return
+            }
+            player = p
+            os_log("Started critical-alert audio playback (duration=%{public}.2fs)", log: log, type: .info, p.duration)
+        } catch {
+            os_log("Failed to start audio playback: %{public}@", log: log, type: .error, String(describing: error))
+        }
+    }
+
+    private func startVibration() {
+        AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
+        vibrationTimer = Timer.scheduledTimer(withTimeInterval: vibrationInterval, repeats: true) { _ in
+            AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
+        }
+    }
+
+    /// Stop playback if any. Safe to call when not playing.
+    func stop() {
+        guard player != nil || stopTimer != nil || vibrationTimer != nil else { return }
+        player?.stop()
+        player = nil
+        stopTimer?.invalidate()
+        stopTimer = nil
+        vibrationTimer?.invalidate()
+        vibrationTimer = nil
+        volumeBooster.restore()
+        try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
+        os_log("Stopped critical-alert audio playback", log: log, type: .info)
+    }
+}
+
+/// Best-effort override of the system output volume.
+///
+/// iOS has no public API to set the hardware volume. `MPVolumeView` hosts a
+/// `UISlider` that drives the system volume when its value changes — the
+/// long-standing workaround. Used ONLY for critical-alert audio on builds
+/// without the Critical Alerts entitlement: without it an urgent-low alarm
+/// can be silent when the ringer is down overnight.
+///
+/// Best-effort: the slider only populates once the hosting view is in an
+/// on-screen window, so it may no-op when the app is fully backgrounded. The
+/// alarm's audio and vibration run regardless — this only makes them louder
+/// when it can. Pre-boost level is restored on `restore()` unless the user
+/// manually changed the volume in the meantime.
+@MainActor private final class SystemVolumeBooster {
+    private let volumeView = MPVolumeView(frame: CGRect(x: -2000, y: -2000, width: 1, height: 1))
+    private var savedVolume: Float?
+    private var targetVolume: Float?
+    private var generation = 0
+
+    func boost(to target: Float) {
+        guard let window = Self.activeWindow() else { return }
+        attach(to: window)
+        generation &+= 1
+        let gen = generation
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
+            guard let self, gen == self.generation, let slider = self.slider() else { return }
+            let current = slider.value
+            guard current < target else { return }
+            self.savedVolume = current
+            self.targetVolume = target
+            slider.setValue(target, animated: false)
+        }
+    }
+
+    func restore() {
+        generation &+= 1
+        if let saved = savedVolume,
+           let target = targetVolume,
+           let slider = slider(),
+           abs(slider.value - target) < 0.05
+        {
+            slider.setValue(saved, animated: false)
+        }
+        savedVolume = nil
+        targetVolume = nil
+        volumeView.removeFromSuperview()
+    }
+
+    private func attach(to window: UIWindow) {
+        volumeView.alpha = 0.0001
+        volumeView.isUserInteractionEnabled = false
+        if volumeView.superview !== window {
+            volumeView.removeFromSuperview()
+            window.addSubview(volumeView)
+        }
+    }
+
+    private func slider() -> UISlider? {
+        volumeView.subviews.compactMap { $0 as? UISlider }.first
+    }
+
+    private static func activeWindow() -> UIWindow? {
+        let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
+        let active = scenes.first { $0.activationState == .foregroundActive }
+        return active?.windows.first(where: { $0.isKeyWindow })
+            ?? active?.windows.first
+            ?? scenes.flatMap(\.windows).first
+    }
+}

+ 77 - 0
Trio/Sources/Services/Alerts/ForecastedGlucoseAlertObserver.swift

@@ -0,0 +1,77 @@
+import Foundation
+import LoopKit
+import Swinject
+
+/// Listens to `DeterminationObserver` and issues `glucose.forecastedLow` when
+/// the blended +20-min prediction (see `ForecastedGlucoseEvaluator`) drops
+/// below the user's low-glucose threshold. Retracts once the forecast
+/// recovers above the threshold plus a small margin to avoid flap.
+final class ForecastedGlucoseAlertObserver: Injectable {
+    @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var trioAlertManager: TrioAlertManager!
+    @Injected() private var settingsManager: SettingsManager!
+
+    private static let recoveryMarginMgDL: Decimal = 10
+    private static let alertIdentifier = "glucose.forecastedLow"
+
+    private let queue = DispatchQueue(label: "ForecastedGlucoseAlertObserver.queue")
+    private var isFiring = false
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        broadcaster.register(DeterminationObserver.self, observer: self)
+    }
+
+    private var alertID: Alert.Identifier {
+        Alert.Identifier(
+            managerIdentifier: BaseTrioAlertManager.managerIdentifier,
+            alertIdentifier: Self.alertIdentifier
+        )
+    }
+}
+
+extension ForecastedGlucoseAlertObserver: DeterminationObserver {
+    func determinationDidUpdate(_ determination: Determination) {
+        guard let result = ForecastedGlucoseEvaluator.evaluate(determination: determination) else {
+            return
+        }
+        let threshold = settingsManager.settings.lowGlucose
+        let predicted = result.predictedGlucose
+        let units = settingsManager.settings.units
+
+        queue.async { [weak self] in
+            guard let self else { return }
+            let firing = self.isFiring
+
+            if predicted < threshold, !firing {
+                self.isFiring = true
+                let title = String(localized: "Predicted Low")
+                let body = String(
+                    format: String(
+                        localized: "Glucose forecasted to reach %1$@ within %2$d minutes (limit: %3$@)."
+                    ),
+                    predicted.formatted(withUnits: units),
+                    result.horizonMinutes,
+                    threshold.formatted(withUnits: units)
+                )
+                let content = Alert.Content(
+                    title: title,
+                    body: body,
+                    acknowledgeActionButtonLabel: String(localized: "OK")
+                )
+                let alert = Alert(
+                    identifier: self.alertID,
+                    foregroundContent: content,
+                    backgroundContent: content,
+                    trigger: .immediate,
+                    interruptionLevel: TrioAlertCategory.glucoseForecastedLow.interruptionLevel,
+                    sound: .sound(name: "trill.caf")
+                )
+                self.trioAlertManager.issueAlert(alert)
+            } else if firing, predicted >= threshold + Self.recoveryMarginMgDL {
+                self.isFiring = false
+                self.trioAlertManager.retractAlert(identifier: self.alertID)
+            }
+        }
+    }
+}

+ 53 - 0
Trio/Sources/Services/Alerts/ForecastedGlucoseEvaluator.swift

@@ -0,0 +1,53 @@
+import Foundation
+
+/// Picks a predicted-glucose value at a fixed horizon across the available
+/// `Determination.predictions` curves (IOB / COB / UAM / ZT).
+///
+/// Mirrors the multi-curve selection in the oref-swift port
+/// (`ForecastGenerator.blendForecasts`) but at a single index instead of
+/// across the post-peak window. The Trio determination JSON already holds
+/// every curve at 5-min increments, so index 4 ≈ +20 min.
+///
+/// Reduction is `min` of available curves at the horizon — conservative for
+/// low detection. oref-swift's own blender uses `max` because it feeds SMB
+/// dosing safety; flipping to `min` for an alarm uses the same input set but
+/// with the inverted intent.
+enum ForecastedGlucoseEvaluator {
+    static let defaultHorizonMinutes = 20
+
+    enum Curve: String { case iob, cob, uam, zt }
+
+    struct Result: Equatable {
+        let predictedGlucose: Decimal
+        let horizonMinutes: Int
+        let curvesUsed: Set<Curve>
+        let perCurve: [Curve: Decimal]
+    }
+
+    static func evaluate(
+        determination: Determination,
+        horizonMinutes: Int = defaultHorizonMinutes
+    ) -> Result? {
+        let index = horizonMinutes / 5
+        guard let predictions = determination.predictions else { return nil }
+
+        var samples: [Curve: Decimal] = [:]
+        if let v = sample(predictions.iob, at: index) { samples[.iob] = v }
+        if let v = sample(predictions.cob, at: index) { samples[.cob] = v }
+        if let v = sample(predictions.uam, at: index) { samples[.uam] = v }
+        if let v = sample(predictions.zt, at: index) { samples[.zt] = v }
+        guard let minSample = samples.values.min() else { return nil }
+
+        return Result(
+            predictedGlucose: minSample,
+            horizonMinutes: horizonMinutes,
+            curvesUsed: Set(samples.keys),
+            perCurve: samples
+        )
+    }
+
+    private static func sample(_ array: [Int]?, at index: Int) -> Decimal? {
+        guard let array, array.indices.contains(index) else { return nil }
+        return Decimal(array[index])
+    }
+}

+ 16 - 0
Trio/Sources/Services/Alerts/TrioAlertCategory.swift

@@ -14,6 +14,10 @@ enum TrioAlertCategory: Equatable {
     case suspendTimeExpired
     case suspendTimeExpired
     case bolusFailed
     case bolusFailed
     case manualTempBasalActive
     case manualTempBasalActive
+    case glucoseUrgentLow
+    case glucoseLow
+    case glucoseForecastedLow
+    case glucoseHigh
     case glucoseDataStale
     case glucoseDataStale
     case algorithmError
     case algorithmError
     case commsTransient
     case commsTransient
@@ -25,6 +29,10 @@ enum TrioAlertCategory: Equatable {
              .batteryLow,
              .batteryLow,
              .bolusFailed,
              .bolusFailed,
              .glucoseDataStale,
              .glucoseDataStale,
+             .glucoseForecastedLow,
+             .glucoseHigh,
+             .glucoseLow,
+             .glucoseUrgentLow,
              .manualTempBasalActive,
              .manualTempBasalActive,
              .occlusion,
              .occlusion,
              .other,
              .other,
@@ -45,6 +53,7 @@ enum TrioAlertCategory: Equatable {
     var interruptionLevel: Alert.InterruptionLevel {
     var interruptionLevel: Alert.InterruptionLevel {
         switch self {
         switch self {
         case .batteryEmpty,
         case .batteryEmpty,
+             .glucoseUrgentLow,
              .occlusion,
              .occlusion,
              .pumpFault,
              .pumpFault,
              .reservoirEmpty:
              .reservoirEmpty:
@@ -52,6 +61,9 @@ enum TrioAlertCategory: Equatable {
         case .batteryLow,
         case .batteryLow,
              .bolusFailed,
              .bolusFailed,
              .glucoseDataStale,
              .glucoseDataStale,
+             .glucoseForecastedLow,
+             .glucoseHigh,
+             .glucoseLow,
              .manualTempBasalActive,
              .manualTempBasalActive,
              .podExpired,
              .podExpired,
              .podShutdownImminent,
              .podShutdownImminent,
@@ -105,6 +117,10 @@ enum TrioAlertClassifier {
         }
         }
         if id.contains("bolusfailed") { return .bolusFailed }
         if id.contains("bolusfailed") { return .bolusFailed }
         if id.contains("manualtempbasal") { return .manualTempBasalActive }
         if id.contains("manualtempbasal") { return .manualTempBasalActive }
+        if id.contains("glucose.urgentlow") || id.contains("glucoseurgentlow") { return .glucoseUrgentLow }
+        if id.contains("glucose.forecastedlow") || id.contains("glucoseforecastedlow") { return .glucoseForecastedLow }
+        if id.contains("glucose.low") || id.contains("glucoselow") { return .glucoseLow }
+        if id.contains("glucose.high") || id.contains("glucosehigh") { return .glucoseHigh }
         if id.contains("glucose"), id.contains("stale") { return .glucoseDataStale }
         if id.contains("glucose"), id.contains("stale") { return .glucoseDataStale }
         return .other(alertIdentifier)
         return .other(alertIdentifier)
     }
     }

+ 35 - 1
Trio/Sources/Services/Alerts/TrioAlertManager.swift

@@ -35,6 +35,10 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
     let muter: AlertMuter
     let muter: AlertMuter
     private let throttler: AlertThrottler
     private let throttler: AlertThrottler
     private let soundLoader: AlertSoundLoader
     private let soundLoader: AlertSoundLoader
+    /// Created lazily on first main-actor access. `CriticalAlertAudioPlayer`
+    /// is `@MainActor` (its `MPVolumeView` + `Timer` members require main),
+    /// but `BaseTrioAlertManager.init` runs on the Swinject resolve thread.
+    @MainActor private var criticalAudioPlayer: CriticalAlertAudioPlayer?
 
 
     let modalScheduler: TrioModalAlertScheduler
     let modalScheduler: TrioModalAlertScheduler
     private let userNotificationScheduler: TrioUserNotificationAlertScheduler
     private let userNotificationScheduler: TrioUserNotificationAlertScheduler
@@ -62,6 +66,25 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         userNotificationScheduler.responder = self
         userNotificationScheduler.responder = self
     }
     }
 
 
+    /// Falls back to in-process AVAudioPlayer for `.critical` alerts on
+    /// builds without the Critical Alerts entitlement. iOS silently
+    /// downgrades `.criticalSoundNamed` and `.defaultCritical` to a regular
+    /// notification without the entitlement, which DnD / silent switch /
+    /// Focus modes then mute — dangerous for an overnight urgent-low.
+    /// `.playback` audio session bypasses those.
+    ///
+    /// Only fires for immediate-trigger alerts; delayed/repeating go
+    /// through UNNotification at fire time. No-op when muted or non-critical.
+    private func playCriticalAudioFallbackIfNeeded(_ alert: Alert, muted: Bool) {
+        guard alert.interruptionLevel == .critical, !muted else { return }
+        guard case .immediate = alert.trigger else { return }
+        let soundName = alert.sound?.filename ?? "critical.caf"
+        Task { @MainActor in
+            if criticalAudioPlayer == nil { criticalAudioPlayer = CriticalAlertAudioPlayer() }
+            criticalAudioPlayer?.play(soundNamed: soundName)
+        }
+    }
+
     // MARK: - Issue / Retract
     // MARK: - Issue / Retract
 
 
     func issueAlert(_ alert: Alert) {
     func issueAlert(_ alert: Alert) {
@@ -74,6 +97,14 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
             debug(.service, "TrioAlertManager dropped \(alert.identifier.value): \(category) not alert-worthy")
             debug(.service, "TrioAlertManager dropped \(alert.identifier.value): \(category) not alert-worthy")
             return
             return
         }
         }
+        let now = Date()
+        // Critical alerts pierce the snooze/mute window. Everything else is
+        // suppressed entirely while muted (no modal, no UN sound, no critical
+        // audio fallback).
+        if alert.interruptionLevel != .critical, muter.shouldMute(at: now) {
+            debug(.service, "TrioAlertManager muted \(alert.identifier.value) (snooze window active)")
+            return
+        }
         guard throttler.shouldDeliver(alert) else {
         guard throttler.shouldDeliver(alert) else {
             debug(.service, "TrioAlertManager throttled \(alert.identifier.value)")
             debug(.service, "TrioAlertManager throttled \(alert.identifier.value)")
             return
             return
@@ -82,13 +113,14 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
             self.liveAlerts[alert.identifier] = alert
             self.liveAlerts[alert.identifier] = alert
         }
         }
         recordIssued(alert)
         recordIssued(alert)
-        let muted = muter.shouldMute(at: Date())
+        let muted = muter.shouldMute(at: now)
         modalScheduler.schedule(alert)
         modalScheduler.schedule(alert)
         userNotificationScheduler.schedule(
         userNotificationScheduler.schedule(
             alert,
             alert,
             muted: muted,
             muted: muted,
             soundURL: soundLoader.url(for: alert)
             soundURL: soundLoader.url(for: alert)
         )
         )
+        playCriticalAudioFallbackIfNeeded(alert, muted: muted)
     }
     }
 
 
     func retractAlert(identifier: Alert.Identifier) {
     func retractAlert(identifier: Alert.Identifier) {
@@ -97,6 +129,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         }
         }
         modalScheduler.unschedule(identifier: identifier)
         modalScheduler.unschedule(identifier: identifier)
         userNotificationScheduler.unschedule(identifier: identifier)
         userNotificationScheduler.unschedule(identifier: identifier)
+        Task { @MainActor in criticalAudioPlayer?.stop() }
         alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
         alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
     }
     }
 
 
@@ -105,6 +138,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
     func handleAcknowledgement(identifier: Alert.Identifier) {
     func handleAcknowledgement(identifier: Alert.Identifier) {
         modalScheduler.unschedule(identifier: identifier)
         modalScheduler.unschedule(identifier: identifier)
         userNotificationScheduler.unschedule(identifier: identifier)
         userNotificationScheduler.unschedule(identifier: identifier)
+        Task { @MainActor in criticalAudioPlayer?.stop() }
         alertHistoryStorage.acknowledgeAlert(issuedDate(for: identifier) ?? Date(), nil)
         alertHistoryStorage.acknowledgeAlert(issuedDate(for: identifier) ?? Date(), nil)
         queue.async {
         queue.async {
             self.liveAlerts.removeValue(forKey: identifier)
             self.liveAlerts.removeValue(forKey: identifier)

+ 1 - 1
Trio/Sources/Services/Alerts/TrioModalAlertScheduler.swift

@@ -199,7 +199,7 @@ struct TrioAlertModifier: ViewModifier {
         .animation(.spring(response: 0.35, dampingFraction: 0.85), value: isExpanded)
         .animation(.spring(response: 0.35, dampingFraction: 0.85), value: isExpanded)
     }
     }
 
 
-    @ViewBuilder  private var bannerStack: some View {
+    @ViewBuilder private var bannerStack: some View {
         if isExpanded || scheduler.active.count <= 1 {
         if isExpanded || scheduler.active.count <= 1 {
             VStack(spacing: 8) {
             VStack(spacing: 8) {
                 ForEach(scheduler.active, id: \.identifier) { alert in
                 ForEach(scheduler.active, id: \.identifier) { alert in

+ 10 - 0
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -447,6 +447,16 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         // removeGlucoseNotifications() is safe to call here since we're @MainActor
         // removeGlucoseNotifications() is safe to call here since we're @MainActor
         removeGlucoseNotifications()
         removeGlucoseNotifications()
 
 
+        // Mirror the snooze into the unified AlertMuter so non-critical
+        // LoopKit alerts (pump/cgm/algorithm) are suppressed during the
+        // same window. Critical alerts (e.g. glucose.urgentLow) pierce
+        // the mute in `TrioAlertManager.issueAlert`.
+        if duration > 0 {
+            trioAlertManager.muter.mute(for: duration)
+        } else {
+            trioAlertManager.muter.unmute()
+        }
+
         // Notify observers that snooze was applied
         // Notify observers that snooze was applied
         broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in
         broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in
             observer.snoozeDidChange(untilDate)
             observer.snoozeDidChange(untilDate)