@@ -1263,70 +1263,87 @@ actor MeshPackets {
12631263 }
12641264 }
12651265 if !( newMessage. fromUser? . mute ?? false ) && newMessage. isEmoji == false {
1266- // Create an iOS Notification for the received DM message
1266+ // Build the notification from the model objects now, while this context is valid,
1267+ // and capture only the resulting value — a deferred Task must not hold `newMessage`
1268+ // (a context-bound model), since a device switch can reset this context first
1269+ // ("destroyed by ModelContext.reset").
1270+ let senderName = newMessage. fromUser? . longName ?? " Unknown " . localized
1271+ var dmNotification = Notification (
1272+ id: ( " notification.id. \( newMessage. messageId) " ) ,
1273+ title: " \( senderName) " ,
1274+ subtitle: " AKA \( newMessage. fromUser? . shortName ?? " ? " ) " ,
1275+ content: messageText!,
1276+ target: " messages " ,
1277+ path: " meshtastic:///messages?userNum= \( newMessage. fromUser? . num ?? 0 ) &messageId= \( newMessage. isEmoji ? newMessage. replyID : newMessage. messageId) " ,
1278+ messageId: newMessage. messageId,
1279+ channel: newMessage. channel,
1280+ userNum: Int64 ( packet. from) ,
1281+ critical: critical
1282+ )
1283+ #if os(iOS)
1284+ dmNotification. senderIntent = CarPlayIntentDonation . incomingMessageIntent ( from: newMessage)
1285+ #endif
1286+ let notification = dmNotification
12671287 Task { @MainActor in
12681288 let manager = LocalNotificationManager ( )
1269- var dmNotification = Notification (
1270- id: ( " notification.id. \( newMessage. messageId) " ) ,
1271- title: " \( newMessage. fromUser? . longName ?? " Unknown " . localized) " ,
1272- subtitle: " AKA \( newMessage. fromUser? . shortName ?? " ? " ) " ,
1273- content: messageText!,
1274- target: " messages " ,
1275- path: " meshtastic:///messages?userNum= \( newMessage. fromUser? . num ?? 0 ) &messageId= \( newMessage. isEmoji ? newMessage. replyID : newMessage. messageId) " ,
1276- messageId: newMessage. messageId,
1277- channel: newMessage. channel,
1278- userNum: Int64 ( packet. from) ,
1279- critical: critical
1280- )
1281- #if os(iOS)
1282- dmNotification. senderIntent = CarPlayIntentDonation . incomingMessageIntent ( from: newMessage)
1283- #endif
1284- manager. notifications = [ dmNotification]
1289+ manager. notifications = [ notification]
12851290 manager. schedule ( )
1286-
1287- Logger . services. debug ( " iOS Notification Scheduled for text message from \( newMessage. fromUser? . longName ?? " Unknown " . localized, privacy: . public) " )
1291+ Logger . services. debug ( " iOS Notification Scheduled for text message from \( senderName, privacy: . public) " )
12881292 }
12891293 }
12901294 } else if newMessage. fromUser != nil && newMessage. toUser == nil {
12911295 let myInfoFetchDescriptor = FetchDescriptor < MyInfoEntity > ( predicate: #Predicate { $0. myNodeNum == connectedNode } )
12921296 do {
12931297 let fetchedMyInfo = try modelContext. fetch ( myInfoFetchDescriptor)
1294- if !fetchedMyInfo. isEmpty {
1295- let ctx = modelContext
1296- // unreadMessages is O(unread); recomputing it for the badge on every
1297- // incoming channel message is quadratic over a burst and stalls slower
1298- // devices. Throttle to ~1/sec — the badge tolerates brief lag and also
1299- // resyncs on app-active and on read (refreshBadgeCount). Notifications
1300- // below still fire per message.
1298+ if let myInfo = fetchedMyInfo. first {
1299+ // Snapshot everything off this context NOW, synchronously, and capture only
1300+ // plain values into the deferred Task. The entity (and `newMessage`) must not
1301+ // cross the hop: a device switch can reset this context before the Task runs,
1302+ // which traps with "destroyed by ModelContext.reset" on access.
1303+ //
1304+ // unreadMessages is O(unread); recomputing it for the badge on every incoming
1305+ // channel message is quadratic over a burst and stalls slower devices. Throttle
1306+ // to ~1/sec — the badge tolerates brief lag and resyncs on app-active and on read.
13011307 let recountUnread = shouldRecomputeChannelUnread ( )
1308+ let connectedNodeNum = connectedNode
1309+ let shouldNotify = UserDefaults . channelMessageNotifications && newMessage. isEmoji == false && myInfo. channels. contains ( where: { $0. index == newMessage. channel && !$0. mute } )
1310+ var channelNotification : Notification ?
1311+ if shouldNotify {
1312+ var chNotification = Notification (
1313+ id: ( " notification.id. \( newMessage. messageId) " ) ,
1314+ title: " \( newMessage. fromUser? . longName ?? " Unknown " . localized) " ,
1315+ subtitle: " AKA \( newMessage. fromUser? . shortName ?? " ? " ) " ,
1316+ content: messageText!,
1317+ target: " messages " ,
1318+ path: " meshtastic:///messages?channelId= \( newMessage. channel) &messageId= \( newMessage. isEmoji ? newMessage. replyID : newMessage. messageId) " ,
1319+ messageId: newMessage. messageId,
1320+ channel: newMessage. channel,
1321+ userNum: Int64 ( newMessage. fromUser? . userId ?? " 0 " ) ,
1322+ critical: critical
1323+ )
1324+ #if os(iOS)
1325+ chNotification. senderIntent = CarPlayIntentDonation . incomingMessageIntent ( from: newMessage)
1326+ #endif
1327+ channelNotification = chNotification
1328+ }
1329+ let notification = channelNotification
13021330 Task { @MainActor in
13031331 if recountUnread {
1304- appState? . unreadChannelMessages = fetchedMyInfo [ 0 ] . unreadMessages ( context: ctx)
1305- }
1306- for channel in fetchedMyInfo [ 0 ] . channels {
1307- if channel. index == newMessage. channel && !channel. mute && UserDefaults . channelMessageNotifications && newMessage. isEmoji == false {
1308- // Create an iOS Notification for the received channel message
1309- let manager = LocalNotificationManager ( )
1310- var chNotification = Notification (
1311- id: ( " notification.id. \( newMessage. messageId) " ) ,
1312- title: " \( newMessage. fromUser? . longName ?? " Unknown " . localized) " ,
1313- subtitle: " AKA \( newMessage. fromUser? . shortName ?? " ? " ) " ,
1314- content: messageText!,
1315- target: " messages " ,
1316- path: " meshtastic:///messages?channelId= \( newMessage. channel) &messageId= \( newMessage. isEmoji ? newMessage. replyID : newMessage. messageId) " ,
1317- messageId: newMessage. messageId,
1318- channel: newMessage. channel,
1319- userNum: Int64 ( newMessage. fromUser? . userId ?? " 0 " ) ,
1320- critical: critical
1321- )
1322- #if os(iOS)
1323- chNotification. senderIntent = CarPlayIntentDonation . incomingMessageIntent ( from: newMessage)
1324- #endif
1325- manager. notifications = [ chNotification]
1326- manager. schedule ( )
1327- Logger . services. debug ( " iOS Notification Scheduled for text message from \( newMessage. fromUser? . longName ?? " Unknown " . localized, privacy: . public) " )
1332+ // Recompute the badge against a freshly re-fetched, live main-context
1333+ // entity (unreadMessages is @MainActor); never touch the captured
1334+ // MeshPackets entity, which a device switch may have invalidated.
1335+ let mainContext = PersistenceController . shared. context
1336+ let liveDescriptor = FetchDescriptor < MyInfoEntity > ( predicate: #Predicate { $0. myNodeNum == connectedNodeNum } )
1337+ if let liveMyInfo = try ? mainContext. fetch ( liveDescriptor) . first {
1338+ appState? . unreadChannelMessages = liveMyInfo. unreadMessages ( context: mainContext)
13281339 }
13291340 }
1341+ if let notification {
1342+ let manager = LocalNotificationManager ( )
1343+ manager. notifications = [ notification]
1344+ manager. schedule ( )
1345+ Logger . services. debug ( " iOS Notification Scheduled for channel text message " )
1346+ }
13301347 }
13311348 }
13321349 } catch {
0 commit comments