crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }