Просмотр исходного кода

Replay unacknowledged alerts on cold start

dnzxy 21 часов назад
Родитель
Сommit
13024e417b

+ 8 - 0
Trio.xcodeproj/project.pbxproj

@@ -467,6 +467,7 @@
 		BD11A001000000000000A011 /* OmnipodKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A011 /* OmnipodKitAlertEmissionTests.swift */; };
 		BD11A001000000000000A011 /* OmnipodKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A011 /* OmnipodKitAlertEmissionTests.swift */; };
 		BD11A001000000000000A012 /* RileyLinkKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A012 /* RileyLinkKitAlertEmissionTests.swift */; };
 		BD11A001000000000000A012 /* RileyLinkKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A012 /* RileyLinkKitAlertEmissionTests.swift */; };
 		BD11A001000000000000A013 /* TandemKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A013 /* TandemKitAlertEmissionTests.swift */; };
 		BD11A001000000000000A013 /* TandemKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A013 /* TandemKitAlertEmissionTests.swift */; };
+		BD11A001000000000000A014 /* AlertColdStartReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A014 /* AlertColdStartReplayTests.swift */; };
 		BD11C001000000000000C001 /* CGMSensorDisplayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C001 /* CGMSensorDisplayState.swift */; };
 		BD11C001000000000000C001 /* CGMSensorDisplayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C001 /* CGMSensorDisplayState.swift */; };
 		BD11C001000000000000C003 /* SensorLifecycleArcView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C003 /* SensorLifecycleArcView.swift */; };
 		BD11C001000000000000C003 /* SensorLifecycleArcView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C003 /* SensorLifecycleArcView.swift */; };
 		BD11C001000000000000C004 /* SensorStatusTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C004 /* SensorStatusTagView.swift */; };
 		BD11C001000000000000C004 /* SensorStatusTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C004 /* SensorStatusTagView.swift */; };
@@ -606,6 +607,7 @@
 		CA01000000000000000010C2 /* AlertCatalogVendor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C1 /* AlertCatalogVendor.swift */; };
 		CA01000000000000000010C2 /* AlertCatalogVendor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C1 /* AlertCatalogVendor.swift */; };
 		CA01000000000000000010C4 /* AlertCatalogRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C3 /* AlertCatalogRegistry.swift */; };
 		CA01000000000000000010C4 /* AlertCatalogRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C3 /* AlertCatalogRegistry.swift */; };
 		CA01000000000000000010C6 /* CGMManagerAlertOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */; };
 		CA01000000000000000010C6 /* CGMManagerAlertOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */; };
+		CA01000000000000000010C8 /* AlertColdStartReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C7 /* AlertColdStartReplay.swift */; };
 		CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */; };
 		CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */; };
 		CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */; };
 		CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */; };
 		CA05000000000000000010C2 /* AlarmGramsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA05000000000000000010C1 /* AlarmGramsSection.swift */; };
 		CA05000000000000000010C2 /* AlarmGramsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA05000000000000000010C1 /* AlarmGramsSection.swift */; };
