crossmate

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

NotificationService.swift (13714B)


      1 import UserNotifications
      2 
      3 /// Notification Service Extension. Runs in its own process when an APNs alert
      4 /// arrives with `mutable-content: 1`, with ~30s to mutate the content before
      5 /// iOS displays it.
      6 ///
      7 /// Crossmate uses the NSE for one job only: keep the app-icon badge close to
      8 /// accurate when push notifications land while the main app is suspended or
      9 /// terminated. The push-side badge model is a per-game horizon ledger in App
     10 /// Group UserDefaults (`BadgeState`): pushes advance `unreadAt`, while the app
     11 /// advances `seenAt` when the user opens the puzzle. Once the main app runs it
     12 /// unions this provisional push ledger with Core Data ground truth and
     13 /// re-stamps the badge.
     14 ///
     15 /// Whether a push marks its game unread is decided from the per-recipient
     16 /// `PushPayload` the sender encodes (forwarded opaquely by the worker):
     17 ///   - a `pause` with unseen cells, or a `win` / `resign` — mark `gameID`
     18 ///     unread. Per-game horizon semantics make repeats idempotent (a pause
     19 ///     followed by a win for the same game is one badge unit, not two).
     20 ///   - a `nudge` (a manual "come play" ping), a legacy `play`, or a `pause`
     21 ///     with zero counts — presence only; the grid has nothing unseen for this
     22 ///     recipient, so stamp the current count without growing it.
     23 /// When the payload is absent (an older sender, or the worker not yet
     24 /// forwarding it) we fall back to the coarse top-level `kind`.
     25 final class NotificationService: UNNotificationServiceExtension {
     26 
     27     private var contentHandler: ((UNNotificationContent) -> Void)?
     28     private var bestAttemptContent: UNMutableNotificationContent?
     29 
     30     override func didReceive(
     31         _ request: UNNotificationRequest,
     32         withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
     33     ) {
     34         self.contentHandler = contentHandler
     35         self.bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
     36 
     37         guard let bestAttemptContent else {
     38             contentHandler(request.content)
     39             return
     40         }
     41 
     42         let userInfo = request.content.userInfo
     43         let kind = userInfo["kind"] as? String
     44         let gameID = (userInfo["gameID"] as? String).flatMap(UUID.init(uuidString:))
     45 
     46         // Resolve the structured payload. Current senders ship it encrypted
     47         // (`enc`) under the game's content key, which the app mirrors into the
     48         // App Group keyed by gameID; decrypt it here. Fall back to the legacy
     49         // cleartext `payload` an older sender may still send. When `enc` is
     50         // present but no key has synced yet (a just-joined participant), this is
     51         // nil and the generic cleartext body stands.
     52         let encrypted = userInfo["enc"] as? String
     53         let payload: PushPayload? = {
     54             if let encrypted, let gameID,
     55                let key = ContentKeyDirectory.key(for: gameID),
     56                let opened = PushPayloadCipher.open(encrypted, key: key) {
     57                 return opened
     58             }
     59             return PushPayload.decode(from: userInfo["payload"] as? String)
     60         }()
     61 
     62         // The wire body is now a generic placeholder ("New activity in one of
     63         // your puzzles") — the real wording never leaves the sender in cleartext.
     64         // Recompose it here from the (decrypted) structured fields:
     65         // `PushPayload.composedBody` runs the same builders the sender used,
     66         // substituting the recipient's private nickname for the sender when one
     67         // is set (mirrored authorID → nickname into the App Group, with
     68         // `fromAuthorID` identifying the sender), otherwise the sender's own
     69         // `playerName` carried in the payload. Recomposing from components means
     70         // a friend's later rename can never desync the result.
     71         // Record *why* the rewrite did or didn't happen as a diagnostics
     72         // receipt — a silent no-op otherwise hides several distinct causes (a
     73         // bodyless background event, an `enc` we couldn't decrypt, or an older
     74         // sender with no structured fields). With it, the next occurrence names
     75         // the cause.
     76         let fromAuthorID = (userInfo["fromAuthorID"] as? String)
     77             .flatMap { $0.isEmpty ? nil : $0 }
     78         let nickname = fromAuthorID.flatMap { NicknameDirectory.entry(for: $0)?.nickname }
     79         let fromPrefix = fromAuthorID.map { String($0.prefix(8)) } ?? "nil"
     80         let rewriteOutcome: String
     81         if bestAttemptContent.body.isEmpty {
     82             rewriteOutcome = "skipped=empty-body"
     83         } else if let payload,
     84                   let rebuilt = payload.composedBody(playerName: nickname ?? payload.playerName ?? "") {
     85             bestAttemptContent.body = rebuilt
     86             rewriteOutcome = "applied via=\(nickname != nil ? "nickname" : "payload-name") from=\(fromPrefix)"
     87         } else if payload == nil {
     88             rewriteOutcome = encrypted != nil ? "skipped=undecryptable from=\(fromPrefix)" : "skipped=no-payload from=\(fromPrefix)"
     89         } else {
     90             rewriteOutcome = "skipped=not-composable from=\(fromPrefix)"
     91         }
     92         VisibleNotificationReceiptLog.record(
     93             body: rewriteOutcome,
     94             source: "nickname-rewrite"
     95         )
     96 
     97         VisibleNotificationReceiptLog.record(
     98             body: bestAttemptContent.body,
     99             source: "notification-service-extension"
    100         )
    101         // When the sender attached pause diagnostics, record them as a second
    102         // receipt so they surface in the app's diagnostics log alongside the
    103         // visible body — the only channel we have to inspect a peer's
    104         // sender-side counting inputs without reaching that peer's device.
    105         if let diagnostics = payload?.diagnostics {
    106             VisibleNotificationReceiptLog.record(
    107                 body: diagnostics.summaryLine,
    108                 source: "pause-diagnostics"
    109             )
    110         }
    111         var updatedUserInfo = bestAttemptContent.userInfo
    112         updatedUserInfo["crossmateNSELogged"] = true
    113         bestAttemptContent.userInfo = updatedUserInfo
    114 
    115         // Whether this push represents grid changes the recipient hasn't seen.
    116         // Prefer the structured payload; fall back to the coarse `kind` when it
    117         // is absent — an older sender, or the worker not yet forwarding it.
    118         let marksUnread: Bool
    119         if let payload {
    120             marksUnread = payload.marksUnread
    121         } else {
    122             marksUnread = kind == "pause" || kind == "win" || kind == "resign"
    123         }
    124 
    125         if let gameID, marksUnread {
    126             BadgeState.markUnread(gameID: gameID)
    127         }
    128         // While another device of this account is present in the game, deliver
    129         // passively: no banner, no sound — the alert drops quietly into
    130         // Notification Center, where the present device's read-cursor sync will
    131         // sweep it shortly. The full no-show path (`willPresent` → `[]`) only
    132         // runs on a foreground app; on an idle sibling the NSE is the only code
    133         // that runs, and `.passive` is as quiet as it can make an alert push.
    134         let deliveredPassively = gameID.map { BadgeState.isSuppressed(gameID: $0) } ?? false
    135         if deliveredPassively {
    136             bestAttemptContent.interruptionLevel = .passive
    137         }
    138         // Fold in pending invites the app published to the App Group: a moves
    139         // push must not re-stamp the badge to the moves-only count and drop a
    140         // still-pending invite. The two sets are disjoint, so the union is exact.
    141         let count = BadgeState.unreadGameIDs()
    142             .union(BadgeState.pendingInviteGameIDs()).count
    143         bestAttemptContent.badge = NSNumber(value: count)
    144         VisibleNotificationReceiptLog.record(
    145             body: [
    146                 "game=\(gameID.map { String($0.uuidString.prefix(8)) } ?? "nil")",
    147                 "kind=\(kind ?? "nil")",
    148                 "payload=\(payload == nil ? "absent" : "present")",
    149                 "marksUnread=\(marksUnread)",
    150                 "passive=\(deliveredPassively)",
    151                 "ledgerUnread=\(BadgeState.unreadGameIDs().count)",
    152                 "pendingInvites=\(BadgeState.pendingInviteGameIDs().count)",
    153                 "stampedBadge=\(count)"
    154             ].joined(separator: " "),
    155             source: "notification-service-extension-badge"
    156         )
    157 
    158         // Coalesce successive session-end summaries for one game into the
    159         // single Notification Center tile they share (same apns-collapse-id).
    160         // Only a `pause` carrying structured counts can be folded; everything
    161         // else (and an older payload-less sender) is delivered as prepared.
    162         let pauseCounts: (fills: Int, clears: Int, checks: Int, reveals: Int)?
    163         if case let .pause(fills, clears, checks, reveals) = payload?.event {
    164             pauseCounts = (fills, clears, checks, reveals)
    165         } else {
    166             pauseCounts = nil
    167         }
    168         let center = UNUserNotificationCenter.current()
    169         center.getDeliveredNotifications { delivered in
    170             self.finalize(
    171                 content: bestAttemptContent,
    172                 gameID: gameID,
    173                 fromAuthorID: fromAuthorID,
    174                 puzzleTitle: payload?.puzzleTitle,
    175                 playerName: payload?.playerName,
    176                 pauseCounts: pauseCounts,
    177                 delivered: delivered,
    178                 center: center,
    179                 contentHandler: contentHandler
    180             )
    181         }
    182     }
    183 
    184     /// Applies game-tile coalescing once the already-delivered notifications
    185     /// are known, then hands the (possibly rewritten) content back to iOS.
    186     ///
    187     /// A `pause` is low-stakes presence chatter, so it is always delivered
    188     /// `.passive` — it updates the tile and the app badge without a sound or a
    189     /// banner-wake. When a tile for this game is already showing, this is a
    190     /// follow-up session-end push: its counts are folded into the running
    191     /// per-sender tally the tile carries in `userInfo` and the body is
    192     /// rewritten to the combined summary. The first push for a game finds no
    193     /// tile and merely seeds the tally. A non-pause push (or an older
    194     /// payload-less sender) carries no counts and is delivered unchanged; its
    195     /// shared collapse id still replaces any tile in place.
    196     private func finalize(
    197         content: UNMutableNotificationContent,
    198         gameID: UUID?,
    199         fromAuthorID: String?,
    200         puzzleTitle: String?,
    201         playerName: String?,
    202         pauseCounts: (fills: Int, clears: Int, checks: Int, reveals: Int)?,
    203         delivered: [UNNotification],
    204         center: UNUserNotificationCenter,
    205         contentHandler: @escaping (UNNotificationContent) -> Void
    206     ) {
    207         guard let gameID, let pauseCounts else {
    208             contentHandler(content)
    209             return
    210         }
    211 
    212         let existing = delivered.filter {
    213             ($0.request.content.userInfo["gameID"] as? String) == gameID.uuidString
    214         }
    215         // Seed from whichever existing tile already carries a tally (the most
    216         // recent wins), else start fresh so this push still records itself.
    217         var summary = existing
    218             .compactMap {
    219                 CoalescedSummary.decode(from: $0.request.content.userInfo["coalescedSummary"] as? String)
    220             }
    221             .last ?? CoalescedSummary()
    222         // Prefer the receiver's private nickname, fall back to the sender's own
    223         // name from the payload, then to a short author id — so a contributor
    224         // is always named without parsing the rendered body.
    225         let authorID = fromAuthorID.flatMap { $0.isEmpty ? nil : $0 }
    226         let name = authorID.flatMap { NicknameDirectory.entry(for: $0)?.nickname }
    227             ?? playerName.flatMap { $0.isEmpty ? nil : $0 }
    228             ?? authorID.map { String($0.prefix(8)) }
    229             ?? ""
    230         summary.add(
    231             authorID: authorID ?? "?",
    232             name: name,
    233             fills: pauseCounts.fills,
    234             clears: pauseCounts.clears,
    235             checks: pauseCounts.checks,
    236             reveals: pauseCounts.reveals
    237         )
    238         var userInfo = content.userInfo
    239         if let encoded = summary.encodedString() {
    240             userInfo["coalescedSummary"] = encoded
    241         }
    242         content.userInfo = userInfo
    243 
    244         // Always deliver a pause quietly — no sound, no banner-wake — leaving
    245         // the badge as the only visible signal. Idempotent with the
    246         // present-sibling suppression path above.
    247         content.interruptionLevel = .passive
    248         let coalescing = !existing.isEmpty
    249         if coalescing {
    250             if let body = PuzzleNotificationText.coalescedBody(
    251                 puzzleTitle: puzzleTitle ?? "",
    252                 contributors: summary.contributors
    253             ) {
    254                 content.body = body
    255             }
    256             // Drop the superseded tiles so only this freshest one remains; the
    257             // shared collapse id already replaces a same-id tile, but this also
    258             // clears any pre-collapse-id leftovers the system won't merge.
    259             let identifiers = existing.map { $0.request.identifier }
    260             if !identifiers.isEmpty {
    261                 center.removeDeliveredNotifications(withIdentifiers: identifiers)
    262             }
    263         }
    264         VisibleNotificationReceiptLog.record(
    265             body: "coalesced=\(coalescing) contributors=\(summary.contributors.count) passive=true",
    266             source: "notification-service-extension-coalesce"
    267         )
    268         contentHandler(content)
    269     }
    270 
    271     override func serviceExtensionTimeWillExpire() {
    272         if let contentHandler, let bestAttemptContent {
    273             contentHandler(bestAttemptContent)
    274         }
    275     }
    276 }