crossmate

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

commit b1744e82ed6b3914dfcd369edf71d21b6fcb07af
parent f45ecf8db8d937ad9c0e7433253d0c53b9c4c123
Author: Michael Camilleri <[email protected]>
Date:   Thu, 21 May 2026 08:29:14 +0900

Notify when a collaborator's play session ends

Crossmate now surfaces a local notification (e.g. 'Alice added 4 letters
and cleared 2 in the puzzle "Tuesday Mini"') when a collaborator stops
typing for a while. The detection is receiver-side and quiescence-based
rather than sender-pushed: each incoming batch of Moves records is
diffed against the merged grid as it stood before the batch, per-cell
transitions are attributed to the LWW-winning writer, and the resulting
(gameID, authorID) deltas feed a running tally. A write older than 180
seconds is treated as cold-launch backfill and ignored so a hard offline
catch-up does not queue a notification for a session that ended hours
ago. Multi-device falls out for free because the bucket is keyed by
author, not device — any of Alice's devices streaming Moves keeps the
timer reset until all of them are quiet.

SessionMonitor holds the per-author buckets and uses
UNTimeIntervalNotificationTrigger with a stable
'session-end-<gameID>-<authorID>' identifier; each new delta re-add()s the
request, collapsing a typing burst into one delivery and surviving a force-quit
because the OS retains pending time-trigger requests. The end-of-session is
cancelled by stronger signals — a .win or .resign ping, a game removal or
access revocation, or the user opening the puzzle — and firing the end also
withdraws any session-begin banner still on screen for that (gameID, authorID)
along with its dedup gate so a fresh session can notify normally. To support
the matrix of begin and end notifications coexisting per (game, author), the
session-begin path stabilises its identifier to
'session-begin-<gameID>-<authorID>', carries the authorID in userInfo, renames
presentSessions to presentSessionBegins, and the begin-side dedup in
NotificationState is rekeyed from gameID to (gameID, authorID) so two
collaborators starting sessions in the same window can both notify. The
receiver-side delta is computed by a new GridStateMerger.mergeWithProvenance
plus authorDeltas helpers in RecordApplier, surfaced from both apply paths
(direct push and CKSyncEngine fetched changes) via a new onRemoteAuthorDelta
callback on SyncEngine.

Tests cover the merged-grid provenance variant, the delta computation
(added/cleared transitions, letter-overwrite-neutral, idempotent re-apply,
cutoff gate, multi-device same-author and per-author aggregation), and
SessionMonitor's accumulation, suppression gate, session rollover after the
trigger elapses, the cancel paths, and end-withdraws-begin. SessionMonitor
gains a small SessionNotificationScheduling protocol so tests can substitute
an in-memory recorder for UNUserNotificationCenter.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 12++++++++++++
MCrossmate/CrossmateApp.swift | 11++++++++---
MCrossmate/Services/AppServices.swift | 54+++++++++++++++++++++++++++++++++++++-----------------
MCrossmate/Sync/GridStateMerger.swift | 46+++++++++++++++++++++++++++++++++++-----------
MCrossmate/Sync/RecordApplier.swift | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
ACrossmate/Sync/SessionMonitor.swift | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 34+++++++++++++++++++++++++++++++---
MShared/NotificationState.swift | 33+++++++++++++++++++++++++--------
MTests/Unit/NotificationStateTests.swift | 34++++++++++++++++++++++++++++++++++
ATests/Unit/Sync/AuthorDeltaTests.swift | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/Sync/SessionMonitorTests.swift | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 1032 insertions(+), 45 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 4D90B39AD2F79959FB8089EE /* MovesUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD270E16E00145EF2807EA9 /* MovesUpdater.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; + 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; @@ -72,6 +73,7 @@ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; }; AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; }; AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF633D73818BD59F759FAC4 /* AboutView.swift */; }; + AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; }; AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; }; B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; }; @@ -80,6 +82,7 @@ BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */; }; C1930083671621AC79CF95DD /* MovesUpdaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */; }; C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; }; + C2D8A9C79D75DBEF45720927 /* AuthorDeltaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */; }; C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; }; C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; }; C89A15D812E372FE1C56039B /* PUZToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE193CAB325C991952D7CE5 /* PUZToXDConverterTests.swift */; }; @@ -128,6 +131,7 @@ 07E5E4B165E374FEE732068B /* CloudDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudDiagnostics.swift; sourceTree = "<group>"; }; 0BF60C84D92A9024AC1A53FC /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordSerializer.swift; sourceTree = "<group>"; }; + 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitor.swift; sourceTree = "<group>"; }; 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelectionPublisher.swift; sourceTree = "<group>"; }; 14B05C19BD4705876B3DF0EC /* GridStateMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridStateMerger.swift; sourceTree = "<group>"; }; 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossmateApp.swift; sourceTree = "<group>"; }; @@ -136,6 +140,7 @@ 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; + 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorDeltaTests.swift; sourceTree = "<group>"; }; 26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; }; 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; }; 2D2FD896D75863554E31654C /* NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationState.swift; sourceTree = "<group>"; }; @@ -227,6 +232,7 @@ EE3412F437AABD2988B6976D /* FriendPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendPickerView.swift; sourceTree = "<group>"; }; EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesInboundTests.swift; sourceTree = "<group>"; }; F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; }; + F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMonitorTests.swift; sourceTree = "<group>"; }; F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; F91707302CBA406BF7FB8F8A /* FriendAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendAvatarView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; @@ -262,6 +268,7 @@ BD63A9B20168F3B81AF4348F /* RecordApplier.swift */, 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */, 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */, + 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */, 5C74683332956B0D1CA37589 /* ShareController.swift */, 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */, 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */, @@ -404,6 +411,7 @@ ABB371EF2574E95782CB05FD /* Sync */ = { isa = PBXGroup; children = ( + 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */, 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */, 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */, B766E872B12DC79ECCD80941 /* FriendModelTests.swift */, @@ -411,6 +419,7 @@ EF1254FE7BE3672AEC1607B1 /* MovesInboundTests.swift */, BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */, 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */, + F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */, 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */, A9A01534A21796A4EC7113A9 /* ZoneOrphaningTests.swift */, ); @@ -544,6 +553,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C2D8A9C79D75DBEF45720927 /* AuthorDeltaTests.swift in Sources */, A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */, @@ -570,6 +580,7 @@ 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */, 89CEDB8864F61E42AC04F9D6 /* RecordSerializerMovesTests.swift in Sources */, ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, + AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */, BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */, 31F2B6A61ED352C7D800149F /* XDAcceptTests.swift in Sources */, @@ -645,6 +656,7 @@ D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */, D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */, CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, + 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */, 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */, BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */, AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -96,9 +96,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser completionHandler([.banner, .sound]) return } - let isSession = (userInfo["crossmateActivity"] as? String) == "session" - if isSession { - NotificationState.recordShown(gameID: gameID) + 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([]) @@ -544,6 +544,11 @@ private struct PuzzleDisplayView: View { private func updateActiveNotificationPuzzleID(for phase: ScenePhase) { if phase == .active { NotificationState.setActivePuzzleID(gameID) + // Opening the puzzle supersedes any pending end-of-session + // summary — the user is about to see the moves directly. + let id = gameID + let sessionMonitor = services.sessionMonitor + Task { await sessionMonitor.cancel(gameID: id) } } else { NotificationState.clearActivePuzzleID(if: gameID) } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -51,6 +51,7 @@ final class AppServices { let nytFetcher: NYTPuzzleFetcher let inputMonitor: InputMonitor let movesUpdater: MovesUpdater + let sessionMonitor: SessionMonitor let playerSelectionPublisher: PlayerSelectionPublisher let identity: AuthorIdentity let shareController: ShareController @@ -146,6 +147,11 @@ final class AppServices { ) self.movesUpdater = movesUpdater + self.sessionMonitor = SessionMonitor( + persistence: persistence, + localAuthorIDProvider: { await MainActor.run { identity.currentID } } + ) + let cursorStore = GameCursorStore() self.cursorStore = cursorStore let onGameDeletedHandler = Self.makeOnGameDeleted( @@ -248,6 +254,10 @@ final class AppServices { identity.currentID } + await syncEngine.setOnRemoteAuthorDelta { [sessionMonitor] deltas in + await sessionMonitor.ingest(deltas) + } + await syncEngine.setOnRemoteMovesUpdated { [weak self, store, identity] gameIDs in store.noteIncomingMovesUpdate( gameIDs: gameIDs, @@ -288,12 +298,14 @@ final class AppServices { await self.identity.refresh(using: self.ckContainer) } - await syncEngine.setOnGameAccessRevoked { [store] gameID in + await syncEngine.setOnGameAccessRevoked { [store, sessionMonitor] gameID in store.markAccessRevoked(gameID: gameID) + await sessionMonitor.cancel(gameID: gameID) } - await syncEngine.setOnGameRemoved { [store] gameID in + await syncEngine.setOnGameRemoved { [store, sessionMonitor] gameID in store.handleRemoteRemoval(gameID: gameID) + await sessionMonitor.cancel(gameID: gameID) } // A sibling device consumed (deleted) a directed ping; withdraw any @@ -746,7 +758,7 @@ final class AppServices { } if let result { await presentPings(result.0) - await presentSessions(result.1) + await presentSessionBegins(result.1) } scheduleBackgroundPushCatchUp(scope: scope) await refreshSnapshot() @@ -1198,6 +1210,13 @@ final class AppServices { private func presentPings(_ pings: [Ping]) async { guard !pings.isEmpty else { return } + // Cancel any pending end-of-session summary that a stronger signal + // (the author solved or gave up) is about to supersede. Runs before + // notification authorization is checked so the cancel happens even + // when the system-only `.friend` ping path skips presentation. + for ping in pings where ping.kind == .win || ping.kind == .resign { + await sessionMonitor.cancel(gameID: ping.gameID, authorID: ping.authorID) + } applyInvitePings(pings) // `.friend` is the friendship-bootstrap handshake — system-only, no // alert, runs without notification authorization. Everything else @@ -1280,12 +1299,12 @@ final class AppServices { } } - private func presentSessions( + private func presentSessionBegins( _ sessions: [Session] ) async { guard !sessions.isEmpty else { return } guard await canPresentNotifications() else { - syncMonitor.note("session: local notification skipped — authorization not granted") + syncMonitor.note("session-begin: local notification skipped — authorization not granted") return } @@ -1312,21 +1331,21 @@ final class AppServices { let center = UNUserNotificationCenter.current() for session in sessions { if session.authorID == identity.currentID { - syncMonitor.note("session: skipped self-authored record \(session.recordName)") + syncMonitor.note("session-begin: skipped self-authored record \(session.recordName)") continue } if completedByAuthor.contains("\(session.gameID.uuidString)|\(session.authorID)") { - syncMonitor.note("session: suppressed — author completed \(session.gameID.uuidString)") + syncMonitor.note("session-begin: suppressed — author completed \(session.gameID.uuidString)") continue } if NotificationState.isSuppressed(gameID: session.gameID) { - NotificationState.recordShown(gameID: session.gameID) - syncMonitor.note("session: suppressed — puzzle is active for \(session.gameID.uuidString)") + NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID) + syncMonitor.note("session-begin: 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)") + if NotificationState.wasRecentlyShown(gameID: session.gameID, authorID: session.authorID) { + NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID) + syncMonitor.note("session-begin: dedup-suppressed for \(session.gameID.uuidString) author=\(session.authorID)") continue } @@ -1336,20 +1355,21 @@ final class AppServices { content.sound = .default content.userInfo = [ "crossmateGameID": session.gameID.uuidString, - "crossmateActivity": "session" + "crossmateAuthorID": session.authorID, + "crossmateActivity": "session-begin" ] let request = UNNotificationRequest( - identifier: "session-\(session.gameID.uuidString)-\(UUID().uuidString)", + identifier: "session-begin-\(session.gameID.uuidString)-\(session.authorID)", 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)") + NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID) + syncMonitor.note("session-begin: queued local notification for \(session.gameID.uuidString) author=\(session.authorID)") } catch { - syncMonitor.note("session: local notification failed — \(error.localizedDescription)") + syncMonitor.note("session-begin: local notification failed — \(error.localizedDescription)") } } } diff --git a/Crossmate/Sync/GridStateMerger.swift b/Crossmate/Sync/GridStateMerger.swift @@ -10,6 +10,40 @@ import Foundation enum GridStateMerger { static func merge(_ moves: [MovesValue]) -> GridState { + var grid: GridState = [:] + for (position, winner) in winners(moves) { + grid[position] = GridCell( + letter: winner.cell.letter, + markKind: winner.cell.markKind, + checkedWrong: winner.cell.checkedWrong, + authorID: winner.cell.authorID + ) + } + return grid + } + + /// Merge variant that preserves the *writer* (the iCloud user whose + /// `MovesValue` won LWW for each cell) and the raw `TimestampedCell`, + /// including `updatedAt`. Cells whose only writes are empty letters are + /// retained — SessionMonitor needs them to detect clears against a + /// before-snapshot. + static func mergeWithProvenance(_ moves: [MovesValue]) -> [GridPosition: Provenance] { + var result: [GridPosition: Provenance] = [:] + for (position, winner) in winners(moves) { + result[position] = Provenance( + cell: winner.cell, + writerAuthorID: winner.writerAuthorID + ) + } + return result + } + + struct Provenance: Equatable { + var cell: TimestampedCell + var writerAuthorID: String + } + + private static func winners(_ moves: [MovesValue]) -> [GridPosition: Winner] { var winners: [GridPosition: Winner] = [:] for view in moves { for (position, cell) in view.cells { @@ -27,17 +61,7 @@ enum GridStateMerger { } } } - - var grid: GridState = [:] - for (position, winner) in winners { - grid[position] = GridCell( - letter: winner.cell.letter, - markKind: winner.cell.markKind, - checkedWrong: winner.cell.checkedWrong, - authorID: winner.cell.authorID - ) - } - return grid + return winners } private struct Winner { diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -2,6 +2,20 @@ import CloudKit import CoreData import Foundation +/// One game's worth of remote-driven cell churn attributed to a single +/// writer. Surfaced from the receiver-side apply path so SessionMonitor can +/// accumulate per-author tallies and (re)schedule end-of-session +/// notifications. `latestUpdate` is the wall-clock timestamp of the most +/// recent winning cell write in this batch — used to schedule the +/// quiescence trigger relative to the actual typing, not to batch arrival. +struct AuthorDelta: Sendable { + let gameID: UUID + let authorID: String + let added: Int + let cleared: Int + let latestUpdate: Date +} + extension SyncEngine { func applyDirectRecordZoneChanges( records: [CKRecord], @@ -12,12 +26,22 @@ extension SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let localAuthorID = await currentLocalAuthorID() - let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, readCursors): - (Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)]) = ctx.performAndWait { + let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, readCursors, authorDeltas): + (Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait { var movesUpdated = Set<UUID>() var affected = Set<UUID>() var playersUpdated = Set<UUID>() var read: [(UUID, Date)] = [] + // Pre-pass: snapshot the merged grid for every game that has a + // Moves record in this batch, so the post-apply diff can attribute + // each cell transition to the LWW-winning writer. + let movesBearingGameIDs: Set<UUID> = Set(records.compactMap { record -> UUID? in + guard record.recordType == "Moves", + let (gameID, _, _) = RecordSerializer.parseMovesRecordName(record.recordID.recordName) + else { return nil } + return gameID + }) + let beforeGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) for record in records { switch record.recordType { case "Game": @@ -62,6 +86,12 @@ extension SyncEngine { for gameID in movesUpdated { self.replayCellCache(for: gameID, in: ctx) } + let afterGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) + let deltas = Self.authorDeltas( + before: beforeGrids, + after: afterGrids, + cutoff: Date().addingTimeInterval(-SessionMonitor.quiescenceWindow) + ) if ctx.hasChanges { do { try ctx.save() @@ -74,12 +104,15 @@ extension SyncEngine { ) } } - return (movesUpdated, affected, playersUpdated, read) + return (movesUpdated, affected, playersUpdated, read, deltas) } if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { await onRemoteMovesUpdated(movesUpdatedGameIDs) } + if let onRemoteAuthorDelta, !authorDeltas.isEmpty { + await onRemoteAuthorDelta(authorDeltas) + } if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty { await onRemotePlayersUpdated(playersUpdatedGameIDs) } @@ -252,6 +285,91 @@ extension SyncEngine { ) } + /// Computes the merged-grid provenance snapshot for each game. Called + /// twice during a Moves batch apply — once before any apply, once after + /// `replayCellCache` runs — so the diff between the two yields per-cell + /// transitions attributed to the LWW-winning writer. + nonisolated static func gridSnapshots( + for gameIDs: Set<UUID>, + in ctx: NSManagedObjectContext + ) -> [UUID: [GridPosition: GridStateMerger.Provenance]] { + var result: [UUID: [GridPosition: GridStateMerger.Provenance]] = [:] + for gameID in gameIDs { + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameReq.fetchLimit = 1 + guard let game = try? ctx.fetch(gameReq).first else { + result[gameID] = [:] + continue + } + let movesReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") + movesReq.predicate = NSPredicate(format: "game == %@", game) + let movesEntities = (try? ctx.fetch(movesReq)) ?? [] + let values: [MovesValue] = movesEntities.compactMap { movesValue(from: $0) } + result[gameID] = GridStateMerger.mergeWithProvenance(values) + } + return result + } + + /// Aggregates the merged-grid diff per `(gameID, writerAuthorID)`. Counts + /// empty→letter transitions as `added` and letter→empty as `cleared`; + /// letter→different-letter (one author rewriting over another's cell) + /// contributes zero. Cells whose winning entry was written before + /// `cutoff` are ignored — older entries can't represent a currently + /// active session and would mis-fire on cold-launch backfill of an old + /// record stream. + nonisolated static func authorDeltas( + before: [UUID: [GridPosition: GridStateMerger.Provenance]], + after: [UUID: [GridPosition: GridStateMerger.Provenance]], + cutoff: Date + ) -> [AuthorDelta] { + struct Key: Hashable { + let gameID: UUID + let authorID: String + } + var byKey: [Key: (added: Int, cleared: Int, latest: Date)] = [:] + let allGameIDs = Set(before.keys).union(after.keys) + for gameID in allGameIDs { + let b = before[gameID] ?? [:] + let a = after[gameID] ?? [:] + let positions = Set(b.keys).union(a.keys) + for pos in positions { + let beforeLetter = b[pos]?.cell.letter ?? "" + let afterEntry = a[pos] + let afterLetter = afterEntry?.cell.letter ?? "" + // Attribute to the LWW-winning writer in the post-apply + // snapshot; if the cell vanished (no MovesEntity for it + // anymore — only via record deletion), fall back to the + // pre-apply writer. + let writer = afterEntry?.writerAuthorID ?? b[pos]?.writerAuthorID ?? "" + guard !writer.isEmpty else { continue } + let stamp = afterEntry?.cell.updatedAt ?? b[pos]?.cell.updatedAt ?? .distantPast + guard stamp >= cutoff else { continue } + let key = Key(gameID: gameID, authorID: writer) + if beforeLetter.isEmpty, !afterLetter.isEmpty { + var bucket = byKey[key] ?? (0, 0, .distantPast) + bucket.added += 1 + if stamp > bucket.latest { bucket.latest = stamp } + byKey[key] = bucket + } else if !beforeLetter.isEmpty, afterLetter.isEmpty { + var bucket = byKey[key] ?? (0, 0, .distantPast) + bucket.cleared += 1 + if stamp > bucket.latest { bucket.latest = stamp } + byKey[key] = bucket + } + } + } + return byKey.map { key, value in + AuthorDelta( + gameID: key.gameID, + authorID: key.authorID, + added: value.added, + cleared: value.cleared, + latestUpdate: value.latest + ) + } + } + nonisolated func applyDeletion( recordID: CKRecord.ID, recordType: CKRecord.RecordType, diff --git a/Crossmate/Sync/SessionMonitor.swift b/Crossmate/Sync/SessionMonitor.swift @@ -0,0 +1,249 @@ +import CoreData +import Foundation +import UserNotifications + +/// Indirection over the slice of `UNUserNotificationCenter` SessionMonitor +/// uses, so tests can substitute an in-memory recorder. The production +/// implementation is the `UNUserNotificationCenter` extension below. +protocol SessionNotificationScheduling: Sendable { + func add(_ request: UNNotificationRequest) async throws + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) +} + +extension UNUserNotificationCenter: SessionNotificationScheduling {} + +/// Receiver-side observer that turns a stream of remote cell deltas +/// (`AuthorDelta`) into one end-of-session local notification per +/// `(gameID, authorID)`. Each delta bumps an in-memory running tally and +/// (re)schedules a `UNNotificationRequest` with a stable identifier and a +/// quiescence-window time trigger — so a quick burst of incoming Moves +/// records collapses to one delivery, and a force-quit between deltas +/// still fires the last-scheduled summary (the OS holds time-trigger +/// requests even when the app is not running). +/// +/// Multi-device collaborators fall out for free: the bucket is keyed by +/// the writing iCloud author, not the device, so any of Alice's devices +/// streaming Moves resets the timer until *all* of her devices are quiet. +actor SessionMonitor { + /// Wall-clock window of inactivity before the end-of-session + /// notification fires. Also used by `RecordApplier.authorDeltas` to gate + /// stale cell writes out of cold-launch backfill so they don't queue a + /// notification for a session that ended hours ago. + static let quiescenceWindow: TimeInterval = 180 + + private struct Key: Hashable { + let gameID: UUID + let authorID: String + } + + private struct Bucket { + var added: Int + var cleared: Int + /// Wall-clock time at which the currently-scheduled + /// `UNNotificationRequest` will fire. On the next note() this is + /// compared against `now` — if the trigger has already elapsed, the + /// tally is treated as consumed and the new delta starts a fresh + /// session. + var scheduledFor: Date + } + + private var buckets: [Key: Bucket] = [:] + private let persistence: PersistenceController + private let notificationCenter: SessionNotificationScheduling + private let nameLookup: @Sendable (UUID, String) async -> (playerName: String, puzzleTitle: String) + private let suppressionGate: @Sendable (UUID) -> Bool + private let localAuthorIDProvider: @Sendable () async -> String? + private let clock: @Sendable () -> Date + + init( + persistence: PersistenceController, + localAuthorIDProvider: @escaping @Sendable () async -> String?, + notificationCenter: SessionNotificationScheduling = UNUserNotificationCenter.current(), + nameLookup: (@Sendable (UUID, String) async -> (playerName: String, puzzleTitle: String))? = nil, + suppressionGate: @escaping @Sendable (UUID) -> Bool = { NotificationState.isSuppressed(gameID: $0) }, + clock: @escaping @Sendable () -> Date = { Date() } + ) { + self.persistence = persistence + self.localAuthorIDProvider = localAuthorIDProvider + self.notificationCenter = notificationCenter + self.suppressionGate = suppressionGate + self.clock = clock + if let nameLookup { + self.nameLookup = nameLookup + } else { + self.nameLookup = { gameID, authorID in + await Self.coreDataNameLookup( + persistence: persistence, + gameID: gameID, + authorID: authorID + ) + } + } + } + + /// Folds one batch of receiver-side deltas into the per-author tallies + /// and reschedules each affected end-of-session notification. Drops + /// self-authored deltas (sibling device of the same iCloud account) and + /// deltas for puzzles the user is currently viewing. + func ingest(_ deltas: [AuthorDelta]) async { + guard !deltas.isEmpty else { return } + let localAuthorID = await localAuthorIDProvider() + for delta in deltas { + if delta.added == 0 && delta.cleared == 0 { continue } + if delta.authorID == localAuthorID { continue } + if suppressionGate(delta.gameID) { continue } + let key = Key(gameID: delta.gameID, authorID: delta.authorID) + let now = clock() + var bucket = buckets[key] ?? Bucket(added: 0, cleared: 0, scheduledFor: now) + // Prior trigger has already fired (or never existed) — start a + // fresh session rather than accumulating into the old summary. + if now >= bucket.scheduledFor { + bucket = Bucket(added: 0, cleared: 0, scheduledFor: now) + } + bucket.added += delta.added + bucket.cleared += delta.cleared + // Schedule the trigger relative to the latest typing time, not + // batch arrival, so a late-arriving batch doesn't push the + // notification further into the future than it should be. + let scheduledFor = max( + now.addingTimeInterval(1), + delta.latestUpdate.addingTimeInterval(Self.quiescenceWindow) + ) + bucket.scheduledFor = scheduledFor + buckets[key] = bucket + await scheduleEnd(key: key, bucket: bucket, now: now) + } + } + + /// Withdraws the pending end-of-session notification and tally for + /// `(gameID, authorID)`. Pass `authorID == nil` to drop every author's + /// bucket for the game — used when the user opens the puzzle, a `.win` + /// or `.resign` ping arrives, or the Game gains a `completedBy` (the + /// summary is superseded by a stronger signal). + func cancel(gameID: UUID, authorID: String? = nil) async { + let keysToCancel: [Key] = buckets.keys.filter { key in + key.gameID == gameID && (authorID == nil || key.authorID == authorID) + } + guard !keysToCancel.isEmpty else { return } + for key in keysToCancel { + buckets.removeValue(forKey: key) + } + let identifiers = keysToCancel.map(Self.endIdentifier(for:)) + notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) + notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) + } + + /// Number of distinct `(gameID, authorID)` buckets currently being + /// accumulated. Exposed for tests; not part of the runtime API. + var pendingBucketCount: Int { buckets.count } + + /// Snapshot of the running tally for `(gameID, authorID)`, if any. + /// Exposed for tests; not part of the runtime API. + func tally(gameID: UUID, authorID: String) -> (added: Int, cleared: Int, scheduledFor: Date)? { + guard let bucket = buckets[Key(gameID: gameID, authorID: authorID)] else { return nil } + return (bucket.added, bucket.cleared, bucket.scheduledFor) + } + + private func scheduleEnd(key: Key, bucket: Bucket, now: Date) async { + let (playerName, puzzleTitle) = await nameLookup(key.gameID, key.authorID) + let content = UNMutableNotificationContent() + content.title = "Crossmate" + content.body = Self.bodyText( + playerName: playerName, + puzzleTitle: puzzleTitle, + added: bucket.added, + cleared: bucket.cleared + ) + content.sound = .default + content.userInfo = [ + "crossmateGameID": key.gameID.uuidString, + "crossmateAuthorID": key.authorID, + "crossmateActivity": "session-end" + ] + let interval = max(bucket.scheduledFor.timeIntervalSince(now), 1) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false) + let request = UNNotificationRequest( + identifier: Self.endIdentifier(for: key), + content: content, + trigger: trigger + ) + do { + try await notificationCenter.add(request) + // The session-begin notification (if still on screen) is + // superseded by this richer end-of-session summary — withdraw + // both the delivered banner and its dedup gate so a fresh + // begin can fire once the next session starts. + notificationCenter.removeDeliveredNotifications( + withIdentifiers: [Self.beginIdentifier(for: key)] + ) + NotificationState.clearShown(gameID: key.gameID, authorID: key.authorID) + } catch { + // Best-effort: a scheduling failure leaves the running tally + // intact, so the next note() will retry the add() with the + // accumulated totals. + } + } + + private static func coreDataNameLookup( + persistence: PersistenceController, + gameID: UUID, + authorID: String + ) async -> (playerName: String, puzzleTitle: String) { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameReq.fetchLimit = 1 + let game = try? ctx.fetch(gameReq).first + let title = game?.title ?? "" + + var name = "" + if let game { + let playerReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + playerReq.predicate = NSPredicate( + format: "game == %@ AND authorID == %@", + game, authorID + ) + playerReq.fetchLimit = 1 + name = (try? ctx.fetch(playerReq).first?.name) ?? "" + } + return (name, title) + } + } + + static func endIdentifier(for gameID: UUID, authorID: String) -> String { + "session-end-\(gameID.uuidString)-\(authorID)" + } + + private static func endIdentifier(for key: Key) -> String { + endIdentifier(for: key.gameID, authorID: key.authorID) + } + + static func beginIdentifier(for gameID: UUID, authorID: String) -> String { + "session-begin-\(gameID.uuidString)-\(authorID)" + } + + private static func beginIdentifier(for key: Key) -> String { + beginIdentifier(for: key.gameID, authorID: key.authorID) + } + + static func bodyText( + playerName: String, + puzzleTitle: String, + added: Int, + cleared: Int + ) -> String { + let name = playerName.isEmpty ? "A player" : playerName + let suffix = puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(puzzleTitle)'" + var parts: [String] = [] + if added > 0 { + parts.append("added \(added) \(added == 1 ? "letter" : "letters")") + } + if cleared > 0 { + parts.append("cleared \(cleared) \(cleared == 1 ? "letter" : "letters")") + } + let action = parts.isEmpty ? "made changes" : parts.joined(separator: " and ") + return "\(name) \(action) in \(suffix)" + } +} diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -74,6 +74,11 @@ actor SyncEngine { private var loggedFirstSharedPushPayload = false var onRemoteMovesUpdated: (@MainActor @Sendable (Set<UUID>) async -> Void)? + /// Fires with per-`(gameID, authorID)` deltas computed by diffing the + /// merged grid before and after a batch of incoming Moves records. Drives + /// SessionMonitor's end-of-session notifications: each delta bumps the + /// running tally and reschedules the quiescence trigger. + var onRemoteAuthorDelta: (@MainActor @Sendable ([AuthorDelta]) async -> Void)? /// Fires with the game IDs for which a collaborator's `Player` record was /// seen for the **first time** (a new `PlayerEntity` was created) — not on /// their subsequent name / cursor updates. Independent of moves; the @@ -121,6 +126,10 @@ actor SyncEngine { onRemoteMovesUpdated = cb } + func setOnRemoteAuthorDelta(_ cb: @MainActor @Sendable @escaping ([AuthorDelta]) async -> Void) { + onRemoteAuthorDelta = cb + } + func setOnRemotePlayersUpdated(_ cb: @MainActor @Sendable @escaping (Set<UUID>) async -> Void) { onRemotePlayersUpdated = cb } @@ -853,14 +862,24 @@ actor SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let localAuthorID = await currentLocalAuthorID() - let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, removedGameIDs, readCursors): - (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>, [(UUID, Date)]) = ctx.performAndWait { + let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, removedGameIDs, readCursors, authorDeltas): + (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait { var movesUpdated = Set<UUID>() var affected = Set<UUID>() var pings: [Ping] = [] var playersUpdated = Set<UUID>() var removed = Set<UUID>() var read: [(UUID, Date)] = [] + // Pre-pass: snapshot the merged grid for every game that has a + // Moves record in this batch, so the post-apply diff can attribute + // each cell transition to the LWW-winning writer. + let movesBearingGameIDs: Set<UUID> = Set(event.modifications.compactMap { mod -> UUID? in + guard mod.record.recordType == "Moves", + let (gameID, _, _) = RecordSerializer.parseMovesRecordName(mod.record.recordID.recordName) + else { return nil } + return gameID + }) + let beforeGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) for mod in event.modifications { let record = mod.record switch record.recordType { @@ -927,6 +946,12 @@ actor SyncEngine { for gameID in movesUpdated { self.replayCellCache(for: gameID, in: ctx) } + let afterGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) + let deltas = Self.authorDeltas( + before: beforeGrids, + after: afterGrids, + cutoff: Date().addingTimeInterval(-SessionMonitor.quiescenceWindow) + ) // CKSyncEngine advances its change token whenever the delegate // returns from fetchedRecordZoneChanges, regardless of whether we // persisted anything. A silent failure here means the records are @@ -944,12 +969,15 @@ actor SyncEngine { ) } } - return (movesUpdated, affected, pings, playersUpdated, removed, read) + return (movesUpdated, affected, pings, playersUpdated, removed, read, deltas) } if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { await onRemoteMovesUpdated(movesUpdatedGameIDs) } + if let onRemoteAuthorDelta, !authorDeltas.isEmpty { + await onRemoteAuthorDelta(authorDeltas) + } if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty { await onRemotePlayersUpdated(playersUpdatedGameIDs) } diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -76,25 +76,42 @@ enum NotificationState { return false } - /// True if session activity for `gameID` was shown within `dedupWindow`. - static func wasRecentlyShown(gameID: UUID, now: Date = Date()) -> Bool { + /// 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 map = shownMap() - guard let last = map[gameID.uuidString] else { return false } + guard let last = map[Self.compositeKey(gameID: gameID, authorID: authorID)] else { return false } return now.timeIntervalSince1970 - last < dedupWindow } - /// Records that a notification for `gameID` was surfaced (or would have - /// been, before suppression decisions). Trims old entries so the map - /// stays small. - static func recordShown(gameID: UUID, now: Date = Date()) { + /// Records that a notification for `(gameID, authorID)` was surfaced (or + /// would have been, before suppression decisions). Trims old entries so + /// the map stays small. + static func recordShown(gameID: UUID, authorID: String, now: Date = Date()) { guard let defaults else { return } var map = shownMap() - map[gameID.uuidString] = now.timeIntervalSince1970 + map[Self.compositeKey(gameID: gameID, authorID: authorID)] = now.timeIntervalSince1970 let cutoff = now.timeIntervalSince1970 - 2 * dedupWindow map = map.filter { $0.value >= cutoff } defaults.set(map, forKey: shownKey) } + /// Drops the session-begin dedup entry for `(gameID, authorID)`. Called + /// when SessionMonitor fires the matching end-of-session notification — + /// the completed session has been summarised, so a fresh session-begin + /// from this author shouldn't be gated by the prior begin's dedup. + static func clearShown(gameID: UUID, authorID: String) { + guard let defaults else { return } + var map = shownMap() + guard map.removeValue(forKey: Self.compositeKey(gameID: gameID, authorID: authorID)) != nil else { return } + defaults.set(map, forKey: shownKey) + } + + private static func compositeKey(gameID: UUID, authorID: String) -> String { + "\(gameID.uuidString)|\(authorID)" + } + private static func shownMap() -> [String: TimeInterval] { defaults?.dictionary(forKey: shownKey) as? [String: TimeInterval] ?? [:] } diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift @@ -43,6 +43,40 @@ 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() diff --git a/Tests/Unit/Sync/AuthorDeltaTests.swift b/Tests/Unit/Sync/AuthorDeltaTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("GridStateMerger.mergeWithProvenance") +struct GridStateMergerProvenanceTests { + + private let gameID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + + private func view( + author: String, + device: String = "d1", + cells: [(row: Int, col: Int, letter: String, updatedAt: Date)] + ) -> MovesValue { + var dict: [GridPosition: TimestampedCell] = [:] + for entry in cells { + dict[GridPosition(row: entry.row, col: entry.col)] = TimestampedCell( + letter: entry.letter, + markKind: 0, + checkedWrong: false, + updatedAt: entry.updatedAt, + authorID: author + ) + } + return MovesValue( + gameID: gameID, + authorID: author, + deviceID: device, + cells: dict, + updatedAt: cells.map(\.updatedAt).max() ?? .distantPast + ) + } + + @Test("Provenance carries the writer authorID, not just the cell author") + func writerAttribution() { + let alice = view( + author: "alice", + cells: [(0, 0, "A", Date(timeIntervalSince1970: 5))] + ) + let result = GridStateMerger.mergeWithProvenance([alice]) + let entry = result[GridPosition(row: 0, col: 0)] + #expect(entry?.writerAuthorID == "alice") + #expect(entry?.cell.letter == "A") + #expect(entry?.cell.updatedAt == Date(timeIntervalSince1970: 5)) + } + + @Test("Cleared cells (empty letter) are retained with their writer") + func emptyCellRetained() { + let cleared = view( + author: "alice", + cells: [(0, 0, "", Date(timeIntervalSince1970: 9))] + ) + let result = GridStateMerger.mergeWithProvenance([cleared]) + let entry = result[GridPosition(row: 0, col: 0)] + #expect(entry?.cell.letter == "") + #expect(entry?.writerAuthorID == "alice") + } + + @Test("LWW winner's writer survives across multiple devices") + func lwwWinnerWriter() { + let alice = view( + author: "alice", + device: "ipad", + cells: [(0, 0, "A", Date(timeIntervalSince1970: 1))] + ) + let bob = view( + author: "bob", + device: "phone", + cells: [(0, 0, "B", Date(timeIntervalSince1970: 2))] + ) + let result = GridStateMerger.mergeWithProvenance([alice, bob]) + #expect(result[GridPosition(row: 0, col: 0)]?.writerAuthorID == "bob") + } +} + +@Suite("RecordApplier.authorDeltas") +struct AuthorDeltaTests { + + private let gameA = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + private let gameB = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + + /// Convenience to build a per-game provenance map without going through + /// GridStateMerger — keeps tests focused on the diff logic. + private func snapshot( + _ entries: [(row: Int, col: Int, letter: String, writer: String, updatedAt: Date)] + ) -> [GridPosition: GridStateMerger.Provenance] { + var out: [GridPosition: GridStateMerger.Provenance] = [:] + for entry in entries { + out[GridPosition(row: entry.row, col: entry.col)] = GridStateMerger.Provenance( + cell: TimestampedCell( + letter: entry.letter, + markKind: 0, + checkedWrong: false, + updatedAt: entry.updatedAt, + authorID: entry.writer + ), + writerAuthorID: entry.writer + ) + } + return out + } + + private let recentEnough = Date(timeIntervalSince1970: 10_000) + private let cutoff = Date(timeIntervalSince1970: 9_000) + + @Test("Empty-to-letter transition counts as added, attributed to the writer") + func addedCount() { + let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]] + let after = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])] + let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) + #expect(deltas.count == 1) + #expect(deltas[0].gameID == gameA) + #expect(deltas[0].authorID == "alice") + #expect(deltas[0].added == 1) + #expect(deltas[0].cleared == 0) + } + + @Test("Letter-to-empty transition counts as cleared, attributed to the clearing writer") + func clearedCount() { + // Bob wrote "B" originally, then Alice's record overwrites that cell + // with an empty letter — the clear is attributed to Alice, who is + // the LWW-winning writer of the post-apply state. + let before = [gameA: snapshot([(0, 0, "B", "bob", recentEnough)])] + let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])] + let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) + #expect(deltas.count == 1) + #expect(deltas[0].authorID == "alice") + #expect(deltas[0].added == 0) + #expect(deltas[0].cleared == 1) + } + + @Test("Letter-to-different-letter contributes zero (no add, no clear)") + func overwriteIsNeutral() { + let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])] + let after = [gameA: snapshot([(0, 0, "B", "bob", recentEnough.addingTimeInterval(1))])] + let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) + #expect(deltas.isEmpty) + } + + @Test("Cells unchanged between before and after contribute zero") + func unchangedCells() { + // Re-applying the same merged-grid state (idempotent CKSyncEngine + // re-delivery) yields no deltas. + let stamp = recentEnough + let same = snapshot([(0, 0, "A", "alice", stamp)]) + let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff) + #expect(deltas.isEmpty) + } + + @Test("Cells whose winning entry predates cutoff are gated out") + func cutoffGateExcludesStale() { + let stale = Date(timeIntervalSince1970: 5_000) // well before cutoff + let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]] + let after = [gameA: snapshot([(0, 0, "A", "alice", stale)])] + let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) + #expect(deltas.isEmpty) + } + + @Test("Adds and clears in the same batch aggregate per author") + func aggregatesPerAuthor() { + let before = [gameA: snapshot([ + (0, 0, "X", "alice", recentEnough), + (0, 1, "Y", "alice", recentEnough), + ])] + let after = [gameA: snapshot([ + (0, 0, "", "alice", recentEnough.addingTimeInterval(1)), // cleared + (0, 1, "Y", "alice", recentEnough), // unchanged + (0, 2, "Z", "alice", recentEnough.addingTimeInterval(2)), // added + (0, 3, "W", "alice", recentEnough.addingTimeInterval(3)), // added + ])] + let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) + #expect(deltas.count == 1) + #expect(deltas[0].added == 2) + #expect(deltas[0].cleared == 1) + #expect(deltas[0].latestUpdate == recentEnough.addingTimeInterval(3)) + } + + @Test("Multi-device same-author writes all attribute to one bucket") + func multiDeviceSameAuthor() { + // Alice's iPad wrote A at (0,0) in the prior batch; her iPhone + // arrives with a clear at the same cell. Both writes have authorID + // = "alice", so the cleared count rolls up into one bucket — the + // hard problem the design was supposed to solve. + let before = [gameA: snapshot([(0, 0, "A", "alice", recentEnough)])] + let after = [gameA: snapshot([(0, 0, "", "alice", recentEnough.addingTimeInterval(1))])] + let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) + #expect(deltas.count == 1) + #expect(deltas[0].authorID == "alice") + #expect(deltas[0].cleared == 1) + } + + @Test("Different authors in the same game produce distinct buckets") + func separateAuthorsSeparateBuckets() { + let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [gameA: [:]] + let after = [gameA: snapshot([ + (0, 0, "A", "alice", recentEnough), + (1, 1, "B", "bob", recentEnough), + ])] + let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) + let byAuthor = Dictionary(uniqueKeysWithValues: deltas.map { ($0.authorID, $0) }) + #expect(byAuthor["alice"]?.added == 1) + #expect(byAuthor["bob"]?.added == 1) + } + + @Test("Deltas across multiple games are surfaced independently") + func multiGameDeltas() { + let before: [UUID: [GridPosition: GridStateMerger.Provenance]] = [ + gameA: [:], + gameB: [:], + ] + let after = [ + gameA: snapshot([(0, 0, "A", "alice", recentEnough)]), + gameB: snapshot([(0, 0, "B", "bob", recentEnough)]), + ] + let deltas = SyncEngine.authorDeltas(before: before, after: after, cutoff: cutoff) + #expect(deltas.count == 2) + #expect(Set(deltas.map { $0.gameID }) == Set([gameA, gameB])) + } + + @Test("LWW-loser writes don't show up: if the cell didn't change, no delta") + func lwwLoserIgnored() { + // Before: Alice already wrote A at t=20. After-apply still shows A + // at t=20 (LWW kept Alice's value despite a stale Bob write being + // applied at t=5, which lost to Alice and so doesn't change the + // merged-grid). The diff sees no transition. + let stamp = recentEnough.addingTimeInterval(20) + let same = snapshot([(0, 0, "A", "alice", stamp)]) + let deltas = SyncEngine.authorDeltas(before: [gameA: same], after: [gameA: same], cutoff: cutoff) + #expect(deltas.isEmpty) + } +} diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -0,0 +1,248 @@ +import Foundation +import Testing +import UserNotifications + +@testable import Crossmate + +/// In-memory recorder for the slice of UNUserNotificationCenter that +/// SessionMonitor uses. Lets tests inspect what would have been scheduled +/// or withdrawn without involving the system notification center. +final class RecordingNotificationScheduler: SessionNotificationScheduling, @unchecked Sendable { + private let lock = NSLock() + private var _added: [UNNotificationRequest] = [] + private var _removedPending: [String] = [] + private var _removedDelivered: [String] = [] + + var added: [UNNotificationRequest] { lock.withLock { _added } } + var removedPending: [String] { lock.withLock { _removedPending } } + var removedDelivered: [String] { lock.withLock { _removedDelivered } } + + func add(_ request: UNNotificationRequest) async throws { + lock.withLock { _added.append(request) } + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + lock.withLock { _removedPending.append(contentsOf: identifiers) } + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { + lock.withLock { _removedDelivered.append(contentsOf: identifiers) } + } +} + +@MainActor +private func makeMonitor( + scheduler: RecordingNotificationScheduler, + localAuthorID: String? = nil, + suppressionGate: @escaping @Sendable (UUID) -> Bool = { _ in false }, + clock: @escaping @Sendable () -> Date = { Date() } +) -> SessionMonitor { + SessionMonitor( + persistence: makeTestPersistence(), + localAuthorIDProvider: { localAuthorID }, + notificationCenter: scheduler, + nameLookup: { _, authorID in ("Player \(authorID)", "Puzzle") }, + suppressionGate: suppressionGate, + clock: clock + ) +} + +private func makeDelta( + gameID: UUID, + authorID: String, + added: Int = 0, + cleared: Int = 0, + latestUpdate: Date = Date() +) -> AuthorDelta { + AuthorDelta( + gameID: gameID, + authorID: authorID, + added: added, + cleared: cleared, + latestUpdate: latestUpdate + ) +} + +@Suite("SessionMonitor") +struct SessionMonitorTests { + + @Test("First delta opens a bucket and schedules the end-of-session notification") + @MainActor func ingestSchedulesNotification() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor(scheduler: scheduler) + let gameID = UUID() + await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 3)]) + + #expect(scheduler.added.count == 1) + let request = scheduler.added.first! + #expect(request.identifier == SessionMonitor.endIdentifier(for: gameID, authorID: "alice")) + #expect(request.content.body.contains("3")) + #expect(request.trigger is UNTimeIntervalNotificationTrigger) + } + + @Test("Successive deltas for the same key accumulate; the bucket totals grow") + @MainActor func tallyAccumulates() async { + let scheduler = RecordingNotificationScheduler() + // Anchor every batch at a fresh "now" so the prior trigger never + // elapses between calls — exercises the accumulation path, not the + // session-rollover path. + let monitor = makeMonitor(scheduler: scheduler, clock: { Date() }) + let gameID = UUID() + await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 2, latestUpdate: Date())]) + await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1, cleared: 4, latestUpdate: Date())]) + + let tally = await monitor.tally(gameID: gameID, authorID: "alice") + #expect(tally?.added == 3) + #expect(tally?.cleared == 4) + // Each ingest reschedules: two add() calls, both with the same id. + #expect(scheduler.added.count == 2) + } + + @Test("Self-authored deltas (sibling device) are dropped") + @MainActor func selfAuthoredSkipped() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor(scheduler: scheduler, localAuthorID: "me") + let gameID = UUID() + await monitor.ingest([ + makeDelta(gameID: gameID, authorID: "me", added: 99), + makeDelta(gameID: gameID, authorID: "alice", added: 1), + ]) + + #expect(scheduler.added.count == 1) + #expect(scheduler.added.first?.identifier + == SessionMonitor.endIdentifier(for: gameID, authorID: "alice")) + let selfTally = await monitor.tally(gameID: gameID, authorID: "me") + #expect(selfTally == nil) + } + + @Test("Zero-count deltas are dropped") + @MainActor func zeroCountSkipped() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor(scheduler: scheduler) + let gameID = UUID() + await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 0, cleared: 0)]) + + #expect(scheduler.added.isEmpty) + let tally = await monitor.tally(gameID: gameID, authorID: "alice") + #expect(tally == nil) + } + + @Test("Active-puzzle suppression skips ingestion for that game") + @MainActor func suppressionGateBlocks() async { + let scheduler = RecordingNotificationScheduler() + let suppressed = UUID() + let other = UUID() + let monitor = makeMonitor( + scheduler: scheduler, + suppressionGate: { $0 == suppressed } + ) + await monitor.ingest([ + makeDelta(gameID: suppressed, authorID: "alice", added: 5), + makeDelta(gameID: other, authorID: "alice", added: 5), + ]) + + #expect(scheduler.added.count == 1) + #expect(scheduler.added.first?.identifier + == SessionMonitor.endIdentifier(for: other, authorID: "alice")) + } + + @Test("Delta whose latestUpdate predates the quiescence window starts a fresh bucket after rollover") + @MainActor func sessionRollover() async { + let scheduler = RecordingNotificationScheduler() + let t0 = Date(timeIntervalSince1970: 1_000_000) + // After the first ingest the scheduledFor is at t0+window. Bump + // the clock past it; the next ingest must reset rather than + // accumulate. + let clock = MutableClock(initial: t0) + let monitor = makeMonitor(scheduler: scheduler, clock: clock.now) + let gameID = UUID() + await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 4, latestUpdate: t0)]) + clock.set(t0.addingTimeInterval(SessionMonitor.quiescenceWindow + 10)) + await monitor.ingest([makeDelta( + gameID: gameID, + authorID: "alice", + added: 1, + latestUpdate: clock.now() + )]) + let tally = await monitor.tally(gameID: gameID, authorID: "alice") + // Second session — first session's tally is treated as consumed. + #expect(tally?.added == 1) + } + + @Test("cancel(gameID:) drops every author's bucket for the game and withdraws the requests") + @MainActor func cancelDropsAllAuthors() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor(scheduler: scheduler) + let gameID = UUID() + await monitor.ingest([ + makeDelta(gameID: gameID, authorID: "alice", added: 1), + makeDelta(gameID: gameID, authorID: "bob", added: 1), + ]) + #expect(await monitor.pendingBucketCount == 2) + + await monitor.cancel(gameID: gameID) + #expect(await monitor.pendingBucketCount == 0) + + let removed = Set(scheduler.removedPending).union(scheduler.removedDelivered) + #expect(removed.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "alice"))) + #expect(removed.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "bob"))) + } + + @Test("cancel(gameID:authorID:) only drops the named author's bucket") + @MainActor func cancelTargetsOneAuthor() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor(scheduler: scheduler) + let gameID = UUID() + await monitor.ingest([ + makeDelta(gameID: gameID, authorID: "alice", added: 1), + makeDelta(gameID: gameID, authorID: "bob", added: 1), + ]) + await monitor.cancel(gameID: gameID, authorID: "alice") + + let aliceTally = await monitor.tally(gameID: gameID, authorID: "alice") + let bobTally = await monitor.tally(gameID: gameID, authorID: "bob") + #expect(aliceTally == nil) + #expect(bobTally?.added == 1) + } + + @Test("Scheduling a new end-of-session withdraws the matching session-begin banner") + @MainActor func endWithdrawsBegin() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor(scheduler: scheduler) + let gameID = UUID() + await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1)]) + + #expect(scheduler.removedDelivered.contains( + SessionMonitor.beginIdentifier(for: gameID, authorID: "alice") + )) + } + + @Test("Notification body pluralises and combines added + cleared") + func bodyTextWording() { + let alice = SessionMonitor.bodyText(playerName: "Alice", puzzleTitle: "Tuesday Mini", added: 1, cleared: 0) + #expect(alice == "Alice added 1 letter in the puzzle 'Tuesday Mini'") + let bob = SessionMonitor.bodyText(playerName: "Bob", puzzleTitle: "Tuesday Mini", added: 4, cleared: 2) + #expect(bob == "Bob added 4 letters and cleared 2 letters in the puzzle 'Tuesday Mini'") + let nameless = SessionMonitor.bodyText(playerName: "", puzzleTitle: "", added: 3, cleared: 0) + #expect(nameless == "A player added 3 letters in the puzzle") + } +} + +/// Mutable wall-clock for tests that need to advance time deterministically. +private final class MutableClock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + + init(initial: Date) { self.current = initial } + + func set(_ date: Date) { + lock.withLock { current = date } + } + + var now: @Sendable () -> Date { + { [weak self] in + guard let self else { return Date() } + return self.lock.withLock { self.current } + } + } +}