crossmate

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

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 }