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:
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'")
+ }
}