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

fix(notifications): standardize threading and fix/improve watch connectivity for snooze

- Standardize all snooze-related async work to Task { @MainActor [weak self] } instead of mixing DispatchQueue.main.async and Task patterns
- Fix/improve watch connectivity reliability: watch was sending snooze via transferUserInfo, but phone was expecting snooze in didReceiveMessage. Updated so watch uses sendMessage with transferUserInfo fallback for immediate delivery when reachable, queued when not and phone is ready to receive in both didReceiveMessage and didReceiveUserInfo.
- Enhance alert deduplication beyond original patch: round timestamps to minutes and add stable Core Data objectID fallback to prevent duplicate notifications
Charlie Chrisman 4 месяцев назад
Родитель
Сommit
904bb573cf

+ 15 - 1
Trio Watch App Extension/Helper/WatchNotificationHandler.swift

@@ -43,10 +43,24 @@ final class WatchNotificationHandler: NSObject, UNUserNotificationCenterDelegate
 
 
     /// Sends snooze request to iPhone via WatchConnectivity.
     /// Sends snooze request to iPhone via WatchConnectivity.
     /// WCSession.transferUserInfo is thread-safe and can be called from any thread.
     /// WCSession.transferUserInfo is thread-safe and can be called from any thread.
+    /// Relies on the watch app's WCSession owner (e.g., WatchState) to handle
+    /// session activation and delegate management.
     private func sendSnoozeRequest(for action: NotificationResponseAction) {
     private func sendSnoozeRequest(for action: NotificationResponseAction) {
         guard WCSession.isSupported() else { return }
         guard WCSession.isSupported() else { return }
 
 
         let payload: [String: Any] = [WatchMessageKeys.snoozeDuration: action.minutes]
         let payload: [String: Any] = [WatchMessageKeys.snoozeDuration: action.minutes]
-        WCSession.default.transferUserInfo(payload)
+        let session = WCSession.default
+
+        // Try sendMessage first if session is reachable and activated (faster, immediate delivery)
+        // Fall back to transferUserInfo if not reachable or if sendMessage fails
+        if session.isReachable && session.activationState == .activated {
+            session.sendMessage(payload, replyHandler: nil) { _ in
+                // Fallback to transferUserInfo if sendMessage fails
+                session.transferUserInfo(payload)
+            }
+        } else {
+            // Session not reachable or not activated - use transferUserInfo (queued delivery)
+            session.transferUserInfo(payload)
+        }
     }
     }
 }
 }

+ 2 - 1
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -688,7 +688,8 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
 
 
         // Handle quick snooze actions (from notification action buttons)
         // Handle quick snooze actions (from notification action buttons)
         if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) {
         if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) {
-            Task { @MainActor in
+            Task { @MainActor [weak self] in
+                guard let self else { return }
                 await self.applySnooze(for: quickAction.duration)
                 await self.applySnooze(for: quickAction.duration)
             }
             }
             return
             return

+ 24 - 17
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -552,16 +552,18 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     }
     }
 
 
     func session(_: WCSession, didReceiveMessage message: [String: Any]) {
     func session(_: WCSession, didReceiveMessage message: [String: Any]) {
-        DispatchQueue.main.async { [weak self] in
-            if let logs = message["watchLogs"] as? String {
-                SimpleLogReporter.appendToWatchLog(logs)
-            }
+        // Handle logs first - doesn't need self, so it can run even during teardown
+        if let logs = message["watchLogs"] as? String {
+            SimpleLogReporter.appendToWatchLog(logs)
+        }
+
+        Task { @MainActor [weak self] in
+            guard let self else { return }
 
 
             if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String,
             if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String,
                requestWatchUpdate == WatchMessageKeys.watchState
                requestWatchUpdate == WatchMessageKeys.watchState
             {
             {
                 debug(.watchManager, "📱 Watch requested watch state data update.")
                 debug(.watchManager, "📱 Watch requested watch state data update.")
-                guard let self = self else { return }
                 // Skip if no watch is paired or app not installed
                 // Skip if no watch is paired or app not installed
                 guard let session = self.session, session.isPaired, session.isReachable,
                 guard let session = self.session, session.isPaired, session.isReachable,
                       session.isWatchAppInstalled else { return }
                       session.isWatchAppInstalled else { return }
@@ -574,24 +576,21 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
 
             if let snoozeMinutes = message[WatchMessageKeys.snoozeDuration] as? Int {
             if let snoozeMinutes = message[WatchMessageKeys.snoozeDuration] as? Int {
                 debug(.watchManager, "📱 Received snooze request from watch: \(snoozeMinutes) minutes")
                 debug(.watchManager, "📱 Received snooze request from watch: \(snoozeMinutes) minutes")
-                Task { @MainActor [weak self] in
-                    guard let self else { return }
-                    await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60))
-                }
+                await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60))
                 return
                 return
             } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
             } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
                       message[WatchMessageKeys.carbs] == nil,
                       message[WatchMessageKeys.carbs] == nil,
                       message[WatchMessageKeys.date] == nil
                       message[WatchMessageKeys.date] == nil
             {
             {
                 debug(.watchManager, "📱 Received bolus request from watch: \(bolusAmount)U")
                 debug(.watchManager, "📱 Received bolus request from watch: \(bolusAmount)U")
-                self?.handleBolusRequest(Decimal(bolusAmount))
+                self.handleBolusRequest(Decimal(bolusAmount))
             } else if let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
             } else if let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval,
                       message[WatchMessageKeys.bolus] == nil
                       message[WatchMessageKeys.bolus] == nil
             {
             {
                 let date = Date(timeIntervalSince1970: timestamp)
                 let date = Date(timeIntervalSince1970: timestamp)
                 debug(.watchManager, "📱 Received carbs request from watch: \(carbsAmount)g at \(date)")
                 debug(.watchManager, "📱 Received carbs request from watch: \(carbsAmount)g at \(date)")
-                self?.handleCarbsRequest(carbsAmount, date)
+                self.handleCarbsRequest(carbsAmount, date)
             } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
             } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
                       let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let carbsAmount = message[WatchMessageKeys.carbs] as? Int,
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval
                       let timestamp = message[WatchMessageKeys.date] as? TimeInterval
@@ -601,11 +600,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                     .watchManager,
                     .watchManager,
                     "📱 Received meal bolus combo request from watch: \(bolusAmount)U, \(carbsAmount)g at \(date)"
                     "📱 Received meal bolus combo request from watch: \(bolusAmount)U, \(carbsAmount)g at \(date)"
                 )
                 )
