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 }