crossmate

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

commit 11d83003757e99a5939c0875e7dcd9396c13154d
parent cae1b44919c366db6a3cf9e9336f35431aa14909
Author: Michael Camilleri <[email protected]>
Date:   Thu, 14 May 2026 06:49:30 +0900

Infer session notifications from Player records

Crossmate previously used session pings as a means of notifying other players
that play session had begun. The sender wrote a transient Ping record on the
first move, and the receiver depended on a silent push plus the ping fast-path
finding that record before it was stale or superseded. In practice the wake
often comes from nearby Player record activity, CloudKit can coalesce or delay
the later Ping/Moves writes and a delayed session ping can arrive next to the
win ping that makes it nonsensical.

Session notifications are now receiver-inferred from recent Player records
instead of sender-authored Ping records. On a background silent-push wake, the
app scans incomplete games in the relevant database scope for recent Player and
Ping records. Explicit join/win/check/reveal pings still go through the
existing ping presentation path; recent remote Player records become inferred
Session notifications after filtering out the local author, active puzzles,
recently shown sessions and sessions superseded by a win ping for the same
game and author.

MovesUpdater no longer owns session-ping timing, and PingKind no longer has a
session case. The session wording and dedupe state remain: the notification is
still "Alice is solving ...", foreground presentation still records session
dedupe via the crossmateActivity=session payload and diagnostics now log
self-authored pings instead of silently skipping them.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 9++++-----
MCrossmate/Persistence/GameMutator.swift | 3+--
MCrossmate/Services/AppServices.swift | 150++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
MCrossmate/Services/DebuggingMonitors.swift | 12++++++++++++
MCrossmate/Sync/MovesUpdater.swift | 40+++-------------------------------------
MCrossmate/Sync/RecordSerializer.swift | 2+-
MCrossmate/Sync/SyncEngine.swift | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MShared/NotificationState.swift | 18++++++++----------
MTests/Unit/PuzzleNotificationTextTests.swift | 14++++++++++++++
9 files changed, 322 insertions(+), 99 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -33,7 +33,7 @@ struct CrossmateApp: App { // MARK: - App Delegate final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, @unchecked Sendable { - var onRemoteNotification: ((String, CKDatabase.Scope?) async -> Void)? + var onRemoteNotification: ((String, CKDatabase.Scope?, Bool) async -> Void)? /// Reports the outcome of `registerForRemoteNotifications`. Surfaced in /// the diagnostics log so a missing APNs token (e.g. an aps-environment /// mismatch between the entitlements and the TestFlight distribution @@ -84,7 +84,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser completionHandler([.banner, .sound]) return } - let isSession = (userInfo["crossmatePingKind"] as? String) == "session" + let isSession = (userInfo["crossmateActivity"] as? String) == "session" if isSession { NotificationState.recordShown(gameID: gameID) } @@ -140,7 +140,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser ) async -> UIBackgroundFetchResult { let summary = AppServices.describePush(userInfo: userInfo) let scope = AppServices.databaseScope(fromPush: userInfo) - await onRemoteNotification?(summary, scope) + let isBackground = application.applicationState != .active + await onRemoteNotification?(summary, scope, isBackground) return .newData } @@ -397,11 +398,9 @@ private struct PuzzleDisplayView: View { NotificationState.clearActivePuzzleID(if: gameID) let selectionPublisher = services.playerSelectionPublisher let movesUpdater = services.movesUpdater - let exitedID = gameID Task { await movesUpdater.flush() await selectionPublisher.clear() - await movesUpdater.noteSessionEnded(gameID: exitedID) } } } diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift @@ -100,8 +100,7 @@ final class GameMutator { let letter = square.entry // The cell's `letterAuthorID` is the canonical author for the square — // it may differ from the acting user when a same-letter write or a - // reveal-of-correct preserved the original author. The acting user is - // still passed separately so MovesUpdater can fire session pings. + // reveal-of-correct preserved the original author. let cellAuthorID = square.letterAuthorID let actingAuthorID = authorIDProvider?() Task { diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -53,17 +53,6 @@ final class AppServices { let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } guard isEnabled else { return } await syncEngine.enqueueMoves(gameIDs: gameIDs) - }, - sessionPingSink: { [preferences] gameID, authorID in - guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } - let name = await MainActor.run { preferences.name } - await syncEngine.enqueuePing( - kind: .session, - scope: nil, - gameID: gameID, - authorID: authorID, - playerName: name - ) } ) self.movesUpdater = movesUpdater @@ -131,8 +120,12 @@ final class AppServices { nytAuth.loadStoredSession() driveMonitor.start() - appDelegate.onRemoteNotification = { summary, scope in - await self.handleRemoteNotification(summary: summary, scope: scope) + appDelegate.onRemoteNotification = { summary, scope, isBackground in + await self.handleRemoteNotification( + summary: summary, + scope: scope, + isBackground: isBackground + ) } appDelegate.onAPNsRegistrationResult = { [syncMonitor] message in syncMonitor.note(message) @@ -309,7 +302,11 @@ final class AppServices { ) } - private func handleRemoteNotification(summary: String, scope: CKDatabase.Scope?) async { + private func handleRemoteNotification( + summary: String, + scope: CKDatabase.Scope?, + isBackground: Bool + ) async { guard preferences.isICloudSyncEnabled else { syncMonitor.note("remote notification ignored while iCloud sync is disabled") return @@ -325,6 +322,21 @@ final class AppServices { return } + if isBackground { + let result = await syncMonitor.run("remote-notification background session scan") { + try await syncEngine.fetchBackgroundSessionsDirect(scope: scope) + } + if let result { + await presentPings(result.0) + await presentSessions( + result.1, + supersedingPings: result.0 + ) + } + await refreshSnapshot() + return + } + if let activeGameID = activeGameID(in: scope) { // Hot path: collaborator activity on the open puzzle. The active // game's zone is already known, so we skip the zone-discovery @@ -398,22 +410,18 @@ final class AppServices { } private func presentPings(_ pings: [Ping]) async { - let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() - let canNotify: Bool - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - canNotify = true - default: - canNotify = false - } - guard canNotify else { - syncMonitor.note("session-ping: local notification skipped — authorization not granted") + guard !pings.isEmpty else { return } + guard await canPresentNotifications() else { + syncMonitor.note("ping: local notification skipped — authorization not granted") return } + let center = UNUserNotificationCenter.current() for ping in pings { - if ping.authorID == identity.currentID { continue } + if ping.authorID == identity.currentID { + syncMonitor.note("ping(\(ping.kind.rawValue)): skipped self-authored record \(ping.recordName)") + continue + } // The push fast path and the CKSyncEngine catch-up both surface // the same Ping records, so dedup by record name. We do this // check first and short-circuit; every other path below ends by @@ -425,18 +433,9 @@ final class AppServices { defer { NotificationState.recordShown(pingRecordName: ping.recordName) } if NotificationState.isActive(gameID: ping.gameID) { - if ping.kind == .session { - NotificationState.recordShown(gameID: ping.gameID) - } syncMonitor.note("ping(\(ping.kind.rawValue)): suppressed — puzzle is active for \(ping.gameID.uuidString)") continue } - if ping.kind == .session, - NotificationState.wasRecentlyShown(gameID: ping.gameID) { - NotificationState.recordShown(gameID: ping.gameID) - syncMonitor.note("ping(session): dedup-suppressed for \(ping.gameID.uuidString)") - continue - } let content = UNMutableNotificationContent() content.title = "Crossmate" @@ -454,9 +453,6 @@ final class AppServices { ) do { try await center.add(request) - if ping.kind == .session { - NotificationState.recordShown(gameID: ping.gameID) - } syncMonitor.note("ping(\(ping.kind.rawValue)): queued local notification for \(ping.gameID.uuidString)") } catch { syncMonitor.note("ping(\(ping.kind.rawValue)): local notification failed — \(error.localizedDescription)") @@ -464,12 +460,80 @@ final class AppServices { } } + private func presentSessions( + _ sessions: [Session], + supersedingPings: [Ping] + ) async { + guard !sessions.isEmpty else { return } + guard await canPresentNotifications() else { + syncMonitor.note("session: local notification skipped — authorization not granted") + return + } + + let completedByAuthor = Set(supersedingPings.compactMap { ping -> String? in + guard ping.kind == .win else { return nil } + return "\(ping.gameID.uuidString)|\(ping.authorID)" + }) + let center = UNUserNotificationCenter.current() + for session in sessions { + if session.authorID == identity.currentID { + syncMonitor.note("session: skipped self-authored record \(session.recordName)") + continue + } + if completedByAuthor.contains("\(session.gameID.uuidString)|\(session.authorID)") { + syncMonitor.note("session: suppressed by win ping for \(session.gameID.uuidString)") + continue + } + if NotificationState.isActive(gameID: session.gameID) { + NotificationState.recordShown(gameID: session.gameID) + syncMonitor.note("session: suppressed — puzzle is active for \(session.gameID.uuidString)") + continue + } + if NotificationState.wasRecentlyShown(gameID: session.gameID) { + NotificationState.recordShown(gameID: session.gameID) + syncMonitor.note("session: dedup-suppressed for \(session.gameID.uuidString)") + continue + } + + let content = UNMutableNotificationContent() + content.title = "Crossmate" + content.body = Self.bodyText(for: session) + content.sound = .default + content.userInfo = [ + "crossmateGameID": session.gameID.uuidString, + "crossmateActivity": "session" + ] + + let request = UNNotificationRequest( + identifier: "session-\(session.gameID.uuidString)-\(UUID().uuidString)", + content: content, + trigger: nil + ) + do { + try await center.add(request) + NotificationState.recordShown(gameID: session.gameID) + syncMonitor.note("session: queued local notification for \(session.gameID.uuidString)") + } catch { + syncMonitor.note("session: local notification failed — \(error.localizedDescription)") + } + } + } + + private func canPresentNotifications() async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + default: + return false + } + } + nonisolated static func bodyText(for ping: Ping) -> String { let player = ping.playerName.isEmpty ? "A player" : ping.playerName let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'" switch ping.kind { - case .session: - return "\(player) is solving \(puzzleSuffix)" case .join: return "\(player) joined \(puzzleSuffix)" case .win: @@ -490,6 +554,12 @@ final class AppServices { } } + nonisolated static func bodyText(for session: Session) -> String { + let player = session.playerName.isEmpty ? "A player" : session.playerName + let puzzleSuffix = session.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(session.puzzleTitle)'" + return "\(player) is solving \(puzzleSuffix)" + } + /// Parses the silent-push payload into a short, human-readable summary /// (database scope, notification type, subscription ID, pruned flag). /// Used by the diagnostics log to confirm whether shared-DB pushes are diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift @@ -241,6 +241,18 @@ final class SyncMonitor { } } + func run<T: Sendable>(_ phase: String, _ body: @Sendable () async throws -> T) async -> T? { + recordStart(phase) + do { + let result = try await body() + recordSuccess(phase) + return result + } catch { + recordError(phase, error) + return nil + } + } + private func append(level: String, _ message: String) { entries.append(SyncDiagnosticEntry(timestamp: Date(), level: level, message: message)) if entries.count > maxEntries { diff --git a/Crossmate/Sync/MovesUpdater.swift b/Crossmate/Sync/MovesUpdater.swift @@ -27,11 +27,9 @@ actor MovesUpdater { } private let debounceInterval: Duration - private let sessionPingStaleInterval: TimeInterval private let persistence: PersistenceController private let writerAuthorIDProvider: @Sendable () async -> String? private let sink: @Sendable (Set<UUID>) async -> Void - private let sessionPingSink: (@Sendable (UUID, String) async -> Void)? /// Sleep primitive used by the debounce timer. Injected so tests can /// drive flushes deterministically instead of racing against wall-clock /// `Task.sleep` from the actor's own task queue. @@ -43,34 +41,25 @@ actor MovesUpdater { /// editing order for cells whose wall clocks are within the same tick. private var lastCell: Key? private var debounceTask: Task<Void, Never>? - /// Per-game timestamp of the last session ping fired. The first `enqueue` - /// for a game with no entry — or one stale beyond - /// `sessionPingStaleInterval` — counts as a new session and fires a ping. - private var lastSessionPingAt: [UUID: Date] = [:] init( debounceInterval: Duration = .milliseconds(500), - sessionPingStaleInterval: TimeInterval = 20 * 60, persistence: PersistenceController, writerAuthorIDProvider: @escaping @Sendable () async -> String?, sink: @escaping @Sendable (Set<UUID>) async -> Void, - sessionPingSink: (@Sendable (UUID, String) async -> Void)? = nil, sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } ) { self.debounceInterval = debounceInterval - self.sessionPingStaleInterval = sessionPingStaleInterval self.persistence = persistence self.writerAuthorIDProvider = writerAuthorIDProvider self.sink = sink - self.sessionPingSink = sessionPingSink self.sleep = sleep } /// Registers a cell edit. `authorID` is the cell-effective author that - /// gets persisted into the merged grid — it may differ from the acting - /// (writer) user when a same-letter rewrite or a reveal-of-correct - /// preserves the original author. The acting user is still passed - /// separately so session pings fire on the typist. + /// gets persisted into the merged grid — it may differ from the writer + /// when a same-letter rewrite or a reveal-of-correct preserves the + /// original author. func enqueue( gameID: UUID, row: Int, @@ -96,17 +85,6 @@ actor MovesUpdater { ) lastCell = key scheduleDebounce() - - let pingAuthorID = actingAuthorID ?? authorID - if let pingAuthorID, !pingAuthorID.isEmpty { - await maybeFireSessionPing(gameID: gameID, authorID: pingAuthorID) - } - } - - /// Resets session-ping tracking for `gameID`. Called from the puzzle - /// view's teardown so re-entry counts as a fresh session. - func noteSessionEnded(gameID: UUID) { - lastSessionPingAt.removeValue(forKey: gameID) } /// Flushes any pending edits immediately and cancels the debounce. Safe @@ -117,18 +95,6 @@ actor MovesUpdater { await performFlush() } - private func maybeFireSessionPing(gameID: UUID, authorID: String) async { - let now = Date() - if let last = lastSessionPingAt[gameID], - now.timeIntervalSince(last) < sessionPingStaleInterval { - return - } - lastSessionPingAt[gameID] = now - if let sessionPingSink { - await sessionPingSink(gameID, authorID) - } - } - private func scheduleDebounce() { debounceTask?.cancel() let interval = debounceInterval diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -139,7 +139,7 @@ enum RecordSerializer { /// no Core Data equivalent and no system-fields archive. /// - `authorID` lets receivers filter out self-sends. /// - `playerName` and `puzzleTitle` let receivers render the alert body. - /// - `kind` distinguishes session/join/win/check/reveal events. + /// - `kind` distinguishes join/win/check/reveal events. /// - `scope` is set only for check/reveal kinds. static func pingRecord( gameID: UUID, diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -19,7 +19,6 @@ extension Notification.Name { /// What a Ping record represents. Stored as a string in the CKRecord's /// `kind` field. enum PingKind: String, Sendable { - case session case join case win case check @@ -44,6 +43,15 @@ struct Ping: Sendable { let scope: PingScope? } +struct Session: Sendable { + let recordName: String + let gameID: UUID + let authorID: String + let playerName: String + let puzzleTitle: String + let updatedAt: Date +} + /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for /// the private database (owned games and shares) and one for the shared /// database (joined games). Zone creation, subscription setup, change-token @@ -103,6 +111,7 @@ actor SyncEngine { /// (0 = private, 1 = shared). private var pingPushCheckpoints: [Int16: Date] = [:] private let pingPushCheckpointOverlap: TimeInterval = 30 + private let backgroundSessionLookback: TimeInterval = 10 * 60 func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { tracer = t @@ -291,7 +300,7 @@ actor SyncEngine { /// from the record name and routes to the correct engine. /// Registers the game's CloudKit zone for deletion. Each game owns its /// own zone, so this removes all remote records for the puzzle, including - /// moves, player records, session pings, and share metadata. + /// moves, player records, pings, and share metadata. func enqueueDeleteGame(_ deletion: GameCloudDeletion) { let zoneID = CKRecordZone.ID( zoneName: deletion.ckZoneName, @@ -303,8 +312,8 @@ actor SyncEngine { Task { try? await engine.sendChanges() } } - /// Registers a Ping record as a pending send. Pings cover session-start, - /// join, win, check, and reveal events; sender-only state — the payload is + /// Registers a Ping record as a pending send. Pings cover join, win, + /// check, and reveal events; sender-only state — the payload is /// stashed in `pendingPings` and only used to build the outgoing /// `CKRecord`; nothing is persisted. func enqueuePing( @@ -641,6 +650,104 @@ actor SyncEngine { return pings.count } + @discardableResult + func fetchBackgroundSessionsDirect(scope: CKDatabase.Scope) async throws -> ([Ping], [Session]) { + let database: CKDatabase + let scopeValue: Int16 + let label: String + switch scope { + case .private: + database = container.privateCloudDatabase + scopeValue = 0 + label = "private" + case .shared: + database = container.sharedCloudDatabase + scopeValue = 1 + label = "shared" + case .public: + return ([], []) + @unknown default: + return ([], []) + } + + let ctx = persistence.container.newBackgroundContext() + let zones = incompleteKnownZones(forScope: scopeValue, in: ctx) + guard !zones.isEmpty else { + await trace("\(label) background session scan: no incomplete zones") + return ([], []) + } + + let since = Date().addingTimeInterval(-backgroundSessionLookback) + struct PerZoneActivity: Sendable { + let records: [CKRecord] + let pings: [Ping] + let players: [Session] + } + + let perZone = await withTaskGroup(of: PerZoneActivity.self) { group in + for zone in zones { + group.addTask { [weak self] in + guard let self else { + return PerZoneActivity(records: [], pings: [], players: []) + } + do { + async let pingRecords = self.queryLiveRecords( + type: "Ping", + database: database, + zoneID: zone.zoneID, + since: since, + desiredKeys: ["authorID", "playerName", "puzzleTitle", "kind", "scope"] + ) + async let playerRecords = self.queryLiveRecords( + type: "Player", + database: database, + zoneID: zone.zoneID, + since: since, + desiredKeys: ["authorID", "name", "updatedAt", "selRow", "selCol", "selDir"] + ) + let (pings, players) = try await (pingRecords, playerRecords) + let activities = players.compactMap { record in + Self.parseSessionRecord(record, puzzleTitle: zone.title) + } + return PerZoneActivity( + records: players, + pings: pings.compactMap(Self.parsePingRecord), + players: activities + ) + } catch { + await self.trace( + "\(label) background session scan: zone \(zone.zoneID.zoneName) failed: " + + "\(error.localizedDescription)" + ) + return PerZoneActivity(records: [], pings: [], players: []) + } + } + } + var all: [PerZoneActivity] = [] + for await result in group { + all.append(result) + } + return all + } + + let records = perZone.flatMap(\.records) + if !records.isEmpty { + await applyDirectRecordZoneChanges( + records: records, + deletions: [], + scopeValue: scopeValue + ) + } + + let pings = perZone.flatMap(\.pings) + let players = perZone.flatMap(\.players) + await trace( + "\(label) background session scan: zones=\(zones.count), " + + "players=\(players.count), pings=\(pings.count)" + ) + return (pings, players) + } + /// Discovers games whose zones the device has never seen and pulls their /// Game / Moves / Player records directly, bypassing CKSyncEngine. /// @@ -1182,6 +1289,12 @@ actor SyncEngine { let zoneID: CKRecordZone.ID } + private struct ActivityZoneInfo: Sendable { + let gameID: UUID + let zoneID: CKRecordZone.ID + let title: String + } + /// Looks up a game's scope and zone ID from Core Data. Returns `nil` if /// the entity can't be found. Not `async` — uses `performAndWait` so it /// can be called from non-async actor context. @@ -1253,6 +1366,35 @@ actor SyncEngine { } } + private nonisolated func incompleteKnownZones( + forScope scope: Int16, + in ctx: NSManagedObjectContext + ) -> [ActivityZoneInfo] { + ctx.performAndWait { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate( + format: "databaseScope == %d AND completedAt == nil", + scope + ) + guard let entities = try? ctx.fetch(req) else { return [] } + var seen = Set<String>() + var result: [ActivityZoneInfo] = [] + for entity in entities { + guard let gameID = entity.id else { continue } + let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" + let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName + let key = "\(ownerName)|\(zoneName)" + guard seen.insert(key).inserted else { continue } + result.append(ActivityZoneInfo( + gameID: gameID, + zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), + title: Self.notificationTitle(for: entity) + )) + } + return result + } + } + /// Extracts the game UUID from any of our record name formats: /// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`. private nonisolated func gameID(fromRecordName name: String) -> UUID? { @@ -1728,6 +1870,29 @@ actor SyncEngine { ) } + private nonisolated static func parseSessionRecord( + _ record: CKRecord, + puzzleTitle: String + ) -> Session? { + guard let (gameID, authorIDFromName) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) + else { return nil } + // A cleared selection is the player leaving the puzzle, not starting + // or actively navigating it. + guard RecordSerializer.parsePlayerSelection(from: record) != nil else { return nil } + let authorID = (record["authorID"] as? String) ?? authorIDFromName + let updatedAt = (record["updatedAt"] as? Date) + ?? record.modificationDate + ?? Date() + return Session( + recordName: record.recordID.recordName, + gameID: gameID, + authorID: authorID, + playerName: (record["name"] as? String) ?? "", + puzzleTitle: puzzleTitle, + updatedAt: updatedAt + ) + } + private nonisolated func applyDeletion( recordID: CKRecord.ID, recordType: CKRecord.RecordType, diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -5,17 +5,16 @@ import Foundation /// Two pieces of state are tracked: /// - `activePuzzleID` — set by the app while the user is viewing a puzzle in /// the foreground so local notifications for that same puzzle can be skipped. -/// - `shownByGame` — a `[gameID: Date]` map used to debounce repeat -/// notifications. Once a session ping for game X has been shown, further -/// session pings for X within `dedupWindow` are suppressed. +/// - `shownByGame` — a `[gameID: Date]` map used to debounce inferred +/// session notifications. Once activity for game X has been shown, further +/// session notifications for X within `dedupWindow` are suppressed. enum NotificationState { static let appGroup = "group.net.inqk.crossmate" - /// How long after a shown session ping subsequent session pings for the - /// same game are suppressed. Matches the sender-side quiet threshold so - /// the sender's gating dominates and the receiver only acts as a guard - /// against multi-device, app-restart, and initial-sync-flood duplicates. - /// Only applies to session pings; join/win/check/reveal bypass dedup. + /// 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 and are + /// deduped by Ping record name instead. static let dedupWindow: TimeInterval = 20 * 60 private static let activeKey = "notif.activePuzzleID" @@ -58,8 +57,7 @@ enum NotificationState { activePuzzleID() == gameID } - /// True if a session ping for `gameID` was shown within `dedupWindow`. - /// Only consulted for `.session` kinds; join/win/check/reveal bypass. + /// True if session activity for `gameID` was shown within `dedupWindow`. static func wasRecentlyShown(gameID: UUID, now: Date = Date()) -> Bool { let map = shownMap() guard let last = map[gameID.uuidString] else { return false } diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -44,4 +44,18 @@ struct PuzzleNotificationTextTests { #expect(AppServices.bodyText(for: ping) == "Alice checked all of the puzzle 'Saturday Puzzle – 1 January 2001'") } + + @Test("Session activity says player is solving puzzle") + func sessionActivitySaysPlayerIsSolvingPuzzle() { + let session = Session( + recordName: "player-test-1", + gameID: UUID(), + authorID: "alice", + playerName: "Alice", + puzzleTitle: "Saturday Puzzle – 1 January 2001", + updatedAt: Date() + ) + + #expect(AppServices.bodyText(for: session) == "Alice is solving the puzzle 'Saturday Puzzle – 1 January 2001'") + } }