-                self?.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date)
+                self.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date)
             } else {
             } else {
                 debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received:  \(message)")
                 debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received:  \(message)")
                 // Acknowledge failure
                 // Acknowledge failure
-                self?.sendAcknowledgment(
+                self.sendAcknowledgment(
                     toWatch: false,
                     toWatch: false,
                     message: "Error! Invalid or incomplete data received from watch.",
                     message: "Error! Invalid or incomplete data received from watch.",
                     ackCode: .genericFailure
                     ackCode: .genericFailure
@@ -614,22 +613,22 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
 
             if message[WatchMessageKeys.cancelOverride] as? Bool == true {
             if message[WatchMessageKeys.cancelOverride] as? Bool == true {
                 debug(.watchManager, "📱 Received cancel override request from watch")
                 debug(.watchManager, "📱 Received cancel override request from watch")
-                self?.handleCancelOverride()
+                self.handleCancelOverride()
             }
             }
 
 
             if let presetName = message[WatchMessageKeys.activateOverride] as? String {
             if let presetName = message[WatchMessageKeys.activateOverride] as? String {
                 debug(.watchManager, "📱 Received activate override request from watch for preset: \(presetName)")
                 debug(.watchManager, "📱 Received activate override request from watch for preset: \(presetName)")
-                self?.handleActivateOverride(presetName)
+                self.handleActivateOverride(presetName)
             }
             }
 
 
             if let presetName = message[WatchMessageKeys.activateTempTarget] as? String {
             if let presetName = message[WatchMessageKeys.activateTempTarget] as? String {
                 debug(.watchManager, "📱 Received activate temp target request from watch for preset: \(presetName)")
                 debug(.watchManager, "📱 Received activate temp target request from watch for preset: \(presetName)")
-                self?.handleActivateTempTarget(presetName)
+                self.handleActivateTempTarget(presetName)
             }
             }
 
 
             if message[WatchMessageKeys.cancelTempTarget] as? Bool == true {
             if message[WatchMessageKeys.cancelTempTarget] as? Bool == true {
                 debug(.watchManager, "📱 Received cancel temp target request from watch")
                 debug(.watchManager, "📱 Received cancel temp target request from watch")
-                self?.handleCancelTempTarget()
+                self.handleCancelTempTarget()
             }
             }
 
 
             if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true {
             if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true {
@@ -690,6 +689,14 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
         if let logs = userInfo["watchLogs"] as? String {
         if let logs = userInfo["watchLogs"] as? String {
             SimpleLogReporter.appendToWatchLog(logs)
             SimpleLogReporter.appendToWatchLog(logs)
         }
         }
+
+        if let snoozeMinutes = userInfo[WatchMessageKeys.snoozeDuration] as? Int {
+            debug(.watchManager, "📱 Received snooze userInfo from watch: \(snoozeMinutes) minutes")
+            Task { @MainActor [weak self] in
+                guard let self else { return }
+                await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60))
+            }
+        }
     }
     }
 
 
     #if os(iOS)
     #if os(iOS)