@@ -1479,6 +1481,7 @@
 		BD11A000000000000000A011 /* OmnipodKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnipodKitAlertEmissionTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A011 /* OmnipodKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnipodKitAlertEmissionTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A012 /* RileyLinkKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkKitAlertEmissionTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A012 /* RileyLinkKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkKitAlertEmissionTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A013 /* TandemKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TandemKitAlertEmissionTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A013 /* TandemKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TandemKitAlertEmissionTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A014 /* AlertColdStartReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertColdStartReplayTests.swift; sourceTree = "<group>"; };
 		BD11C000000000000000C001 /* CGMSensorDisplayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMSensorDisplayState.swift; sourceTree = "<group>"; };
 		BD11C000000000000000C001 /* CGMSensorDisplayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMSensorDisplayState.swift; sourceTree = "<group>"; };
 		BD11C000000000000000C003 /* SensorLifecycleArcView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorLifecycleArcView.swift; sourceTree = "<group>"; };
 		BD11C000000000000000C003 /* SensorLifecycleArcView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorLifecycleArcView.swift; sourceTree = "<group>"; };
 		BD11C000000000000000C004 /* SensorStatusTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorStatusTagView.swift; sourceTree = "<group>"; };
 		BD11C000000000000000C004 /* SensorStatusTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorStatusTagView.swift; sourceTree = "<group>"; };
@@ -1617,6 +1620,7 @@
 		CA01000000000000000010C1 /* AlertCatalogVendor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogVendor.swift; sourceTree = "<group>"; };
 		CA01000000000000000010C1 /* AlertCatalogVendor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogVendor.swift; sourceTree = "<group>"; };
 		CA01000000000000000010C3 /* AlertCatalogRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistry.swift; sourceTree = "<group>"; };
 		CA01000000000000000010C3 /* AlertCatalogRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistry.swift; sourceTree = "<group>"; };
 		CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMManagerAlertOwnership.swift; sourceTree = "<group>"; };
 		CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMManagerAlertOwnership.swift; sourceTree = "<group>"; };
+		CA01000000000000000010C7 /* AlertColdStartReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertColdStartReplay.swift; sourceTree = "<group>"; };
 		CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryLimitsSyncTests.swift; sourceTree = "<group>"; };
 		CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryLimitsSyncTests.swift; sourceTree = "<group>"; };
 		CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistryOmniFaultTests.swift; sourceTree = "<group>"; };
 		CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistryOmniFaultTests.swift; sourceTree = "<group>"; };
 		CA05000000000000000010C1 /* AlarmGramsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmGramsSection.swift; sourceTree = "<group>"; };
 		CA05000000000000000010C1 /* AlarmGramsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmGramsSection.swift; sourceTree = "<group>"; };
@@ -2997,6 +3001,7 @@
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				BD11A000000000000000A002 /* AlertMuterTests.swift */,
 				BD11A000000000000000A002 /* AlertMuterTests.swift */,
 				BD11A000000000000000A013 /* TandemKitAlertEmissionTests.swift */,
 				BD11A000000000000000A013 /* TandemKitAlertEmissionTests.swift */,
+				BD11A000000000000000A014 /* AlertColdStartReplayTests.swift */,
 				BD11A000000000000000A012 /* RileyLinkKitAlertEmissionTests.swift */,
 				BD11A000000000000000A012 /* RileyLinkKitAlertEmissionTests.swift */,
 				BD11A000000000000000A011 /* OmnipodKitAlertEmissionTests.swift */,
 				BD11A000000000000000A011 /* OmnipodKitAlertEmissionTests.swift */,
 				BD11A000000000000000A010 /* MinimedKitAlertEmissionTests.swift */,
 				BD11A000000000000000A010 /* MinimedKitAlertEmissionTests.swift */,
@@ -3415,6 +3420,7 @@
 				BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */,
 				BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */,
 				CA01000000000000000010C1 /* AlertCatalogVendor.swift */,
 				CA01000000000000000010C1 /* AlertCatalogVendor.swift */,
 				CA01000000000000000010C3 /* AlertCatalogRegistry.swift */,
 				CA01000000000000000010C3 /* AlertCatalogRegistry.swift */,
+				CA01000000000000000010C7 /* AlertColdStartReplay.swift */,
 				CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */,
 				CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */,
 			);
 			);
 			path = Alerts;
 			path = Alerts;
@@ -5194,6 +5200,7 @@
 				CA01000000000000000010C2 /* AlertCatalogVendor.swift in Sources */,
 				CA01000000000000000010C2 /* AlertCatalogVendor.swift in Sources */,
 				CA01000000000000000010C4 /* AlertCatalogRegistry.swift in Sources */,
 				CA01000000000000000010C4 /* AlertCatalogRegistry.swift in Sources */,
 				CA01000000000000000010C6 /* CGMManagerAlertOwnership.swift in Sources */,
 				CA01000000000000000010C6 /* CGMManagerAlertOwnership.swift in Sources */,
+				CA01000000000000000010C8 /* AlertColdStartReplay.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 */,
 				BD1179322F4E22C100F90001 /* CriticalAlertAudioPlayer.swift in Sources */,
@@ -5677,6 +5684,7 @@
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */,
 				BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */,
 				BD11A001000000000000A013 /* TandemKitAlertEmissionTests.swift in Sources */,
 				BD11A001000000000000A013 /* TandemKitAlertEmissionTests.swift in Sources */,
+				BD11A001000000000000A014 /* AlertColdStartReplayTests.swift in Sources */,
 				BD11A001000000000000A012 /* RileyLinkKitAlertEmissionTests.swift in Sources */,
 				BD11A001000000000000A012 /* RileyLinkKitAlertEmissionTests.swift in Sources */,
 				BD11A001000000000000A011 /* OmnipodKitAlertEmissionTests.swift in Sources */,
 				BD11A001000000000000A011 /* OmnipodKitAlertEmissionTests.swift in Sources */,
 				BD11A001000000000000A010 /* MinimedKitAlertEmissionTests.swift in Sources */,
 				BD11A001000000000000A010 /* MinimedKitAlertEmissionTests.swift in Sources */,

