crossmate

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

NotificationService.swift (14475B)


      1 @preconcurrency 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: (@Sendable (UNNotificationContent) -> Void)?
     28     private var bestAttemptContent: UNMutableNotificationContent?
     29 
     30     override func didReceive(
     31         _ request: UNNotificationRequest,
     32         withContentHandler contentHandler: @escaping @Sendable (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             if let encrypted,
     60                kind == "invite",
     61                let fromAuthorID = (userInfo["fromAuthorID"] as? String).flatMap({ $0.isEmpty ? nil : $0 }),
     62                let key = FriendEncryptionKeyDirectory.key(for: fromAuthorID),
     63                let opened = PushPayloadCipher.open(encrypted, key: key) {
     64                 return opened
     65             }
     66             return PushPayload.decode(from: userInfo["payload"] as? String)
     67         }()
     68 
     69         // The wire body is now a generic placeholder ("New activity in one of
     70         // your puzzles") — the real wording never leaves the sender in cleartext.
     71         // Recompose it here from the (decrypted) structured fields:
     72         // `PushPayload.composedBody` runs the same builders the sender used,
     73         // substituting the recipient's private nickname for the sender when one
     74         // is set (mirrored authorID → nickname into the App Group, with
     75         // `fromAuthorID` identifying the sender), otherwise the sender's own
     76         // `playerName` carried in the payload. Recomposing from components means
     77         // a friend's later rename can never desync the result.
     78         // Record *why* the rewrite did or didn't happen as a diagnostics
     79         // receipt — a silent no-op otherwise hides several distinct causes (a
     80         // bodyless background event, an `enc` we couldn't decrypt, or an older
     81         // sender with no structured fields). With it, the next occurrence names
     82         // the cause.
     83         let fromAuthorID = (userInfo["fromAuthorID"] as? String)
     84             .flatMap { $0.isEmpty ? nil : $0 }
     85         let nickname = fromAuthorID.flatMap { NicknameDirectory.entry(for: $0)?.nickname }
     86         let fromPrefix = fromAuthorID.map { String($0.prefix(8)) } ?? "nil"
     87         let rewriteOutcome: String
     88         if bestAttemptContent.body.isEmpty {
     89             rewriteOutcome = "skipped=empty-body"
     90         } else if let payload,
     91                   let rebuilt = payload.composedBody(playerName: nickname ?? payload.playerName ?? "") {
     92             bestAttemptContent.body = rebuilt
     93             rewriteOutcome = "applied via=\(nickname != nil ? "nickname" : "payload-name") from=\(fromPrefix)"
     94         } else if payload == nil {
     95             rewriteOutcome = encrypted != nil ? "skipped=undecryptable from=\(fromPrefix)" : "skipped=no-payload from=\(fromPrefix)"
     96         } else {
     97             rewriteOutcome = "skipped=not-composable from=\(fromPrefix)"
     98         }
     99         VisibleNotificationReceiptLog.record(
    100             body: rewriteOutcome,
    101             source: "nickname-rewrite"
    102         )
    103 
    104         VisibleNotificationReceiptLog.record(
    105             body: bestAttemptContent.body,
    106             source: "notification-service-extension"
    107         )
    108         // When the sender attached pause diagnostics, record them as a second
    109         // receipt so they surface in the app's diagnostics log alongside the
    110         // visible body — the only channel we have to inspect a peer's
    111         // sender-side counting inputs without reaching that peer's device.
    112         if let diagnostics = payload?.diagnostics {
    113             VisibleNotificationReceiptLog.record(
    114                 body: diagnostics.summaryLine,
    115                 source: "pause-diagnostics"
    116             )
    117         }
    118         var updatedUserInfo = bestAttemptContent.userInfo
    119         updatedUserInfo["crossmateNSELogged"] = true
    120         bestAttemptContent.userInfo = updatedUserInfo
    121 
    122         // Whether this push represents grid changes the recipient hasn't seen.
    123         // Prefer the structured payload; fall back to the coarse `kind` when it
    124         // is absent — an older sender, or the worker not yet forwarding it.
    125         let marksUnread: Bool
    126         if let payload {
    127             marksUnread = payload.marksUnread
    128         } else {
    129             marksUnread = kind == "pause" || kind == "win" || kind == "resign"
    130         }
    131 
    132         if let gameID, marksUnread {
    133             // Durable completion delivery can be retried (and APNs can deliver
    134             // it) well after the puzzle finished. Preserve the event's real
    135             // horizon so a recipient who already saw the completion has a
    136             // newer seen watermark and does not get a phantom app badge.
    137             BadgeState.markUnread(gameID: gameID, at: payload?.occurredAt ?? Date())
    138         }
    139         // While another device of this account is present in the game, deliver
    140         // passively: no banner, no sound — the alert drops quietly into
    141         // Notification Center, where the present device's read-cursor sync will
    142         // sweep it shortly. The full no-show path (`willPresent` → `[]`) only
    143         // runs on a foreground app; on an idle sibling the NSE is the only code
    144         // that runs, and `.passive` is as quiet as it can make an alert push.
    145         let deliveredPassively = gameID.map { BadgeState.isSuppressed(gameID: $0) } ?? false
    146         if deliveredPassively {
    147             bestAttemptContent.interruptionLevel = .passive
    148         }
    149         // Fold in pending invites the app published to the App Group: a moves
    150         // push must not re-stamp the badge to the moves-only count and drop a
    151         // still-pending invite. The two sets are disjoint, so the union is exact.
    152         let count = BadgeState.unreadGameIDs()
    153             .union(BadgeState.pendingInviteGameIDs()).count
    154         bestAttemptContent.badge = NSNumber(value: count)
    155         VisibleNotificationReceiptLog.record(
    156             body: [
    157                 "game=\(gameID.map { String($0.uuidString.prefix(8)) } ?? "nil")",
    158                 "kind=\(kind ?? "nil")",
    159                 "payload=\(payload == nil ? "absent" : "present")",
    160                 "marksUnread=\(marksUnread)",
    161                 "passive=\(deliveredPassively)",
    162                 "ledgerUnread=\(BadgeState.unreadGameIDs().count)",
    163                 "pendingInvites=\(BadgeState.pendingInviteGameIDs().count)",
    164                 "stampedBadge=\(count)"
    165             ].joined(separator: " "),
    166             source: "notification-service-extension-badge"
    167         )
    168 
    169         // Coalesce successive session-end summaries for one game into the
    170         // single Notification Center tile they share (same apns-collapse-id).
    171         // Only a `pause` carrying structured counts can be folded; everything
    172         // else (and an older payload-less sender) is delivered as prepared.
    173         let pauseCounts: (fills: Int, clears: Int, checks: Int, reveals: Int)?
    174         if case let .pause(fills, clears, checks, reveals) = payload?.event {
    175             pauseCounts = (fills, clears, checks, reveals)
    176         } else {
    177             pauseCounts = nil
    178         }
    179         let center = UNUserNotificationCenter.current()
    180         center.getDeliveredNotifications { delivered in
    181             Self.finalize(
    182                 content: bestAttemptContent,
    183                 gameID: gameID,
    184                 fromAuthorID: fromAuthorID,
    185                 puzzleTitle: payload?.puzzleTitle,
    186                 playerName: payload?.playerName,
    187                 pauseCounts: pauseCounts,
    188                 delivered: delivered,
    189                 center: center,
    190                 contentHandler: contentHandler
    191             )
    192         }
    193     }
    194 
    195     /// Applies game-tile coalescing once the already-delivered notifications
    196     /// are known, then hands the (possibly rewritten) content back to iOS.
    197     ///
    198     /// A `pause` is low-stakes presence chatter, so it is always delivered
    199     /// `.passive` — it updates the tile and the app badge without a sound or a
    200     /// banner-wake. When a tile for this game is already showing, this is a
    201     /// follow-up session-end push: its counts are folded into the running
    202     /// per-sender tally the tile carries in `userInfo` and the body is
    203     /// rewritten to the combined summary. The first push for a game finds no
    204     /// tile and merely seeds the tally. A non-pause push (or an older
    205     /// payload-less sender) carries no counts and is delivered unchanged; its
    206     /// shared collapse id still replaces any tile in place.
    207     private static func finalize(
    208         content: UNMutableNotificationContent,
    209         gameID: UUID?,
    210         fromAuthorID: String?,
    211         puzzleTitle: String?,
    212         playerName: String?,
    213         pauseCounts: (fills: Int, clears: Int, checks: Int, reveals: Int)?,
    214         delivered: [UNNotification],
    215         center: UNUserNotificationCenter,
    216         contentHandler: @escaping @Sendable (UNNotificationContent) -> Void
    217     ) {
    218         guard let gameID, let pauseCounts else {
    219             contentHandler(content)
    220             return
    221         }
    222 
    223         let existing = delivered.filter {
    224             ($0.request.content.userInfo["gameID"] as? String) == gameID.uuidString
    225         }
    226         // Seed from whichever existing tile already carries a tally (the most
    227         // recent wins), else start fresh so this push still records itself.
    228         var summary = existing
    229             .compactMap {
    230                 CoalescedSummary.decode(from: $0.request.content.userInfo["coalescedSummary"] as? String)
    231             }
    232             .last ?? CoalescedSummary()
    233         // Prefer the receiver's private nickname, fall back to the sender's own
    234         // name from the payload, then to a short author id — so a contributor
    235         // is always named without parsing the rendered body.
    236         let authorID = fromAuthorID.flatMap { $0.isEmpty ? nil : $0 }
    237         let name = authorID.flatMap { NicknameDirectory.entry(for: $0)?.nickname }
    238             ?? playerName.flatMap { $0.isEmpty ? nil : $0 }
    239             ?? authorID.map { String($0.prefix(8)) }
    240             ?? ""
    241         summary.add(
    242             authorID: authorID ?? "?",
    243             name: name,
    244             fills: pauseCounts.fills,
    245             clears: pauseCounts.clears,
    246             checks: pauseCounts.checks,
    247             reveals: pauseCounts.reveals
    248         )
    249         var userInfo = content.userInfo
    250         if let encoded = summary.encodedString() {
    251             userInfo["coalescedSummary"] = encoded
    252         }
    253         content.userInfo = userInfo
    254 
    255         // Always deliver a pause quietly — no sound, no banner-wake — leaving
    256         // the badge as the only visible signal. Idempotent with the
    257         // present-sibling suppression path above.
    258         content.interruptionLevel = .passive
    259         let coalescing = !existing.isEmpty
    260         if coalescing {
    261             if let body = PuzzleNotificationText.coalescedBody(
    262                 puzzleTitle: puzzleTitle ?? "",
    263                 contributors: summary.contributors
    264             ) {
    265                 content.body = body
    266             }
    267             // Drop the superseded tiles so only this freshest one remains; the
    268             // shared collapse id already replaces a same-id tile, but this also
    269             // clears any pre-collapse-id leftovers the system won't merge.
    270             let identifiers = existing.map { $0.request.identifier }
    271             if !identifiers.isEmpty {
    272                 center.removeDeliveredNotifications(withIdentifiers: identifiers)
    273             }
    274         }
    275         VisibleNotificationReceiptLog.record(
    276             body: "coalesced=\(coalescing) contributors=\(summary.contributors.count) passive=true",
    277             source: "notification-service-extension-coalesce"
    278         )
    279         contentHandler(content)
    280     }
    281 
    282     override func serviceExtensionTimeWillExpire() {
    283         if let contentHandler, let bestAttemptContent {
    284             contentHandler(bestAttemptContent)
    285         }
    286     }
    287 }