BadgeCoordinator.swift (12261B)
1 import Foundation 2 import UserNotifications 3 4 /// Reconciles the app-icon badge and its push-side ledger, extracted from 5 /// `AppServices`: setting the badge from Core Data ground truth unioned with 6 /// the NSE's provisional `BadgeState` entries, withdrawing delivered 7 /// notifications when a game is seen (here or on a sibling device), and 8 /// pruning ledger entries no longer backed by anything. 9 @MainActor 10 final class BadgeCoordinator { 11 private let store: GameStore 12 private let syncMonitor: SyncMonitor 13 /// Length of the presence lease (`AppServices.readLeaseDuration`): how far 14 /// forward the suppression horizon is stamped while the puzzle is open. 15 private let readLeaseDuration: TimeInterval 16 /// Fans the seen horizon out to sibling devices — 17 /// `AccountPushCoordinator.publishAccountSeenPush(gameID:presenceUntil:)`. 18 private let publishAccountSeenPush: (UUID, Date) async -> Void 19 private var lastLoggedBadgeSnapshot: BadgeRefreshSnapshot? 20 21 private struct BadgeRefreshSnapshot: Equatable { 22 let ledgerUnread: Set<UUID> 23 let coreDataUnread: Set<UUID> 24 let pendingInvites: Set<UUID> 25 26 var merged: Set<UUID> { 27 ledgerUnread.union(coreDataUnread).union(pendingInvites) 28 } 29 } 30 31 init( 32 store: GameStore, 33 syncMonitor: SyncMonitor, 34 readLeaseDuration: TimeInterval, 35 publishAccountSeenPush: @escaping (UUID, Date) async -> Void 36 ) { 37 self.store = store 38 self.syncMonitor = syncMonitor 39 self.readLeaseDuration = readLeaseDuration 40 self.publishAccountSeenPush = publishAccountSeenPush 41 } 42 43 /// Removes any already-delivered local notifications for `gameID` from 44 /// this device's Notification Center. Sibling devices of the same iCloud 45 /// account learn about the dismissal indirectly: a directed ping is 46 /// deleted on consumption (the `onPingDeleted` path then withdraws their 47 /// copy), and the unread-moves badge converges via `Player.presenceUntil`. 48 /// 49 /// Every dismissal path is also a "user has seen this game" signal, so 50 /// we advance the App Group badge ledger's seen horizon and refresh the 51 /// app-icon badge. Without this, pause/win/resign entries added by the 52 /// Notification Service Extension would otherwise linger past the point 53 /// where their banners have already been withdrawn. 54 func dismissDeliveredNotifications( 55 for gameID: UUID, 56 seenAt explicitSeenAt: Date? = nil, 57 publishAccountSeen: Bool = true, 58 preserveUnread: Bool = false 59 ) async { 60 let center = UNUserNotificationCenter.current() 61 let delivered = await center.deliveredNotifications() 62 let preserveUnreadNotifications = preserveUnread 63 && store.hasUnreadOtherMoves(gameID: gameID) 64 let identifiers = delivered.compactMap { notification -> String? in 65 let userInfo = notification.request.content.userInfo 66 guard let raw = userInfo["gameID"] as? String, 67 raw == gameID.uuidString 68 else { return nil } 69 if preserveUnreadNotifications, notificationMarksUnread(notification) { 70 return nil 71 } 72 return notification.request.identifier 73 } 74 if !identifiers.isEmpty { 75 center.removeDeliveredNotifications(withIdentifiers: identifiers) 76 syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)") 77 } else if preserveUnreadNotifications { 78 syncMonitor.note("notif: kept delivered unread notifications for \(gameID.uuidString)") 79 } 80 // While viewing, the horizon is the local lease (forward-dated); the 81 // ledger adoption splits it — watermark to now, suppression to the 82 // lease — so pushes landing mid-session don't badge, yet the leave 83 // path's `collapseSuppression` can still un-swallow anything that 84 // arrives after the user stops looking. Writing the raw horizon into 85 // `markSeen` here is what used to pin the badge dark for the rest of 86 // the lease window after backgrounding. 87 let horizon = explicitSeenAt 88 ?? (NotificationState.isSuppressed(gameID: gameID) 89 ? Date().addingTimeInterval(readLeaseDuration) 90 : Date()) 91 BadgeState.adoptReadHorizon(gameID: gameID, horizon: horizon) 92 await refreshAppBadge(reason: "dismiss delivered") 93 if publishAccountSeen { 94 await publishAccountSeenPush(gameID, horizon) 95 } 96 } 97 98 /// Sets the app icon badge to Core Data ground truth (the 99 /// `hasUnreadOtherMoves` heuristic that drives the per-row dot in the 100 /// library list) unioned with provisional push-side unread entries the 101 /// Notification Service Extension added since the last refresh. 102 /// 103 /// Core Data unread games are seeded into `BadgeState` as `unreadAt` 104 /// horizons stamped with each game's newest unseen other-author move time. 105 /// The NSE can't reach Core Data, so without this seed a push landing on a 106 /// suspended app would re-stamp the badge from the ledger alone and drop 107 /// any game whose unread state arrived purely via CloudKit sync. Seeding is 108 /// safe under horizon semantics — a game the user has since opened carries a 109 /// newer `seenAt` and won't resurrect — which the old set-based ledger 110 /// couldn't express, hence why this write-back was previously dropped. 111 func refreshAppBadge(reason: String = "unspecified") async { 112 let coreDataUnread = store.unreadOtherMovesGameTimes() 113 BadgeState.seedUnread(coreDataUnread) 114 // Pending invites are binary (not a read horizon), so publish them as a 115 // dedicated authoritative set the NSE unions into its count. Disjoint 116 // from the unread-moves set, so the union below never double-counts. 117 let pendingInvites = store.pendingInviteGameIDs() 118 BadgeState.setPendingInvites(pendingInvites) 119 let snapshot = BadgeRefreshSnapshot( 120 ledgerUnread: BadgeState.unreadGameIDs(), 121 coreDataUnread: Set(coreDataUnread.keys), 122 pendingInvites: pendingInvites 123 ) 124 let merged = snapshot.merged 125 if snapshot != lastLoggedBadgeSnapshot { 126 syncMonitor.note( 127 "app badge refresh(\(reason)): count=\(merged.count) " 128 + "ledger=\(snapshot.ledgerUnread.count) [\(shortIDs(snapshot.ledgerUnread))] " 129 + "coreData=\(snapshot.coreDataUnread.count) [\(shortIDs(snapshot.coreDataUnread))] " 130 + "pendingInvites=\(snapshot.pendingInvites.count) [\(shortIDs(snapshot.pendingInvites))]" 131 ) 132 lastLoggedBadgeSnapshot = snapshot 133 } 134 do { 135 try await UNUserNotificationCenter.current().setBadgeCount(merged.count) 136 } catch { 137 syncMonitor.note("app badge update failed: \(error.localizedDescription)") 138 } 139 } 140 141 /// One startup-only snapshot for badge debugging. Kept out of 142 /// `refreshAppBadge` so normal notification and sync churn does not flood 143 /// diagnostics. 144 func logNotificationStartupSnapshot() async { 145 let delivered = await UNUserNotificationCenter.current().deliveredNotifications() 146 let ledgerUnread = BadgeState.unreadGameIDs() 147 let coreDataUnread = store.unreadOtherMovesGameTimes() 148 let merged = ledgerUnread.union(coreDataUnread.keys) 149 syncMonitor.note( 150 "notif startup: delivered=\(delivered.count) " 151 + "badgeLedger=\(ledgerUnread.count) [\(shortIDs(ledgerUnread))] " 152 + "coreDataUnread=\(coreDataUnread.count) [\(shortIDs(coreDataUnread.keys))] " 153 + "merged=\(merged.count) [\(shortIDs(merged))]" 154 ) 155 for notification in delivered.sorted(by: { $0.date < $1.date }) { 156 syncMonitor.note("notif delivered: \(notificationSummary(notification))") 157 } 158 } 159 160 /// Clears stale provisional badge-ledger entries that are no longer backed 161 /// by either synced unread state or a delivered badge-worthy notification. 162 /// This keeps presence-only notifications (`play`, `replay`, zero-count 163 /// pause) from keeping an old ledger entry alive while still preserving 164 /// push-ahead-of-sync unread entries when their delivered notification is 165 /// visible on the device. 166 func reconcileBadgeLedgerWithDeliveredNotifications() async { 167 let ledgerUnread = BadgeState.unreadGameIDs() 168 guard !ledgerUnread.isEmpty else { return } 169 let coreDataUnread = Set(store.unreadOtherMovesGameTimes().keys) 170 let delivered = await UNUserNotificationCenter.current().deliveredNotifications() 171 let deliveredUnread = deliveredUnreadGameIDs(from: delivered) 172 let stale = ledgerUnread.subtracting(coreDataUnread).subtracting(deliveredUnread) 173 guard !stale.isEmpty else { return } 174 for gameID in stale { 175 BadgeState.forget(gameID: gameID) 176 } 177 syncMonitor.note( 178 "app badge ledger reconcile: cleared=\(stale.count) [\(shortIDs(stale))] " 179 + "coreData=\(coreDataUnread.count) [\(shortIDs(coreDataUnread))] " 180 + "deliveredUnread=\(deliveredUnread.count) [\(shortIDs(deliveredUnread))]" 181 ) 182 } 183 184 private func notificationSummary(_ notification: UNNotification) -> String { 185 let request = notification.request 186 let content = request.content 187 let userInfo = content.userInfo 188 let gameID = notificationGameID(from: userInfo) 189 let kind = userInfo["kind"] as? String 190 let pingKind = userInfo["pingKind"] as? String 191 let nseLogged = (userInfo["crossmateNSELogged"] as? Bool) == true 192 return "id=\(request.identifier)" 193 + " date=\(notification.date.ISO8601Format())" 194 + " game=\(gameID.map { shortID($0) } ?? "nil")" 195 + " kind=\(kind ?? "nil")" 196 + " pingKind=\(pingKind ?? "nil")" 197 + " badge=\(content.badge?.stringValue ?? "nil")" 198 + " nseLogged=\(nseLogged)" 199 + " title=\"\(logEscaped(content.title))\"" 200 + " body=\"\(logEscaped(content.body))\"" 201 } 202 203 private func notificationMarksUnread(_ notification: UNNotification) -> Bool { 204 let userInfo = notification.request.content.userInfo 205 if let payload = PushPayload.decode(from: userInfo["payload"] as? String) { 206 return payload.marksUnread 207 } 208 let kind = userInfo["kind"] as? String 209 return kind == "pause" || kind == "win" || kind == "resign" 210 } 211 212 private func deliveredUnreadGameIDs() async -> Set<UUID> { 213 let delivered = await UNUserNotificationCenter.current().deliveredNotifications() 214 return deliveredUnreadGameIDs(from: delivered) 215 } 216 217 private func deliveredUnreadGameIDs(from delivered: [UNNotification]) -> Set<UUID> { 218 Set(delivered.compactMap { notification -> UUID? in 219 guard notificationMarksUnread(notification), 220 let gameID = notificationGameID(from: notification.request.content.userInfo) 221 else { return nil } 222 return gameID 223 }) 224 } 225 226 private func notificationGameID(from userInfo: [AnyHashable: Any]) -> UUID? { 227 if let raw = userInfo["gameID"] as? String, 228 let id = UUID(uuidString: raw) { 229 return id 230 } 231 guard let ck = userInfo["ck"] as? [AnyHashable: Any], 232 let qry = ck["qry"] as? [AnyHashable: Any], 233 let zoneName = qry["zid"] as? String, 234 zoneName.hasPrefix("game-") 235 else { return nil } 236 return UUID(uuidString: String(zoneName.dropFirst("game-".count))) 237 } 238 239 private func shortIDs<S: Sequence>(_ ids: S) -> String where S.Element == UUID { 240 ids.map(shortID).sorted().joined(separator: ",") 241 } 242 243 private func shortID(_ id: UUID) -> String { 244 String(id.uuidString.prefix(8)) 245 } 246 247 private func logEscaped(_ text: String) -> String { 248 text.replacingOccurrences(of: "\n", with: " ") 249 .trimmingCharacters(in: .whitespacesAndNewlines) 250 } 251 }