NotificationState.swift (4649B)
1 import Foundation 2 3 /// Notification suppression state persisted via App Group UserDefaults. 4 /// 5 /// Two pieces of state are tracked: 6 /// - `activePuzzleID` — set by the app while the user is viewing a puzzle in 7 /// the foreground so local notifications for that same puzzle can be skipped. 8 /// - `shownByGame` — a `[gameID: Date]` map used to debounce repeat 9 /// notifications. Once a session ping for game X has been shown, further 10 /// session pings for X within `dedupWindow` are suppressed. 11 enum NotificationState { 12 static let appGroup = "group.net.inqk.crossmate" 13 14 /// How long after a shown session ping subsequent session pings for the 15 /// same game are suppressed. Matches the sender-side quiet threshold so 16 /// the sender's gating dominates and the receiver only acts as a guard 17 /// against multi-device, app-restart, and initial-sync-flood duplicates. 18 /// Only applies to session pings; join/win/check/reveal bypass dedup. 19 static let dedupWindow: TimeInterval = 20 * 60 20 21 private static let activeKey = "notif.activePuzzleID" 22 private static let shownKey = "notif.shownByGame" 23 private static let shownPingNamesKey = "notif.shownPingNames" 24 25 /// Maximum number of recently-presented ping record names retained for 26 /// dedup. FIFO; older entries are evicted as new ones come in. 200 covers 27 /// the worst-case overlap between the push fast path and the eventual 28 /// CKSyncEngine catch-up many times over. 29 static let shownPingNamesCap = 200 30 31 private static var defaults: UserDefaults? { 32 UserDefaults(suiteName: appGroup) 33 } 34 35 static func activePuzzleID() -> UUID? { 36 guard let s = defaults?.string(forKey: activeKey) else { return nil } 37 return UUID(uuidString: s) 38 } 39 40 static func setActivePuzzleID(_ id: UUID?) { 41 guard let defaults else { return } 42 if let id { 43 defaults.set(id.uuidString, forKey: activeKey) 44 } else { 45 defaults.removeObject(forKey: activeKey) 46 } 47 } 48 49 static func clearActivePuzzleID(if id: UUID) { 50 guard activePuzzleID() == id else { return } 51 setActivePuzzleID(nil) 52 } 53 54 /// True if the user is currently viewing the puzzle for `gameID`. Active- 55 /// puzzle suppression applies to all ping kinds — no notifications fire 56 /// while you're already in the puzzle they describe. 57 static func isActive(gameID: UUID) -> Bool { 58 activePuzzleID() == gameID 59 } 60 61 /// True if a session ping for `gameID` was shown within `dedupWindow`. 62 /// Only consulted for `.session` kinds; join/win/check/reveal bypass. 63 static func wasRecentlyShown(gameID: UUID, now: Date = Date()) -> Bool { 64 let map = shownMap() 65 guard let last = map[gameID.uuidString] else { return false } 66 return now.timeIntervalSince1970 - last < dedupWindow 67 } 68 69 /// Records that a notification for `gameID` was surfaced (or would have 70 /// been, before suppression decisions). Trims old entries so the map 71 /// stays small. 72 static func recordShown(gameID: UUID, now: Date = Date()) { 73 guard let defaults else { return } 74 var map = shownMap() 75 map[gameID.uuidString] = now.timeIntervalSince1970 76 let cutoff = now.timeIntervalSince1970 - 2 * dedupWindow 77 map = map.filter { $0.value >= cutoff } 78 defaults.set(map, forKey: shownKey) 79 } 80 81 private static func shownMap() -> [String: TimeInterval] { 82 defaults?.dictionary(forKey: shownKey) as? [String: TimeInterval] ?? [:] 83 } 84 85 /// True if a notification for this specific Ping record name has already 86 /// been presented. Used to keep the push fast path and the eventual 87 /// CKSyncEngine catch-up from double-notifying for the same ping. 88 static func wasShown(pingRecordName name: String) -> Bool { 89 shownPingNames().contains(name) 90 } 91 92 /// Records that a notification for this Ping record name was presented. 93 /// Maintains FIFO order; evicts the oldest entries once `shownPingNamesCap` 94 /// is exceeded. 95 static func recordShown(pingRecordName name: String) { 96 guard let defaults else { return } 97 var names = shownPingNames() 98 if let existing = names.firstIndex(of: name) { 99 names.remove(at: existing) 100 } 101 names.append(name) 102 if names.count > shownPingNamesCap { 103 names.removeFirst(names.count - shownPingNamesCap) 104 } 105 defaults.set(names, forKey: shownPingNamesKey) 106 } 107 108 private static func shownPingNames() -> [String] { 109 defaults?.stringArray(forKey: shownPingNamesKey) ?? [] 110 } 111 }