+ 34 - 1
Trio/Sources/APS/Storage/AlertStorage.swift

@@ -10,6 +10,7 @@ protocol AlertObserver {
 protocol AlertHistoryStorage {
 protocol AlertHistoryStorage {
     func addAlert(_ alert: AlertEntry)
     func addAlert(_ alert: AlertEntry)
     func acknowledgeAlert(_ issuedAt: Date, _ error: String?)
     func acknowledgeAlert(_ issuedAt: Date, _ error: String?)
+    func acknowledgeAllEntries(managerIdentifier: String, alertIdentifier: String)
     func removeAlert(identifier: String)
     func removeAlert(identifier: String)
     func unacknowledgedAlertsWithinLast24Hours() -> [AlertEntry]
     func unacknowledgedAlertsWithinLast24Hours() -> [AlertEntry]
     func broadcastAlertUpdates()
     func broadcastAlertUpdates()
@@ -129,14 +130,46 @@ final class BaseAlertHistoryStorage: AlertHistoryStorage, Injectable {
         }
         }
     }
     }
 
 
+    /// Marks every unacknowledged entry matching both identifiers as acknowledged.
+    ///
+    /// Used by the acknowledgement path instead of the issued-date lookup so
+    /// duplicate entries for one identifier (e.g. cold-start replay plus a
+    /// genuine producer re-issue) all clear together.
+    func acknowledgeAllEntries(managerIdentifier: String, alertIdentifier: String) {
+        processQueue.sync {
+            var all = loadAll()
+            var changed = false
+            for idx in all.indices
+                where all[idx].managerIdentifier == managerIdentifier
+                && all[idx].alertIdentifier == alertIdentifier
+                && all[idx].acknowledgedDate == nil
+            {
+                all[idx].acknowledgedDate = Date()
+                changed = true
+            }
+            guard changed else { return }
+
+            let cleaned = pruneAndSort(dedupeByIssuedDate(all))
+            saveAll(cleaned)
+            unacknowledgedAlertsPublisher.send(self.unacknowledgedAlertsWithinLast24HoursOnQueue().isNotEmpty)
+            broadcaster.notify(AlertObserver.self, on: processQueue) {
+                $0.AlertDidUpdate(cleaned)
+            }
+        }
+    }
+
     /// Deletes an alert entry by its identifier and notifies observers.
     /// Deletes an alert entry by its identifier and notifies observers.
     ///
     ///
