NotificationState.swift (17274B)
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.presenceUntil`. 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 /// Exposes the (testing-aware) App Group defaults to siblings in this 105 /// module — currently `BadgeState`, which shares storage but lives in 106 /// its own namespace. 107 static var sharedDefaultsForSiblings: UserDefaults? { defaults } 108 } 109 110 /// App-icon badge ledger, persisted in the same App Group as 111 /// `NotificationState` so the Notification Service Extension can mutate it 112 /// from a separate process when an APNs alert arrives. This ledger is only the 113 /// provisional push-side input; Core Data remains the app's synced ground truth 114 /// for Moves-derived unread state. 115 /// 116 /// Each game tracks the newest push-side unread event, the newest local seen 117 /// horizon, and a suppression horizon. A game is unread from this ledger only 118 /// when `unreadAt` is newer than both, so opening a puzzle can defeat stale NSE 119 /// entries rather than fighting the old "union forever" set semantics. 120 /// 121 /// `seenAt` is a true read watermark — it never moves into the future, and it 122 /// only advances. `suppressedUntil` is the ledger's mirror of the account's 123 /// presence lease: while some device of this account is in the puzzle, pushes 124 /// arriving before the horizon are presumed watched live and don't badge. 125 /// Unlike `seenAt` it is *collapsible* — leaving the puzzle (or a sibling's 126 /// session close syncing in) pulls it back to the leave instant, so a push 127 /// that actually arrived after the user stopped looking resurrects as unread. 128 /// Folding both meanings into a forward-dated `seenAt`, as before, made the 129 /// suppression permanent: `markSeen` is monotonic, so the badge swallowed 130 /// every push for the rest of the lease window even after the user left — 131 /// the push-ledger twin of the `presenceUntil`/`readThrough` conflation PLAN.md 132 /// describes. 133 enum BadgeState { 134 private static let ledgerKey = "badge.ledger.v2" 135 private static let pendingInvitesKey = "badge.pendingInvites.v1" 136 137 private struct Entry: Codable, Equatable { 138 var unreadAt: Date? = nil 139 var seenAt: Date? = nil 140 /// Optional with a default so ledgers written before the field existed 141 /// decode as nil (no suppression) rather than failing wholesale. 142 var suppressedUntil: Date? = nil 143 } 144 145 private static var defaults: UserDefaults? { 146 NotificationState.sharedDefaultsForSiblings 147 } 148 149 /// True while a sibling device of this account is present in `gameID`: the 150 /// suppression horizon (extended by `accountSeen`/the local lease via 151 /// `extendSuppression`/`adoptReadHorizon`) is still in the future. The 152 /// Notification Service Extension reads this to deliver passively while the 153 /// user is playing on another device, rather than bannering a session 154 /// they're watching live. 155 static func isSuppressed(gameID: UUID, now: Date = Date()) -> Bool { 156 guard let until = loadLedger()[gameID.uuidString]?.suppressedUntil else { return false } 157 return until > now 158 } 159 160 static func unreadGameIDs() -> Set<UUID> { 161 let ledger = loadLedger() 162 return Set(ledger.compactMap { key, entry in 163 guard let gameID = UUID(uuidString: key), 164 let unreadAt = entry.unreadAt, 165 unreadAt > (entry.seenAt ?? .distantPast), 166 unreadAt > (entry.suppressedUntil ?? .distantPast) 167 else { return nil } 168 return gameID 169 }) 170 } 171 172 /// Records a push-side unread event. Returns the resulting ledger-only 173 /// unread count so the NSE can stamp the outgoing APNs badge. 174 @discardableResult 175 static func markUnread(gameID: UUID, at time: Date = Date()) -> Int { 176 var ledger = loadLedger() 177 var entry = ledger[gameID.uuidString] ?? Entry() 178 if (entry.unreadAt ?? .distantPast) < time { 179 entry.unreadAt = time 180 } 181 ledger[gameID.uuidString] = entry 182 saveLedger(ledger) 183 return unreadGameIDs().count 184 } 185 186 /// Records that the user has seen this game on this device. Returns the 187 /// resulting ledger-only unread count. `time` must not be forward-dated: 188 /// `seenAt` is monotonic, so a future value would suppress unread events 189 /// irreversibly — that's `suppressedUntil`'s (collapsible) job. Callers 190 /// holding a horizon that may reach into the future go through 191 /// `adoptReadHorizon`, which splits it. 192 @discardableResult 193 static func markSeen(gameID: UUID, at time: Date = Date()) -> Int { 194 var ledger = loadLedger() 195 var entry = ledger[gameID.uuidString] ?? Entry() 196 if (entry.seenAt ?? .distantPast) < time { 197 entry.seenAt = time 198 } 199 ledger[gameID.uuidString] = entry 200 saveLedger(ledger) 201 return unreadGameIDs().count 202 } 203 204 /// Records an account read horizon that may be forward-dated (a presence 205 /// lease, locally minted or received from a sibling device): the watermark 206 /// advances only to `min(horizon, now)`, while the suppression horizon 207 /// takes the full value. The two halves mirror the `readThrough`/`presenceUntil` 208 /// split on the Player record. 209 static func adoptReadHorizon(gameID: UUID, horizon: Date, now: Date = Date()) { 210 markSeen(gameID: gameID, at: min(horizon, now)) 211 extendSuppression(gameID: gameID, until: horizon) 212 } 213 214 /// Raises the suppression horizon for `gameID`, monotonically — a renewal 215 /// extends it, while a stale (older) horizon arriving late can't shorten 216 /// an active one. The deliberate pull-back on session close goes through 217 /// `collapseSuppression`. Mirrors how the presence lease itself behaves 218 /// (`GameStore.setReadCursor`'s refresh floor vs. its direct collapse). 219 static func extendSuppression(gameID: UUID, until horizon: Date) { 220 var ledger = loadLedger() 221 var entry = ledger[gameID.uuidString] ?? Entry() 222 guard (entry.suppressedUntil ?? .distantPast) < horizon else { return } 223 entry.suppressedUntil = horizon 224 ledger[gameID.uuidString] = entry 225 saveLedger(ledger) 226 } 227 228 /// Collapses the suppression horizon to `horizon` — the session-close 229 /// signal (this device leaving the puzzle, or a sibling's close syncing 230 /// in). Direct adoption, not monotonic: the whole point is to pull a 231 /// forward-dated lease back to the instant the account stopped looking, 232 /// so a push that arrived after that instant counts as unread again. A 233 /// game with no ledger entry has nothing to collapse. 234 static func collapseSuppression(gameID: UUID, to horizon: Date) { 235 var ledger = loadLedger() 236 guard var entry = ledger[gameID.uuidString], 237 entry.suppressedUntil != horizon 238 else { return } 239 entry.suppressedUntil = horizon 240 ledger[gameID.uuidString] = entry 241 saveLedger(ledger) 242 } 243 244 /// Bulk-applies push-side unread horizons in a single load/save — one 245 /// `markUnread`-equivalent per entry. The app uses this to seed Core Data 246 /// ground truth into the ledger so the NSE inherits it while the app is 247 /// suspended; horizon semantics make a re-seed of an already-seen game a 248 /// no-op (its newer `seenAt` still wins). 249 static func seedUnread(_ times: [UUID: Date]) { 250 guard !times.isEmpty else { return } 251 var ledger = loadLedger() 252 for (gameID, time) in times { 253 var entry = ledger[gameID.uuidString] ?? Entry() 254 if (entry.unreadAt ?? .distantPast) < time { 255 entry.unreadAt = time 256 } 257 ledger[gameID.uuidString] = entry 258 } 259 saveLedger(ledger) 260 } 261 262 /// Removes a game from the ledger outright. Called when a game is deleted 263 /// or hard-removed: a seen horizon can't help once the game is gone (there 264 /// is nothing left to open), so a stale `unreadAt > seenAt` entry would 265 /// otherwise count toward the badge forever. 266 static func forget(gameID: UUID) { 267 var ledger = loadLedger() 268 guard ledger.removeValue(forKey: gameID.uuidString) != nil else { return } 269 saveLedger(ledger) 270 } 271 272 /// Authoritative set of games this account has been invited to but not yet 273 /// joined. Unlike the horizon ledger, a pending invite is binary — it is 274 /// pending or it isn't — so the app overwrites this set wholesale from Core 275 /// Data ground truth on every `refreshAppBadge`. The Notification Service 276 /// Extension, which can't reach Core Data, unions this into its badge count 277 /// so a moves push landing while the app is suspended doesn't drop a still 278 /// pending invite from the total. 279 static func setPendingInvites(_ ids: Set<UUID>) { 280 guard let defaults else { return } 281 if ids.isEmpty { 282 defaults.removeObject(forKey: pendingInvitesKey) 283 return 284 } 285 defaults.set(ids.map(\.uuidString), forKey: pendingInvitesKey) 286 } 287 288 static func pendingInviteGameIDs() -> Set<UUID> { 289 guard let defaults, 290 let raw = defaults.array(forKey: pendingInvitesKey) as? [String] 291 else { return [] } 292 return Set(raw.compactMap(UUID.init(uuidString:))) 293 } 294 295 /// Clears the entire ledger. Used by the diagnostics "reset all data" 296 /// path, which deletes every game at once. 297 static func reset() { 298 guard let defaults else { return } 299 defaults.removeObject(forKey: ledgerKey) 300 defaults.removeObject(forKey: pendingInvitesKey) 301 } 302 303 private static func loadLedger() -> [String: Entry] { 304 guard let defaults else { return [:] } 305 guard let data = defaults.data(forKey: ledgerKey), 306 let ledger = try? JSONDecoder().decode([String: Entry].self, from: data) 307 else { return [:] } 308 return ledger 309 } 310 311 private static func saveLedger(_ ledger: [String: Entry]) { 312 guard let defaults else { return } 313 if ledger.isEmpty { 314 defaults.removeObject(forKey: ledgerKey) 315 return 316 } 317 if let data = try? JSONEncoder().encode(ledger) { 318 defaults.set(data, forKey: ledgerKey) 319 } 320 } 321 } 322 323 /// Small App Group ring buffer for visible notification receipts. The 324 /// Notification Service Extension runs in a separate process, so it cannot 325 /// write to the app's in-memory diagnostics log directly; it records here and 326 /// the app drains the entries into `EventLog` when it next runs. 327 enum VisibleNotificationReceiptLog { 328 struct Entry: Codable, Sendable, Equatable { 329 let timestamp: Date 330 let source: String 331 let body: String 332 } 333 334 private static let entriesKey = "visibleNotificationReceipts.entries" 335 private static let maxEntries = 50 336 337 private static var defaults: UserDefaults? { 338 NotificationState.sharedDefaultsForSiblings 339 } 340 341 static func record(body: String, source: String, at timestamp: Date = Date()) { 342 guard let defaults else { return } 343 var entries = loadEntries(from: defaults) 344 entries.append(Entry( 345 timestamp: timestamp, 346 source: source, 347 body: body 348 )) 349 if entries.count > maxEntries { 350 entries.removeFirst(entries.count - maxEntries) 351 } 352 save(entries, to: defaults) 353 } 354 355 static func drain() -> [Entry] { 356 guard let defaults else { return [] } 357 let entries = loadEntries(from: defaults) 358 defaults.removeObject(forKey: entriesKey) 359 return entries 360 } 361 362 static func message(for entry: Entry) -> String { 363 let escapedBody = entry.body 364 .replacingOccurrences(of: "\n", with: " ") 365 .trimmingCharacters(in: .whitespacesAndNewlines) 366 return "visible notification receipt imported: utc=\(utcString(entry.timestamp)) source=\(entry.source) body=\"\(escapedBody)\"" 367 } 368 369 private static func loadEntries(from defaults: UserDefaults) -> [Entry] { 370 guard let data = defaults.data(forKey: entriesKey), 371 let entries = try? JSONDecoder().decode([Entry].self, from: data) 372 else { return [] } 373 return entries 374 } 375 376 private static func save(_ entries: [Entry], to defaults: UserDefaults) { 377 guard let data = try? JSONEncoder().encode(entries) else { return } 378 defaults.set(data, forKey: entriesKey) 379 } 380 381 private static func utcString(_ date: Date) -> String { 382 let formatter = ISO8601DateFormatter() 383 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 384 formatter.timeZone = TimeZone(secondsFromGMT: 0) 385 return formatter.string(from: date) 386 } 387 }