crossmate

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

commit 334341f9e9786f7462792098d7687d75b625a35c
parent 1bb923cb407c5683e9591bb24a16a5789cfe30e1
Author: Michael Camilleri <[email protected]>
Date:   Thu, 28 May 2026 13:27:40 +0900

Navigate to game on push notification tap

Tapping a push notification was supposed to open the game it referred to but
this didn't work like it did for local notifications. The reason was that push
notifications stamped the game UUID under a different userInfo key — it was
'crossmateGameID' for local notifications, but 'gameID' for push notifications
(per PushClient.publish).

This commit standardises on 'gameID' across both channels. The push
notification worker stays as-is; presentLocalNotification and
dismissDeliveredNotifications now stamp / match the same unprefixed key, and
the extractor reads one key (the CloudKit silent-push ck.qry.zid fallback is
retained). The 'crossmate' prefix is also dropped from 'crossmatePingKind'.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 14+++++---------
MCrossmate/Services/AppServices.swift | 6+++---
MShared/NotificationState.swift | 90+------------------------------------------------------------------------------
MTests/Unit/NotificationStateTests.swift | 34----------------------------------
4 files changed, 9 insertions(+), 135 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -108,8 +108,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser /// Foreground notification arrival. If the user is currently viewing the /// puzzle the ping refers to, hide it entirely (`[]`); otherwise show it - /// as a banner with sound. In both cases the dedup map is updated so a - /// rapid follow-up doesn't refire. + /// as a banner with sound. func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -120,10 +119,6 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser completionHandler([.banner, .sound]) return } - let isSessionBegin = (userInfo["crossmateActivity"] as? String) == "session-begin" - if isSessionBegin, let authorID = userInfo["crossmateAuthorID"] as? String { - NotificationState.recordShown(gameID: gameID, authorID: authorID) - } if NotificationState.isSuppressed(gameID: gameID) { completionHandler([]) } else { @@ -143,7 +138,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser } let fromInvitePing = - (userInfo["crossmatePingKind"] as? String) == PingKind.invite.rawValue + (userInfo["pingKind"] as? String) == PingKind.invite.rawValue Task { @MainActor in NotificationNavigationBroker.shared.openGame( @@ -155,8 +150,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser } private static func gameID(from userInfo: [AnyHashable: Any]) -> UUID? { - if let id = userInfo["crossmateGameID"] as? String { - return UUID(uuidString: id) + if let id = userInfo["gameID"] as? String, + let uuid = UUID(uuidString: id) { + return uuid } guard let ck = userInfo["ck"] as? [AnyHashable: Any], let qry = ck["qry"] as? [AnyHashable: Any], diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -2007,8 +2007,8 @@ final class AppServices { content.body = Self.bodyText(for: ping) content.sound = .default content.userInfo = [ - "crossmateGameID": ping.gameID.uuidString, - "crossmatePingKind": ping.kind.rawValue + "gameID": ping.gameID.uuidString, + "pingKind": ping.kind.rawValue ] let request = UNNotificationRequest( @@ -2197,7 +2197,7 @@ final class AppServices { let delivered = await center.deliveredNotifications() let identifiers = delivered.compactMap { notification -> String? in let userInfo = notification.request.content.userInfo - guard let raw = userInfo["crossmateGameID"] as? String, + guard let raw = userInfo["gameID"] as? String, raw == gameID.uuidString else { return nil } return notification.request.identifier diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -5,10 +5,6 @@ import Foundation /// State tracked: /// - `activePuzzleID` (+ local leave-grace) — this device is viewing a puzzle, /// so notifications and the unseen-moves badge for it are skipped. -/// - `shownByGame` — per-`(gameID, authorID)` timestamps used to debounce -/// inferred session notifications. Once activity for game X from author Y -/// has been shown, further session notifications for that pair within -/// `dedupWindow` are suppressed. /// /// `isSuppressed(gameID:)` is the unified gate; presently it is just the /// local-active check, kept under that name so callers don't need to know @@ -16,14 +12,7 @@ import Foundation enum NotificationState { static let appGroup = "group.net.inqk.crossmate" - /// How long after a shown session notification subsequent session - /// notifications for the same game are suppressed. Explicit - /// join/win/check/reveal pings bypass this game-level dedup. - static let dedupWindow: TimeInterval = 20 * 60 - private static let activeKey = "notif.activePuzzleID" - private static let shownPrefix = "notif.shownByGame." - private static let legacyShownKey = "notif.shownByGame" private static let localActiveUntilKey = "notif.localActiveUntil" /// Grace window after the user leaves a puzzle during which the game is @@ -88,85 +77,8 @@ enum NotificationState { return false } - /// True if a session-begin notification for `(gameID, authorID)` was - /// shown within `dedupWindow`. The key is per-author so a second - /// collaborator starting a session in the same window can still notify. - static func wasRecentlyShown(gameID: UUID, authorID: String, now: Date = Date()) -> Bool { - let key = shownEntryKey(gameID: gameID, authorID: authorID) - let last = defaults?.object(forKey: key) as? TimeInterval - ?? shownMap()[Self.compositeKey(gameID: gameID, authorID: authorID)] - guard let last else { return false } - return now.timeIntervalSince1970 - last < dedupWindow - } - - /// Records that a notification for `(gameID, authorID)` was surfaced (or - /// would have been, before suppression decisions). Trims old entries so - /// stored state stays small. - static func recordShown(gameID: UUID, authorID: String, now: Date = Date()) { - guard let defaults else { return } - defaults.set( - now.timeIntervalSince1970, - forKey: shownEntryKey(gameID: gameID, authorID: authorID) - ) - pruneShownEntries(now: now) - } - - /// Drops the session-begin dedup entry for `(gameID, authorID)`. - static func clearShown(gameID: UUID, authorID: String) { - guard let defaults else { return } - defaults.removeObject(forKey: shownEntryKey(gameID: gameID, authorID: authorID)) - var map = shownMap() - if map.removeValue(forKey: Self.compositeKey(gameID: gameID, authorID: authorID)) != nil { - defaults.set(map, forKey: legacyShownKey) - } - } - - private static func compositeKey(gameID: UUID, authorID: String) -> String { - "\(gameID.uuidString)|\(authorID)" - } - - private static func shownMap() -> [String: TimeInterval] { - defaults?.dictionary(forKey: legacyShownKey) as? [String: TimeInterval] ?? [:] - } - - private static func shownEntryKey(gameID: UUID, authorID: String) -> String { - "\(shownPrefix)\(gameID.uuidString).\(encodedKeyComponent(authorID))" - } - - private static let keyComponentAllowedCharacters: CharacterSet = { - var allowed = CharacterSet.alphanumerics - allowed.insert(charactersIn: "-_") - return allowed - }() - - private static func encodedKeyComponent(_ value: String) -> String { - value.addingPercentEncoding(withAllowedCharacters: keyComponentAllowedCharacters) ?? "" - } - - private static func pruneShownEntries(now: Date) { - guard let defaults else { return } - let cutoff = now.timeIntervalSince1970 - 2 * dedupWindow - for (key, value) in defaults.dictionaryRepresentation() - where key.hasPrefix(shownPrefix) { - guard let timestamp = value as? TimeInterval else { continue } - if timestamp < cutoff { - defaults.removeObject(forKey: key) - } - } - - var map = shownMap() - guard !map.isEmpty else { return } - map = map.filter { $0.value >= cutoff } - if map.isEmpty { - defaults.removeObject(forKey: legacyShownKey) - } else { - defaults.set(map, forKey: legacyShownKey) - } - } - /// Stamps `id` as locally active until `until`, evicting entries that - /// have already expired so the map stays small. Mirrors `shownMap`'s - /// App-Group dictionary idiom. + /// have already expired so the map stays small. private static func stampLocalActive(_ id: UUID, until: Date, now: Date) { guard let defaults else { return } var map = localActiveMap() diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift @@ -43,40 +43,6 @@ struct NotificationStateTests { NotificationState.setActivePuzzleID(nil) } - @Test("Per-author dedup: two authors in the same game don't collide") - func dedupIsPerAuthor() { - let gameID = UUID() - let now = Date(timeIntervalSince1970: 8_000_000) - - NotificationState.recordShown(gameID: gameID, authorID: "alice", now: now) - // Alice is gated within the dedup window; Bob is not. - #expect(NotificationState.wasRecentlyShown(gameID: gameID, authorID: "alice", now: now)) - #expect(!NotificationState.wasRecentlyShown(gameID: gameID, authorID: "bob", now: now)) - - // After Bob also shows, both are gated; clearing Alice doesn't - // unblock Bob. - NotificationState.recordShown(gameID: gameID, authorID: "bob", now: now) - NotificationState.clearShown(gameID: gameID, authorID: "alice") - #expect(!NotificationState.wasRecentlyShown(gameID: gameID, authorID: "alice", now: now)) - #expect(NotificationState.wasRecentlyShown(gameID: gameID, authorID: "bob", now: now)) - - NotificationState.clearShown(gameID: gameID, authorID: "bob") - } - - @Test("Dedup window expires after dedupWindow seconds") - func dedupWindowExpires() { - let gameID = UUID() - let now = Date(timeIntervalSince1970: 9_000_000) - NotificationState.recordShown(gameID: gameID, authorID: "alice", now: now) - #expect(NotificationState.wasRecentlyShown(gameID: gameID, authorID: "alice", now: now)) - #expect(!NotificationState.wasRecentlyShown( - gameID: gameID, - authorID: "alice", - now: now.addingTimeInterval(NotificationState.dedupWindow) - )) - NotificationState.clearShown(gameID: gameID, authorID: "alice") - } - @Test("isSuppressed tracks the local active puzzle (and its grace tail)") func suppressedTracksLocalActive() { let gameID = UUID()