+    /// Only unacknowledged entries match — producer retractions must not
+    /// erase rows the user already acknowledged.
+    ///
     /// After persisting, this updates `unacknowledgedAlertsPublisher` and broadcasts the updated list.
     /// After persisting, this updates `unacknowledgedAlertsPublisher` and broadcasts the updated list.
     /// - Parameter identifier: The `alertIdentifier` of the entry to delete.
     /// - Parameter identifier: The `alertIdentifier` of the entry to delete.
     func removeAlert(identifier: String) {
     func removeAlert(identifier: String) {
         processQueue.sync {
         processQueue.sync {
             var all = loadAll()
             var all = loadAll()
-            guard let idx = all.firstIndex(where: { $0.alertIdentifier == identifier }) else { return }
+            guard let idx = all.firstIndex(where: { $0.alertIdentifier == identifier && $0.acknowledgedDate == nil })
+            else { return }
 
 
             all.remove(at: idx)
             all.remove(at: idx)
 
 

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

@@ -93,6 +93,9 @@ extension Notification.Name {
         _ = resolver.resolve(GlucoseAlertCoordinator.self)!
         _ = resolver.resolve(GlucoseAlertCoordinator.self)!
         _ = resolver.resolve(NotLoopingMonitor.self)!
         _ = resolver.resolve(NotLoopingMonitor.self)!
         _ = DeviceAlertsStore.shared
         _ = DeviceAlertsStore.shared
+        // Last: needs the pump manager's AlertResponder registration and the
+        // seeded DeviceAlertsStore in place before re-presenting alerts.
+        resolver.resolve(TrioAlertManager.self)!.replayUnacknowledgedAlerts()
     }
     }
 
 
     init() {
     init() {

+ 89 - 0
Trio/Sources/Services/Alerts/AlertColdStartReplay.swift

@@ -0,0 +1,89 @@
+import Foundation
+import LoopKit
+
+/// Rebuilds unacknowledged history entries into presentable alerts at app
+/// launch. In-app presentation state (`liveAlerts` + banner queue) dies with
+/// the process, but every issued alert is persisted to `AlertHistoryStorage`
+/// first — so a force-quit must not lose the user's only in-app surface for
+/// acknowledging a device alert (e.g. a beeping pod).
+///
+/// Pure decision logic — mirrors `issueAlert`'s snooze/mute gates but skips
+/// throttle, history recording, UN scheduling, and critical audio: the OS
+/// still holds the original notifications across relaunch.
+enum AlertColdStartReplay {
+    enum Decision: Equatable {
+        /// Re-present in-app (liveAlerts + banner scheduler).
+        case replay(Alert)
+        /// Glucose-family: `GlucoseAlertCoordinator` re-derives truth from
+        /// live data after its launch quiet window and re-issues if still
+        /// valid; a replayed entry could never be retracted on recovery.
+        /// Mark it acknowledged so it doesn't linger unresolved.
+        case acknowledgeSilently
+        /// Gated (snooze/mute) or unreconstructable — entry stays
+        /// unacknowledged and gets another chance next launch.
+        case skip
+    }
+
+    /// Storage returns newest-first; keep the newest entry per identifier so
+    /// a re-issued alert doesn't replay twice.
+    static func newestPerIdentifier(_ entries: [AlertEntry]) -> [AlertEntry] {
+        var seen = Set<String>()
+        return entries.filter { seen.insert("\($0.managerIdentifier).\($0.alertIdentifier)").inserted }
+    }
+
+    static func decision(
+        for entry: AlertEntry,
+        now: Date,
+        isTierSnoozed: (DeviceAlertSeverity) -> Bool,
+        isMuted: Bool
+    ) -> Decision {
+        if GlucoseAlertType(slug: entry.alertIdentifier) != nil { return .acknowledgeSilently }
+
+        // Rebuild the trigger relative to issuance: elapsed delayed →
+        // immediate, pending delayed → remaining interval (keeps the
+        // not-looping watchdog armed instead of blasting at launch).
+        guard let trigger = try? Alert.Trigger(
+            storedType: entry.triggerType,
+            storedInterval: entry.triggerInterval.map { NSDecimalNumber(decimal: $0) },
+            storageDate: entry.issuedDate,
+            now: now
+        ) else { return .skip }
+
+        let identifier = Alert.Identifier(
+            managerIdentifier: entry.managerIdentifier,
+            alertIdentifier: entry.alertIdentifier
+        )
+        let catalogEntry = AlertCatalogRegistry.lookup(identifier)
+        let level = catalogEntry?.interruptionLevel
+            ?? entry.primitiveInterruptionLevel
+            .flatMap { Alert.InterruptionLevel(storedValue: NSDecimalNumber(decimal: $0)) }
+            ?? .timeSensitive
+
+        // Same gates as issueAlert: tier snooze for catalog-known device
+        // alerts, global mute for everything non-critical.
+        if let tier = DeviceAlertSeverity(level: level),
+           catalogEntry != nil,
+           tier != .critical,
+           isTierSnoozed(tier)
+        {
+            return .skip
+        }
+        if level != .critical, isMuted { return .skip }
+
+        // The banner drops alerts without foregroundContent, so mirror the
+        // stored content into both slots. The ack label is required non-nil
+        // but rendered by neither surface.
+        let content = Alert.Content(
+            title: entry.contentTitle ?? "",
+            body: entry.contentBody ?? "",
+            acknowledgeActionButtonLabel: String(localized: "OK")
+        )
+        return .replay(Alert(
+            identifier: identifier,
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: trigger,
+            interruptionLevel: level
+        ))
+    }
+}

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

@@ -8,6 +8,10 @@ import UserNotifications
 protocol TrioAlertManager: AnyObject {
 protocol TrioAlertManager: AnyObject {
     func issueAlert(_ alert: Alert)
     func issueAlert(_ alert: Alert)
     func retractAlert(identifier: Alert.Identifier)
     func retractAlert(identifier: Alert.Identifier)
+    /// Re-presents unacknowledged history alerts after a cold start — the
+    /// in-app presentation state dies with the process, but the history
+    /// entry and the OS-side notifications survive.
+    func replayUnacknowledgedAlerts()
 
 
     func register(responder: AlertResponder, for managerIdentifier: String)
     func register(responder: AlertResponder, for managerIdentifier: String)
     func register(soundVendor: AlertSoundVendor, for managerIdentifier: String)
     func register(soundVendor: AlertSoundVendor, for managerIdentifier: String)
@@ -99,22 +103,38 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
     }
     }
 
 
     @objc private func reconcileBannersWithDeliveredNotifications() {
     @objc private func reconcileBannersWithDeliveredNotifications() {
-        UNUserNotificationCenter.current().getDeliveredNotifications { [weak self] delivered in
-            guard let self else { return }
-            let deliveredIDs = Set(delivered.compactMap { note -> Alert.Identifier? in
-                let userInfo = note.request.content.userInfo
-                guard
-                    let managerId = userInfo[AlertUserInfoKey.managerIdentifier.rawValue] as? String,
-                    let alertId = userInfo[AlertUserInfoKey.alertIdentifier.rawValue] as? String
-                else { return nil }
-                return Alert.Identifier(managerIdentifier: managerId, alertIdentifier: alertId)
-            })
-            DispatchQueue.main.async {
-                let stale = self.modalScheduler.active
-                    .map(\.identifier)
-                    .filter { !deliveredIDs.contains($0) }
-                for identifier in stale {
-                    self.modalScheduler.unschedule(identifier: identifier)
+        let center = UNUserNotificationCenter.current()
+        center.getNotificationSettings { [weak self] settings in
+            // Without authorization nothing is ever "delivered", so a missing
+            // notification carries no dismissal signal — bail entirely.
+            guard settings.authorizationStatus == .authorized else { return }
+            center.getDeliveredNotifications { delivered in
+                center.getPendingNotificationRequests { pending in
+                    guard let self else { return }
+                    // Delivered ∪ pending — a replayed .delayed banner may fire
+                    // moments before its OS notification moves pending→delivered.
+                    let knownIDs = Set(
+                        (delivered.map(\.request) + pending).compactMap { request -> Alert.Identifier? in
+                            let userInfo = request.content.userInfo
+                            guard
+                                let managerId = userInfo[AlertUserInfoKey.managerIdentifier.rawValue] as? String,
+                                let alertId = userInfo[AlertUserInfoKey.alertIdentifier.rawValue] as? String
+                            else { return nil }
+                            return Alert.Identifier(managerIdentifier: managerId, alertIdentifier: alertId)
+                        }
+                    )
+                    DispatchQueue.main.async {
+                        let stale = self.modalScheduler.active
+                            .map(\.identifier)
+                            .filter { !knownIDs.contains($0) }
+                        for identifier in stale {
+                            // The notification is gone from Notification Center —
+                            // same gesture as swiping it away, so treat it as a
+                            // dismissal: ack (history + device) + 15-min snooze.
+                            self.requestSnooze(identifier: identifier, duration: 15 * 60)
+                            self.modalScheduler.unschedule(identifier: identifier)
+                        }
+                    }
                 }
                 }
             }
             }
         }
         }
@@ -216,13 +236,46 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
         alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
     }
     }
 
 
+    /// Cold-start replay. Deliberately skips `recordIssued` (entry already
+    /// exists), the UN scheduler (pending/delivered notifications survive
+    /// termination at OS level), the throttler (a genuine producer re-issue
+    /// must still deliver fully), and critical audio.
+    func replayUnacknowledgedAlerts() {
+        let now = Date()
+        let entries = AlertColdStartReplay.newestPerIdentifier(
+            alertHistoryStorage.unacknowledgedAlertsWithinLast24Hours()
+        )
+        let muted = muter.shouldMute(at: now)
+        for entry in entries {
+            switch AlertColdStartReplay.decision(
+                for: entry,
+                now: now,
+                isTierSnoozed: { DeviceAlertsStore.shared.isTierSnoozed($0, at: now) },
+                isMuted: muted
+            ) {
+            case let .replay(alert):
+                debug(.service, "TrioAlertManager replaying \(alert.identifier.value)")
+                queue.async { self.liveAlerts[alert.identifier] = alert }
+                modalScheduler.schedule(alert)
+            case .acknowledgeSilently:
+                alertHistoryStorage.acknowledgeAlert(entry.issuedDate, nil)
+            case .skip:
+                break
+            }
+        }
+    }
+
     // MARK: - Acknowledgement
     // MARK: - Acknowledgement
 
 
     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() }
         Task { @MainActor in criticalAudioPlayer?.stop() }
-        alertHistoryStorage.acknowledgeAlert(issuedDate(for: identifier) ?? Date(), nil)
+        // All matching entries — replay + a genuine re-issue can coexist.
+        alertHistoryStorage.acknowledgeAllEntries(
+            managerIdentifier: identifier.managerIdentifier,
+            alertIdentifier: identifier.alertIdentifier
+        )
         queue.async {
         queue.async {
             self.liveAlerts.removeValue(forKey: identifier)
             self.liveAlerts.removeValue(forKey: identifier)
         }
         }
@@ -362,20 +415,12 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         )
         )
         alertHistoryStorage.addAlert(entry)
         alertHistoryStorage.addAlert(entry)
     }
     }
