crossmate

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

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 }