crossmate

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

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 }