-
-    private func issuedDate(for identifier: Alert.Identifier) -> Date? {
-        alertHistoryStorage.unacknowledgedAlertsWithinLast24Hours()
-            .first {
-                $0.managerIdentifier == identifier.managerIdentifier
-                    && $0.alertIdentifier == identifier.alertIdentifier
-            }?.issuedDate
-    }
 }
 }
 
 
 extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAlertResponder {
 extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAlertResponder {
     func requestSnooze(identifier: Alert.Identifier, duration: TimeInterval) {
     func requestSnooze(identifier: Alert.Identifier, duration: TimeInterval) {
-        // Dismissal is the user's response — ack device-issued alerts at the PM layer too.
-        acknowledgeOnDevice(identifier: identifier)
+        // Dismissal is the user's response — full ack (history + device) first.
+        handleAcknowledgement(identifier: identifier)
         let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
         let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
         if let glucoseType = GlucoseAlertType(slug: identifier.alertIdentifier) {
         if let glucoseType = GlucoseAlertType(slug: identifier.alertIdentifier) {
             broadcaster.notify(GlucoseSnoozeObserver.self, on: .main) { (observer: GlucoseSnoozeObserver) in
             broadcaster.notify(GlucoseSnoozeObserver.self, on: .main) { (observer: GlucoseSnoozeObserver) in
@@ -386,7 +431,7 @@ extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAle
                   tier != .critical
                   tier != .critical
         {
         {
             DeviceAlertsStore.shared.snoozeTier(tier, until: untilDate)
             DeviceAlertsStore.shared.snoozeTier(tier, until: untilDate)
-            retractAlertsInTier(tier)
+            dismissAlertsInTier(tier, excluding: identifier)
         } else {
         } else {
             Task { @MainActor [weak self] in
             Task { @MainActor [weak self] in
                 await self?.applySnooze(for: duration)
                 await self?.applySnooze(for: duration)
@@ -394,18 +439,21 @@ extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAle
         }
         }
     }
     }
 
 
-    private func retractAlertsInTier(_ tier: DeviceAlertSeverity) {
-        let toRetract: [Alert.Identifier] = queue.sync {
-            liveAlerts.compactMap { id, alert in
-                guard let entry = AlertCatalogRegistry.lookup(id),
+    /// Same-tier siblings visible when the user snoozes one alert get the
+    /// same treatment — acknowledged (history + device), not silently
+    /// retracted with their history entries deleted.
+    private func dismissAlertsInTier(_ tier: DeviceAlertSeverity, excluding: Alert.Identifier) {
+        let toDismiss: [Alert.Identifier] = queue.sync {
+            liveAlerts.compactMap { id, _ in
+                guard id != excluding,
+                      let entry = AlertCatalogRegistry.lookup(id),
                       DeviceAlertSeverity(level: entry.interruptionLevel) == tier
                       DeviceAlertSeverity(level: entry.interruptionLevel) == tier
                 else { return nil }
                 else { return nil }
-                _ = alert
                 return id
                 return id
             }
             }
         }
         }
-        for id in toRetract {
-            retractAlert(identifier: id)
+        for id in toDismiss {
+            handleAcknowledgement(identifier: id)
         }
         }
     }
     }
 
 

+ 216 - 0
TrioTests/AlertColdStartReplayTests.swift

@@ -0,0 +1,216 @@
+import Foundation
+import LoopKit
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("Trio Alerts: AlertColdStartReplay decisions") struct AlertColdStartReplayTests {
+    private let now = Date()
+
+    private func entry(
+        manager: String = "Omni",
+        alert: String = "userPodExpiration",
+        triggerType: Int16 = 0,
+        triggerInterval: Decimal? = nil,
+        issuedAgo: TimeInterval = 60,
+        level: Decimal? = 2,
+        title: String? = "Title",
+        body: String? = "Body"
+    ) -> AlertEntry {
+        AlertEntry(
+            alertIdentifier: alert,
+            primitiveInterruptionLevel: level,
+            issuedDate: now.addingTimeInterval(-issuedAgo),
+            managerIdentifier: manager,
+            triggerType: triggerType,
+            triggerInterval: triggerInterval,
+            contentTitle: title,
+            contentBody: body
+        )
+    }
+
+    private func decide(
+        _ entry: AlertEntry,
+        tierSnoozed: Bool = false,
+        muted: Bool = false
+    ) -> AlertColdStartReplay.Decision {
+        AlertColdStartReplay.decision(
+            for: entry,
+            now: now,
+            isTierSnoozed: { _ in tierSnoozed },
+            isMuted: muted
+        )
+    }
+
+    @Test("Elapsed delayed trigger replays as immediate") func elapsedDelayedBecomesImmediate() {
+        let decision = decide(entry(
+            manager: "trio.aps", alert: "loop.notActive",
+            triggerType: 1, triggerInterval: 1200, issuedAgo: 25 * 60
+        ))
+        guard case let .replay(alert) = decision else {
+            Issue.record("expected replay, got \(decision)")
+            return
+        }
+        #expect(alert.trigger == .immediate)
+    }
+
+    @Test("Pending delayed trigger re-arms with remaining interval") func pendingDelayedKeepsRemainder() {
+        let decision = decide(entry(
+            manager: "trio.aps", alert: "loop.notActive",
+            triggerType: 1, triggerInterval: 1200, issuedAgo: 5 * 60
+        ))
+        guard case let .replay(alert) = decision, case let .delayed(interval) = alert.trigger else {
+            Issue.record("expected delayed replay, got \(decision)")
+            return
+        }
+        #expect(abs(interval - 900) < 2)
+    }
+
+    @Test("Repeating trigger replays with original interval") func repeatingKeepsInterval() {
+        let decision = decide(entry(triggerType: 2, triggerInterval: 900, issuedAgo: 3600))
+        guard case let .replay(alert) = decision else {
+            Issue.record("expected replay, got \(decision)")
+            return
+        }
+        #expect(alert.trigger == .repeating(repeatInterval: 900))
+    }
+
+    @Test("Delayed trigger without interval is unreconstructable") func corruptTriggerSkips() {
+        #expect(decide(entry(triggerType: 1, triggerInterval: nil)) == .skip)
+    }
+
+    @Test("Glucose-family entries acknowledge silently") func glucoseAcknowledgesSilently() {
+        let glucose = entry(manager: "Trio", alert: "glucose.urgentLow.ABC-123")
+        #expect(decide(glucose) == .acknowledgeSilently)
+        let carbs = entry(manager: "Trio", alert: "glucose.carbsRequired.DEF-456")
+        #expect(decide(carbs) == .acknowledgeSilently)
+    }
+
+    @Test("Catalog level override wins over stored level") func catalogOverridesStoredLevel() {
+        // userPodExpiration is catalogued .active; stored says critical (3).
+        let decision = decide(entry(level: 3))
+        guard case let .replay(alert) = decision else {
+            Issue.record("expected replay, got \(decision)")
+            return
+        }
+        #expect(alert.interruptionLevel == .active)
+    }
+
+    @Test("Unknown identifier falls back to stored level, then timeSensitive") func levelFallbacks() {
+        guard case let .replay(stored) = decide(entry(manager: "x", alert: "y", level: 3)) else {
+            Issue.record("expected replay")
+            return
+        }
+        #expect(stored.interruptionLevel == .critical)
+
+        guard case let .replay(unstored) = decide(entry(manager: "x", alert: "y", level: nil)) else {
+            Issue.record("expected replay")
+            return
+        }
+        #expect(unstored.interruptionLevel == .timeSensitive)
+    }
+
+    @Test("Tier snooze skips catalog non-critical, critical pierces") func tierSnoozeGate() {
+        #expect(decide(entry(), tierSnoozed: true) == .skip)
+        let critical = entry(manager: "trio.aps", alert: "loop.notActive", issuedAgo: 60)
+        if case .replay = decide(critical, tierSnoozed: true) {} else {
+            Issue.record("critical should pierce tier snooze")
+        }
+    }
+
+    @Test("Mute skips non-critical, critical pierces") func muteGate() {
+        #expect(decide(entry(), muted: true) == .skip)
+        let critical = entry(manager: "trio.aps", alert: "loop.notActive", issuedAgo: 60)
+        if case .replay = decide(critical, muted: true) {} else {
+            Issue.record("critical should pierce mute")
+        }
+    }
+
+    @Test("Rebuilt alert carries content on both surfaces, no sound") func rebuiltAlertShape() {
+        guard case let .replay(alert) = decide(entry()) else {
+            Issue.record("expected replay")
+            return
+        }
+        #expect(alert.sound == nil)
+        #expect(alert.foregroundContent?.title == "Title")
+        #expect(alert.foregroundContent == alert.backgroundContent)
+    }
+
+    @Test("newestPerIdentifier keeps first (newest) entry per identifier") func dedupe() {
+        let newer = entry(issuedAgo: 60)
+        let older = entry(issuedAgo: 3600)
+        let other = entry(alert: "lowReservoir", issuedAgo: 120)
+        let result = AlertColdStartReplay.newestPerIdentifier([newer, other, older])
+        #expect(result == [newer, other])
+    }
+}
+
+@Suite("Trio Alerts: AlertHistoryStorage acknowledgement") struct AlertHistoryStorageAckTests {
+    private func makeStorage() -> (BaseAlertHistoryStorage, UserDefaults) {
+        let suiteName = "AlertHistoryStorageAckTests-\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defaults.removePersistentDomain(forName: suiteName)
+        let container = Container()
+        container.register(FileStorage.self) { _ in BaseFileStorage() }
+        container.register(Broadcaster.self) { _ in BaseBroadcaster() }
+        let storage = BaseAlertHistoryStorage(resolver: container.synchronize(), userDefaults: defaults)
+        return (storage, defaults)
+    }
+
+    private func entry(_ alert: String, issuedAgo: TimeInterval, manager: String = "Omni") -> AlertEntry {
+        AlertEntry(
+            alertIdentifier: alert,
+            primitiveInterruptionLevel: 2,
+            issuedDate: Date().addingTimeInterval(-issuedAgo),
+            managerIdentifier: manager,
+            triggerType: 0,
+            triggerInterval: nil,
+            contentTitle: "t",
+            contentBody: "b"
+        )
+    }
+
+    @Test("acknowledgeAllEntries marks every matching entry") func ackAllMatching() {
+        let (storage, _) = makeStorage()
+        storage.addAlert(entry("userPodExpiration", issuedAgo: 3600))
+        storage.addAlert(entry("userPodExpiration", issuedAgo: 60))
+        storage.addAlert(entry("lowReservoir", issuedAgo: 120))
+
+        storage.acknowledgeAllEntries(managerIdentifier: "Omni", alertIdentifier: "userPodExpiration")
+
+        let unacked = storage.unacknowledgedAlertsWithinLast24Hours()
+        #expect(unacked.map(\.alertIdentifier) == ["lowReservoir"])
+    }
+
+    @Test("acknowledgeAllEntries matches manager identifier too") func ackScopedToManager() {
+        let (storage, _) = makeStorage()
+        storage.addAlert(entry("expiration", issuedAgo: 60, manager: "Omni"))
+        storage.addAlert(entry("expiration", issuedAgo: 120, manager: "Medtrum"))
+
+        storage.acknowledgeAllEntries(managerIdentifier: "Omni", alertIdentifier: "expiration")
+
+        #expect(storage.unacknowledgedAlertsWithinLast24Hours().map(\.managerIdentifier) == ["Medtrum"])
+    }
+
+    @Test("removeAlert skips acknowledged entries") func removePreservesAcknowledged() {
+        let (storage, defaults) = makeStorage()
+        storage.addAlert(entry("userPodExpiration", issuedAgo: 60))
+        storage.acknowledgeAllEntries(managerIdentifier: "Omni", alertIdentifier: "userPodExpiration")
+
+        storage.removeAlert(identifier: "userPodExpiration")
+
+        // Row survives as acknowledged history instead of being erased.
+        let data = defaults.data(forKey: "openaps.monitor.alertHistory.data")!
+        let persisted = try! JSONCoding.decoder.decode([AlertEntry].self, from: data)
+        #expect(persisted.count == 1)
+        #expect(persisted[0].acknowledgedDate != nil)
+    }
+
+    @Test("removeAlert deletes unacknowledged entry") func removeDeletesUnacknowledged() {
+        let (storage, _) = makeStorage()
+        storage.addAlert(entry("userPodExpiration", issuedAgo: 60))
+        storage.removeAlert(identifier: "userPodExpiration")
+        #expect(storage.unacknowledgedAlertsWithinLast24Hours().isEmpty)
+    }
+}

+ 1 - 0
TrioTests/NotLoopingMonitorTests.swift

@@ -38,6 +38,7 @@ final class SpyAlertManager: TrioAlertManager {
     func handleAcknowledgement(identifier _: Alert.Identifier) {}
     func handleAcknowledgement(identifier _: Alert.Identifier) {}
     func handleNotificationResponse(_: UNNotificationResponse) {}
     func handleNotificationResponse(_: UNNotificationResponse) {}
     func acknowledgeAllOutstanding() {}
     func acknowledgeAllOutstanding() {}
+    func replayUnacknowledgedAlerts() {}
     @MainActor func applySnooze(for _: TimeInterval) async {}
     @MainActor func applySnooze(for _: TimeInterval) async {}
     func clearPendingNonCriticalNotifications() {}
     func clearPendingNonCriticalNotifications() {}