BadgeCoordinator.swift (12681B)
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:readAt:)`. 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.readAt`. 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 if BadgeState.claimLegacyReadThroughHealNeeded() { 113 let deliveredUnread = await deliveredUnreadGameIDs() 114 let healed = store.backfillLegacyReadThrough(excluding: deliveredUnread) 115 syncMonitor.note( 116 "app badge legacy readThrough heal: changed=\(healed) " 117 + "preservedDelivered=\(deliveredUnread.count) [\(shortIDs(deliveredUnread))]" 118 ) 119 } 120 let coreDataUnread = store.unreadOtherMovesGameTimes() 121 BadgeState.seedUnread(coreDataUnread) 122 // Pending invites are binary (not a read horizon), so publish them as a 123 // dedicated authoritative set the NSE unions into its count. Disjoint 124 // from the unread-moves set, so the union below never double-counts. 125 let pendingInvites = store.pendingInviteGameIDs() 126 BadgeState.setPendingInvites(pendingInvites) 127 let snapshot = BadgeRefreshSnapshot( 128 ledgerUnread: BadgeState.unreadGameIDs(), 129 coreDataUnread: Set(coreDataUnread.keys), 130 pendingInvites: pendingInvites 131 ) 132 let merged = snapshot.merged 133 if snapshot != lastLoggedBadgeSnapshot { 134 syncMonitor.note( 135 "app badge refresh(\(reason)): count=\(merged.count) " 136 + "ledger=\(snapshot.ledgerUnread.count) [\(shortIDs(snapshot.ledgerUnread))] " 137 + "coreData=\(snapshot.coreDataUnread.count) [\(shortIDs(snapshot.coreDataUnread))] " 138 + "pendingInvites=\(snapshot.pendingInvites.count) [\(shortIDs(snapshot.pendingInvites))]" 139 ) 140 lastLoggedBadgeSnapshot = snapshot 141 } 142 do { 143 try await UNUserNotificationCenter.current().setBadgeCount(merged.count) 144 } catch { 145 syncMonitor.note("app badge update failed: \(error.localizedDescription)") 146 } 147 } 148 149 /// One startup-only snapshot for badge debugging. Kept out of 150 /// `refreshAppBadge` so normal notification and sync churn does not flood 151 /// diagnostics. 152 func logNotificationStartupSnapshot() async { 153 let delivered = await UNUserNotificationCenter.current().deliveredNotifications() 154 let ledgerUnread = BadgeState.unreadGameIDs() 155 let coreDataUnread = store.unreadOtherMovesGameTimes() 156 let merged = ledgerUnread.union(coreDataUnread.keys) 157 syncMonitor.note( 158 "notif startup: delivered=\(delivered.count) " 159 + "badgeLedger=\(ledgerUnread.count) [\(shortIDs(ledgerUnread))] " 160 + "coreDataUnread=\(coreDataUnread.count) [\(shortIDs(coreDataUnread.keys))] " 161 + "merged=\(merged.count) [\(shortIDs(merged))]" 162 ) 163 for notification in delivered.sorted(by: { $0.date < $1.date }) { 164 syncMonitor.note("notif delivered: \(notificationSummary(notification))") 165 } 166 } 167 168 /// Clears stale provisional badge-ledger entries that are no longer backed 169 /// by either synced unread state or a delivered badge-worthy notification. 170 /// This keeps presence-only notifications (`play`, `replay`, zero-count 171 /// pause) from keeping an old ledger entry alive while still preserving 172 /// push-ahead-of-sync unread entries when their delivered notification is 173 /// visible on the device. 174 func reconcileBadgeLedgerWithDeliveredNotifications() async { 175 let ledgerUnread = BadgeState.unreadGameIDs() 176 guard !ledgerUnread.isEmpty else { return } 177 let coreDataUnread = Set(store.unreadOtherMovesGameTimes().keys) 178 let delivered = await UNUserNotificationCenter.current().deliveredNotifications() 179 let deliveredUnread = deliveredUnreadGameIDs(from: delivered) 180 let stale = ledgerUnread.subtracting(coreDataUnread).subtracting(deliveredUnread) 181 guard !stale.isEmpty else { return } 182 for gameID in stale { 183 BadgeState.forget(gameID: gameID) 184 } 185 syncMonitor.note( 186 "app badge ledger reconcile: cleared=\(stale.count) [\(shortIDs(stale))] " 187 + "coreData=\(coreDataUnread.count) [\(shortIDs(coreDataUnread))] " 188 + "deliveredUnread=\(deliveredUnread.count) [\(shortIDs(deliveredUnread))]" 189 ) 190 } 191 192 private func notificationSummary(_ notification: UNNotification) -> String { 193 let request = notification.request 194 let content = request.content 195 let userInfo = content.userInfo 196 let gameID = notificationGameID(from: userInfo) 197 let kind = userInfo["kind"] as? String 198 let pingKind = userInfo["pingKind"] as? String 199 let nseLogged = (userInfo["crossmateNSELogged"] as? Bool) == true 200 return "id=\(request.identifier)" 201 + " date=\(notification.date.ISO8601Format())" 202 + " game=\(gameID.map { shortID($0) } ?? "nil")" 203 + " kind=\(kind ?? "nil")" 204 + " pingKind=\(pingKind ?? "nil")" 205 + " badge=\(content.badge?.stringValue ?? "nil")" 206 + " nseLogged=\(nseLogged)" 207 + " title=\"\(logEscaped(content.title))\"" 208 + " body=\"\(logEscaped(content.body))\"" 209 } 210 211 private func notificationMarksUnread(_ notification: UNNotification) -> Bool { 212 let userInfo = notification.request.content.userInfo 213 if let payload = PushPayload.decode(from: userInfo["payload"] as? String) { 214 return payload.marksUnread 215 } 216 let kind = userInfo["kind"] as? String 217 return kind == "pause" || kind == "win" || kind == "resign" 218 } 219 220 private func deliveredUnreadGameIDs() async -> Set<UUID> { 221 let delivered = await UNUserNotificationCenter.current().deliveredNotifications() 222 return deliveredUnreadGameIDs(from: delivered) 223 } 224 225 private func deliveredUnreadGameIDs(from delivered: [UNNotification]) -> Set<UUID> { 226 Set(delivered.compactMap { notification -> UUID? in 227 guard notificationMarksUnread(notification), 228 let gameID = notificationGameID(from: notification.request.content.userInfo) 229 else { return nil } 230 return gameID 231 }) 232 } 233 234 private func notificationGameID(from userInfo: [AnyHashable: Any]) -> UUID? { 235 if let raw = userInfo["gameID"] as? String, 236 let id = UUID(uuidString: raw) { 237 return id 238 } 239 guard let ck = userInfo["ck"] as? [AnyHashable: Any], 240 let qry = ck["qry"] as? [AnyHashable: Any], 241 let zoneName = qry["zid"] as? String, 242 zoneName.hasPrefix("game-") 243 else { return nil } 244 return UUID(uuidString: String(zoneName.dropFirst("game-".count))) 245 } 246 247 private func shortIDs<S: Sequence>(_ ids: S) -> String where S.Element == UUID { 248 ids.map(shortID).sorted().joined(separator: ",") 249 } 250 251 private func shortID(_ id: UUID) -> String { 252 String(id.uuidString.prefix(8)) 253 } 254 255 private func logEscaped(_ text: String) -> String { 256 text.replacingOccurrences(of: "\n", with: " ") 257 .trimmingCharacters(in: .whitespacesAndNewlines) 258 } 259 }