NotificationState.swift (22434B)
1 import Foundation 2 3 /// Notification suppression state persisted via App Group UserDefaults. 4 /// 5 /// State tracked: 6 /// - `activePuzzleID` (+ local leave-grace) — this device is viewing a puzzle, 7 /// so notifications and the unseen-moves badge for it are skipped. 8 /// 9 /// `isSuppressed(gameID:)` is the unified gate; presently it is just the 10 /// local-active check, kept under that name so callers don't need to know 11 /// whether sibling-device presence is part of the rule. 12 enum NotificationState { 13 static let appGroup = "group.net.inqk.crossmate" 14 15 private static let activeKey = "notif.activePuzzleID" 16 private static let localActiveUntilKey = "notif.localActiveUntil" 17 18 /// Grace window after the user leaves a puzzle during which the game is 19 /// still treated as active. Inbound moves or pings fetched while the 20 /// puzzle was on screen can finish processing a beat after `.onDisappear` 21 /// clears the active ID; without this tail that race re-flags moves the 22 /// user already watched arrive as unseen (and can re-notify for them). 23 static let leaveGraceWindow: TimeInterval = 15 24 25 /// `UserDefaults` itself isn't `Sendable` under strict concurrency, but its 26 /// methods are thread-safe in practice. Vouch for that here so the testing 27 /// override can flow through a `TaskLocal`. 28 struct TestingDefaults: @unchecked Sendable { 29 let userDefaults: UserDefaults 30 } 31 32 /// Test-only override for the storage backend. Production never sets 33 /// this; tests inject a per-test UUID-named `UserDefaults` (typically via 34 /// `.isolatedNotificationState`) so suites no longer mutate the shared 35 /// App-Group store. Implemented as a `TaskLocal` so the override flows 36 /// through actor hops and `Task` continuations inside test bodies — a 37 /// plain settable static would race when suites run in parallel. 38 @TaskLocal static var testingDefaults: TestingDefaults? 39 40 nonisolated(unsafe) private static let sharedDefaults: UserDefaults? = 41 UserDefaults(suiteName: appGroup) 42 43 private static var defaults: UserDefaults? { 44 testingDefaults?.userDefaults ?? sharedDefaults 45 } 46 47 static func activePuzzleID() -> UUID? { 48 guard let s = defaults?.string(forKey: activeKey) else { return nil } 49 return UUID(uuidString: s) 50 } 51 52 static func setActivePuzzleID(_ id: UUID?) { 53 guard let defaults else { return } 54 if let id { 55 defaults.set(id.uuidString, forKey: activeKey) 56 } else { 57 defaults.removeObject(forKey: activeKey) 58 } 59 } 60 61 static func clearActivePuzzleID(if id: UUID, now: Date = Date()) { 62 guard activePuzzleID() == id else { return } 63 setActivePuzzleID(nil) 64 stampLocalActive(id, until: now.addingTimeInterval(leaveGraceWindow), now: now) 65 } 66 67 /// True if the user is currently viewing the puzzle for `gameID`, or left 68 /// it within `leaveGraceWindow`. Active-puzzle suppression applies to all 69 /// ping kinds — no notifications fire while you're already in the puzzle 70 /// they describe — and the grace tail keeps the just-left puzzle covered 71 /// while in-flight inbound work settles. 72 static func isActive(gameID: UUID, now: Date = Date()) -> Bool { 73 if activePuzzleID() == gameID { return true } 74 if let until = localActiveMap()[gameID.uuidString] { 75 return now.timeIntervalSince1970 < until 76 } 77 return false 78 } 79 80 /// Stamps `id` as locally active until `until`, evicting entries that 81 /// have already expired so the map stays small. 82 private static func stampLocalActive(_ id: UUID, until: Date, now: Date) { 83 guard let defaults else { return } 84 var map = localActiveMap() 85 map[id.uuidString] = until.timeIntervalSince1970 86 let nowTS = now.timeIntervalSince1970 87 map = map.filter { $0.value > nowTS } 88 defaults.set(map, forKey: localActiveUntilKey) 89 } 90 91 private static func localActiveMap() -> [String: TimeInterval] { 92 defaults?.dictionary(forKey: localActiveUntilKey) as? [String: TimeInterval] ?? [:] 93 } 94 95 /// The unified suppression gate: the user is viewing `gameID` here 96 /// (including the local leave-grace tail). Sibling-device presence used 97 /// to factor in here via the `.opened`/`.closed` lease; that subsystem is 98 /// gone — cross-device read state now rides `Player.readAt`. The 99 /// gate is kept under this name so callers don't need to change. 100 static func isSuppressed(gameID: UUID, now: Date = Date()) -> Bool { 101 isActive(gameID: gameID, now: now) 102 } 103 104 private static let legacyLeasePurgeKey = "migration.legacyLeasePurge.v1" 105 private static let legacyInvitePurgeKey = "migration.legacyInvitePurge.v1" 106 private static let staleHailPurgeKey = "migration.staleHailPurge.v1" 107 private static let debugPreviewFriendPurgeKey = "migration.debugPreviewFriendPurge.v1" 108 private static let legacyPlayPingPurgeKey = "migration.legacyPlayPingPurge.v1" 109 110 /// True if the one-shot cleanup of legacy `.opened`/`.closed` lease pings 111 /// has not yet run successfully on this device. The flag is per-device 112 /// (App Group UserDefaults), so each device drains its own slice of the 113 /// account zone exactly once. 114 static func legacyLeasePurgeNeeded() -> Bool { 115 defaults?.bool(forKey: legacyLeasePurgeKey) == false 116 } 117 118 /// Records that the legacy-lease purge completed successfully so the next 119 /// launch skips it. 120 static func markLegacyLeasePurged() { 121 defaults?.set(true, forKey: legacyLeasePurgeKey) 122 } 123 124 /// True if the one-shot cleanup of legacy broadcast `.invite` pings has 125 /// not yet run successfully on this device. 126 static func legacyInvitePurgeNeeded() -> Bool { 127 defaults?.bool(forKey: legacyInvitePurgeKey) == false 128 } 129 130 /// Records that the legacy invite purge completed successfully so the next 131 /// launch skips it. 132 static func markLegacyInvitePurged() { 133 defaults?.set(true, forKey: legacyInvitePurgeKey) 134 } 135 136 /// True if the one-shot cleanup of pre-migration `.hail` pings from game 137 /// zones has not yet run successfully on this device. 138 static func staleHailPurgeNeeded() -> Bool { 139 defaults?.bool(forKey: staleHailPurgeKey) == false 140 } 141 142 /// Records that the stale-hail purge completed successfully so the next 143 /// launch skips it. 144 static func markStaleHailPurged() { 145 defaults?.set(true, forKey: staleHailPurgeKey) 146 } 147 148 /// True if the one-shot cleanup of local debug-preview friend rows has 149 /// not yet run successfully on this device. 150 static func debugPreviewFriendPurgeNeeded() -> Bool { 151 defaults?.bool(forKey: debugPreviewFriendPurgeKey) == false 152 } 153 154 /// Records that the debug-preview friend cleanup completed successfully. 155 static func markDebugPreviewFriendPurged() { 156 defaults?.set(true, forKey: debugPreviewFriendPurgeKey) 157 } 158 159 /// True if the one-shot cleanup of retired play-event Ping kinds 160 /// (`.check`, `.reveal`, `.resign`, `.win`, and the old session-start 161 /// `.join`) has not yet run successfully on this device. Those kinds 162 /// were retired when the push worker took over user-facing event 163 /// notifications; any records still in game zones are dead weight that 164 /// can resurrect obsolete announcements on a zone replay. 165 static func legacyPlayPingPurgeNeeded() -> Bool { 166 defaults?.bool(forKey: legacyPlayPingPurgeKey) == false 167 } 168 169 /// Records that the legacy play-ping purge completed successfully. 170 static func markLegacyPlayPingPurged() { 171 defaults?.set(true, forKey: legacyPlayPingPurgeKey) 172 } 173 174 /// Exposes the (testing-aware) App Group defaults to siblings in this 175 /// module — currently `BadgeState`, which shares storage but lives in 176 /// its own namespace. 177 static var sharedDefaultsForSiblings: UserDefaults? { defaults } 178 } 179 180 /// App-icon badge ledger, persisted in the same App Group as 181 /// `NotificationState` so the Notification Service Extension can mutate it 182 /// from a separate process when an APNs alert arrives. This ledger is only the 183 /// provisional push-side input; Core Data remains the app's synced ground truth 184 /// for Moves-derived unread state. 185 /// 186 /// Each game tracks the newest push-side unread event, the newest local seen 187 /// horizon, and a suppression horizon. A game is unread from this ledger only 188 /// when `unreadAt` is newer than both, so opening a puzzle can defeat stale NSE 189 /// entries rather than fighting the old "union forever" set semantics. 190 /// 191 /// `seenAt` is a true read watermark — it never moves into the future, and it 192 /// only advances. `suppressedUntil` is the ledger's mirror of the account's 193 /// presence lease: while some device of this account is in the puzzle, pushes 194 /// arriving before the horizon are presumed watched live and don't badge. 195 /// Unlike `seenAt` it is *collapsible* — leaving the puzzle (or a sibling's 196 /// session close syncing in) pulls it back to the leave instant, so a push 197 /// that actually arrived after the user stopped looking resurrects as unread. 198 /// Folding both meanings into a forward-dated `seenAt`, as before, made the 199 /// suppression permanent: `markSeen` is monotonic, so the badge swallowed 200 /// every push for the rest of the lease window even after the user left — 201 /// the push-ledger twin of the `readAt`/`readThrough` conflation PLAN.md 202 /// describes. 203 enum BadgeState { 204 private static let ledgerKey = "badge.ledger.v2" 205 private static let legacyLedgerKey = "badge.ledger.v1" 206 private static let legacyUnreadKey = "badge.unreadGameIDs" 207 private static let pendingInvitesKey = "badge.pendingInvites.v1" 208 private static let legacyReadThroughHealV1Key = "badge.legacyReadThroughHeal.v1" 209 private static let legacyReadThroughHealKey = "badge.legacyReadThroughHeal.v2" 210 211 private struct Entry: Codable, Equatable { 212 var unreadAt: Date? = nil 213 var seenAt: Date? = nil 214 /// Optional with a default so ledgers written before the field existed 215 /// decode as nil (no suppression) rather than failing wholesale. 216 var suppressedUntil: Date? = nil 217 } 218 219 private static var defaults: UserDefaults? { 220 NotificationState.sharedDefaultsForSiblings 221 } 222 223 /// True while a sibling device of this account is present in `gameID`: the 224 /// suppression horizon (extended by `accountSeen`/the local lease via 225 /// `extendSuppression`/`adoptReadHorizon`) is still in the future. The 226 /// Notification Service Extension reads this to deliver passively while the 227 /// user is playing on another device, rather than bannering a session 228 /// they're watching live. 229 static func isSuppressed(gameID: UUID, now: Date = Date()) -> Bool { 230 guard let until = loadLedger()[gameID.uuidString]?.suppressedUntil else { return false } 231 return until > now 232 } 233 234 static func unreadGameIDs() -> Set<UUID> { 235 let ledger = loadLedger() 236 return Set(ledger.compactMap { key, entry in 237 guard let gameID = UUID(uuidString: key), 238 let unreadAt = entry.unreadAt, 239 unreadAt > (entry.seenAt ?? .distantPast), 240 unreadAt > (entry.suppressedUntil ?? .distantPast) 241 else { return nil } 242 return gameID 243 }) 244 } 245 246 /// Records a push-side unread event. Returns the resulting ledger-only 247 /// unread count so the NSE can stamp the outgoing APNs badge. 248 @discardableResult 249 static func markUnread(gameID: UUID, at time: Date = Date()) -> Int { 250 var ledger = loadLedger() 251 var entry = ledger[gameID.uuidString] ?? Entry() 252 if (entry.unreadAt ?? .distantPast) < time { 253 entry.unreadAt = time 254 } 255 ledger[gameID.uuidString] = entry 256 saveLedger(ledger) 257 return unreadGameIDs().count 258 } 259 260 /// Records that the user has seen this game on this device. Returns the 261 /// resulting ledger-only unread count. `time` must not be forward-dated: 262 /// `seenAt` is monotonic, so a future value would suppress unread events 263 /// irreversibly — that's `suppressedUntil`'s (collapsible) job. Callers 264 /// holding a horizon that may reach into the future go through 265 /// `adoptReadHorizon`, which splits it. 266 @discardableResult 267 static func markSeen(gameID: UUID, at time: Date = Date()) -> Int { 268 var ledger = loadLedger() 269 var entry = ledger[gameID.uuidString] ?? Entry() 270 if (entry.seenAt ?? .distantPast) < time { 271 entry.seenAt = time 272 } 273 ledger[gameID.uuidString] = entry 274 saveLedger(ledger) 275 return unreadGameIDs().count 276 } 277 278 /// Records an account read horizon that may be forward-dated (a presence 279 /// lease, locally minted or received from a sibling device): the watermark 280 /// advances only to `min(horizon, now)`, while the suppression horizon 281 /// takes the full value. The two halves mirror the `readThrough`/`readAt` 282 /// split on the Player record. 283 static func adoptReadHorizon(gameID: UUID, horizon: Date, now: Date = Date()) { 284 markSeen(gameID: gameID, at: min(horizon, now)) 285 extendSuppression(gameID: gameID, until: horizon) 286 } 287 288 /// Raises the suppression horizon for `gameID`, monotonically — a renewal 289 /// extends it, while a stale (older) horizon arriving late can't shorten 290 /// an active one. The deliberate pull-back on session close goes through 291 /// `collapseSuppression`. Mirrors how the presence lease itself behaves 292 /// (`GameStore.setReadCursor`'s refresh floor vs. its direct collapse). 293 static func extendSuppression(gameID: UUID, until horizon: Date) { 294 var ledger = loadLedger() 295 var entry = ledger[gameID.uuidString] ?? Entry() 296 guard (entry.suppressedUntil ?? .distantPast) < horizon else { return } 297 entry.suppressedUntil = horizon 298 ledger[gameID.uuidString] = entry 299 saveLedger(ledger) 300 } 301 302 /// Collapses the suppression horizon to `horizon` — the session-close 303 /// signal (this device leaving the puzzle, or a sibling's close syncing 304 /// in). Direct adoption, not monotonic: the whole point is to pull a 305 /// forward-dated lease back to the instant the account stopped looking, 306 /// so a push that arrived after that instant counts as unread again. A 307 /// game with no ledger entry has nothing to collapse. 308 static func collapseSuppression(gameID: UUID, to horizon: Date) { 309 var ledger = loadLedger() 310 guard var entry = ledger[gameID.uuidString], 311 entry.suppressedUntil != horizon 312 else { return } 313 entry.suppressedUntil = horizon 314 ledger[gameID.uuidString] = entry 315 saveLedger(ledger) 316 } 317 318 /// Bulk-applies push-side unread horizons in a single load/save — one 319 /// `markUnread`-equivalent per entry. The app uses this to seed Core Data 320 /// ground truth into the ledger so the NSE inherits it while the app is 321 /// suspended; horizon semantics make a re-seed of an already-seen game a 322 /// no-op (its newer `seenAt` still wins). 323 static func seedUnread(_ times: [UUID: Date]) { 324 guard !times.isEmpty else { return } 325 var ledger = loadLedger() 326 for (gameID, time) in times { 327 var entry = ledger[gameID.uuidString] ?? Entry() 328 if (entry.unreadAt ?? .distantPast) < time { 329 entry.unreadAt = time 330 } 331 ledger[gameID.uuidString] = entry 332 } 333 saveLedger(ledger) 334 } 335 336 /// Removes a game from the ledger outright. Called when a game is deleted 337 /// or hard-removed: a seen horizon can't help once the game is gone (there 338 /// is nothing left to open), so a stale `unreadAt > seenAt` entry would 339 /// otherwise count toward the badge forever. 340 static func forget(gameID: UUID) { 341 var ledger = loadLedger() 342 guard ledger.removeValue(forKey: gameID.uuidString) != nil else { return } 343 saveLedger(ledger) 344 } 345 346 /// Authoritative set of games this account has been invited to but not yet 347 /// joined. Unlike the horizon ledger, a pending invite is binary — it is 348 /// pending or it isn't — so the app overwrites this set wholesale from Core 349 /// Data ground truth on every `refreshAppBadge`. The Notification Service 350 /// Extension, which can't reach Core Data, unions this into its badge count 351 /// so a moves push landing while the app is suspended doesn't drop a still 352 /// pending invite from the total. 353 static func setPendingInvites(_ ids: Set<UUID>) { 354 guard let defaults else { return } 355 if ids.isEmpty { 356 defaults.removeObject(forKey: pendingInvitesKey) 357 return 358 } 359 defaults.set(ids.map(\.uuidString), forKey: pendingInvitesKey) 360 } 361 362 static func pendingInviteGameIDs() -> Set<UUID> { 363 guard let defaults, 364 let raw = defaults.array(forKey: pendingInvitesKey) as? [String] 365 else { return [] } 366 return Set(raw.compactMap(UUID.init(uuidString:))) 367 } 368 369 /// Returns true once per install after the read-watermark split, so the app 370 /// can backfill `Game.readThroughAt` for pre-split rows that only carried 371 /// the older `readAt`/presence cursor, and repair stale first-pass 372 /// watermarks. The NSE never calls this. 373 static func claimLegacyReadThroughHealNeeded() -> Bool { 374 guard let defaults else { return false } 375 guard defaults.bool(forKey: legacyReadThroughHealKey) == false else { return false } 376 defaults.set(true, forKey: legacyReadThroughHealKey) 377 return true 378 } 379 380 /// Clears the entire ledger (and any legacy stores). Used by the 381 /// diagnostics "reset all data" path, which deletes every game at once. 382 static func reset() { 383 guard let defaults else { return } 384 defaults.removeObject(forKey: ledgerKey) 385 defaults.removeObject(forKey: legacyLedgerKey) 386 defaults.removeObject(forKey: legacyUnreadKey) 387 defaults.removeObject(forKey: pendingInvitesKey) 388 defaults.removeObject(forKey: legacyReadThroughHealV1Key) 389 defaults.removeObject(forKey: legacyReadThroughHealKey) 390 } 391 392 private static func loadLedger() -> [String: Entry] { 393 guard let defaults else { return [:] } 394 if let data = defaults.data(forKey: ledgerKey), 395 let ledger = try? JSONDecoder().decode([String: Entry].self, from: data) { 396 return ledger 397 } 398 // Neither the pre-horizon set (`badge.unreadGameIDs`) nor the v1 ledger 399 // carried trustworthy unread timestamps we can rebuild from: the v1 400 // migration fabricated `unreadAt = now` for every legacy entry, which 401 // makes an already-seen game look freshly unread and, worse, 402 // un-clearable by read state — a future-dated `unreadAt` beats any past 403 // `seenAt`, and a deleted game has nothing left to open. Core Data is 404 // the durable ground truth for unread-other-moves, so discard both 405 // legacy stores and let `refreshAppBadge` re-seed from Core Data on the 406 // next run rather than carry phantom badges forward. 407 if defaults.object(forKey: legacyLedgerKey) != nil { 408 defaults.removeObject(forKey: legacyLedgerKey) 409 } 410 if defaults.object(forKey: legacyUnreadKey) != nil { 411 defaults.removeObject(forKey: legacyUnreadKey) 412 } 413 return [:] 414 } 415 416 private static func saveLedger(_ ledger: [String: Entry]) { 417 guard let defaults else { return } 418 if ledger.isEmpty { 419 defaults.removeObject(forKey: ledgerKey) 420 return 421 } 422 if let data = try? JSONEncoder().encode(ledger) { 423 defaults.set(data, forKey: ledgerKey) 424 } 425 } 426 } 427 428 /// Small App Group ring buffer for visible notification receipts. The 429 /// Notification Service Extension runs in a separate process, so it cannot 430 /// write to the app's in-memory diagnostics log directly; it records here and 431 /// the app drains the entries into `EventLog` when it next runs. 432 enum VisibleNotificationReceiptLog { 433 struct Entry: Codable, Sendable, Equatable { 434 let timestamp: Date 435 let source: String 436 let body: String 437 } 438 439 private static let entriesKey = "visibleNotificationReceipts.entries" 440 private static let maxEntries = 50 441 442 private static var defaults: UserDefaults? { 443 NotificationState.sharedDefaultsForSiblings 444 } 445 446 static func record(body: String, source: String, at timestamp: Date = Date()) { 447 guard let defaults else { return } 448 var entries = loadEntries(from: defaults) 449 entries.append(Entry( 450 timestamp: timestamp, 451 source: source, 452 body: body 453 )) 454 if entries.count > maxEntries { 455 entries.removeFirst(entries.count - maxEntries) 456 } 457 save(entries, to: defaults) 458 } 459 460 static func drain() -> [Entry] { 461 guard let defaults else { return [] } 462 let entries = loadEntries(from: defaults) 463 defaults.removeObject(forKey: entriesKey) 464 return entries 465 } 466 467 static func message(for entry: Entry) -> String { 468 let escapedBody = entry.body 469 .replacingOccurrences(of: "\n", with: " ") 470 .trimmingCharacters(in: .whitespacesAndNewlines) 471 return "visible notification receipt imported: utc=\(utcString(entry.timestamp)) source=\(entry.source) body=\"\(escapedBody)\"" 472 } 473 474 private static func loadEntries(from defaults: UserDefaults) -> [Entry] { 475 guard let data = defaults.data(forKey: entriesKey), 476 let entries = try? JSONDecoder().decode([Entry].self, from: data) 477 else { return [] } 478 return entries 479 } 480 481 private static func save(_ entries: [Entry], to defaults: UserDefaults) { 482 guard let data = try? JSONEncoder().encode(entries) else { return } 483 defaults.set(data, forKey: entriesKey) 484 } 485 486 private static func utcString(_ date: Date) -> String { 487 let formatter = ISO8601DateFormatter() 488 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 489 formatter.timeZone = TimeZone(secondsFromGMT: 0) 490 return formatter.string(from: date) 491 } 492 }