crossmate

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

commit 7308d28801de6ccf66ff6f7b85766ed3be348aad
parent d71aee9b866565002fb14122be9b965694741a9f
Author: Michael Camilleri <[email protected]>
Date:   Thu, 21 May 2026 14:07:09 +0900

Move session notification policy into SessionMonitor

This commit makes SessionMonitor the owner of the session-begin notification
policy, alongside its existing session-end summary handling. AppServices now
only routes background session scan results into the monitor and logs the
returned diagnostic notes.

This gives SessionMonitor responsibility for begin notification authorization,
self-authored filtering, completed-game suppression, active-puzzle suppression,
per-author dedup, notification identifiers, and body text. It also keeps the
begin dedup gate intact when scheduling a richer session-end summary,
preventing repeated 'started playing' banners during the same active session.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 85+++----------------------------------------------------------------------------
MCrossmate/Sync/SessionMonitor.swift | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MShared/NotificationState.swift | 5+----
MTests/Unit/PuzzleNotificationTextTests.swift | 2+-
MTests/Unit/Sync/SessionMonitorTests.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 184 insertions(+), 91 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -765,7 +765,9 @@ final class AppServices { } if let result { await presentPings(result.0) - await presentSessionBegins(result.1) + for note in await sessionMonitor.presentBegins(result.1) { + syncMonitor.note(note) + } } scheduleBackgroundPushCatchUp(scope: scope) await refreshSnapshot() @@ -1306,81 +1308,6 @@ final class AppServices { } } - private func presentSessionBegins( - _ sessions: [Session] - ) async { - guard !sessions.isEmpty else { return } - guard await canPresentNotifications() else { - syncMonitor.note("session-begin: local notification skipped — authorization not granted") - return - } - - // A session ("X is playing") is stale once X has completed the puzzle. - // The durable signal is the Game record's `completedBy` (the solver of - // a win; nil for a resignation, which therefore never supersedes), not - // a transient win ping. Keyed gameID|authorID as before. - let sessionGameIDs = Set(sessions.map { $0.gameID }) - let ctx = persistence.container.newBackgroundContext() - let completedByAuthor: Set<String> = ctx.performAndWait { - var keys = Set<String>() - for gid in sessionGameIDs { - let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") - req.predicate = NSPredicate(format: "id == %@", gid as CVarArg) - req.fetchLimit = 1 - guard let g = try? ctx.fetch(req).first, - g.completedAt != nil, - let by = g.completedBy, !by.isEmpty - else { continue } - keys.insert("\(gid.uuidString)|\(by)") - } - return keys - } - let center = UNUserNotificationCenter.current() - for session in sessions { - if session.authorID == identity.currentID { - syncMonitor.note("session-begin: skipped self-authored record \(session.recordName)") - continue - } - if completedByAuthor.contains("\(session.gameID.uuidString)|\(session.authorID)") { - syncMonitor.note("session-begin: suppressed — author completed \(session.gameID.uuidString)") - continue - } - if NotificationState.isSuppressed(gameID: session.gameID) { - 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, 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 - } - - let content = UNMutableNotificationContent() - content.title = "Crossmate" - content.body = Self.bodyText(for: session) - content.sound = .default - content.userInfo = [ - "crossmateGameID": session.gameID.uuidString, - "crossmateAuthorID": session.authorID, - "crossmateActivity": "session-begin" - ] - - let request = UNNotificationRequest( - identifier: "session-begin-\(session.gameID.uuidString)-\(session.authorID)", - content: content, - trigger: nil - ) - do { - try await center.add(request) - 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-begin: local notification failed — \(error.localizedDescription)") - } - } - } - private func canPresentNotifications() async -> Bool { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() @@ -1460,12 +1387,6 @@ final class AppServices { return phrases.joined(separator: "; ") } - 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/Sync/SessionMonitor.swift b/Crossmate/Sync/SessionMonitor.swift @@ -54,6 +54,7 @@ actor SessionMonitor { 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 notificationAuthorization: @Sendable () async -> Bool private let clock: @Sendable () -> Date init( @@ -62,12 +63,22 @@ actor SessionMonitor { notificationCenter: SessionNotificationScheduling = UNUserNotificationCenter.current(), nameLookup: (@Sendable (UUID, String) async -> (playerName: String, puzzleTitle: String))? = nil, suppressionGate: @escaping @Sendable (UUID) -> Bool = { NotificationState.isSuppressed(gameID: $0) }, + notificationAuthorization: @escaping @Sendable () async -> Bool = { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + default: + return false + } + }, clock: @escaping @Sendable () -> Date = { Date() } ) { self.persistence = persistence self.localAuthorIDProvider = localAuthorIDProvider self.notificationCenter = notificationCenter self.suppressionGate = suppressionGate + self.notificationAuthorization = notificationAuthorization self.clock = clock if let nameLookup { self.nameLookup = nameLookup @@ -82,6 +93,68 @@ actor SessionMonitor { } } + /// Presents inferred session-start notifications from recently changed + /// Player records. This owns the full policy for "X is solving": local + /// author filtering, completed-game suppression, active-puzzle + /// suppression, per-author dedup, request identifiers, and body text. + /// + /// Returns diagnostics messages for the caller's sync log; scheduling + /// decisions remain internal to the monitor. + func presentBegins(_ sessions: [Session]) async -> [String] { + guard !sessions.isEmpty else { return [] } + guard await notificationAuthorization() else { + return ["session-begin: local notification skipped — authorization not granted"] + } + + let localAuthorID = await localAuthorIDProvider() + let completedByAuthor = completedAuthorKeys(for: Set(sessions.map(\.gameID))) + var notes: [String] = [] + for session in sessions { + if session.authorID == localAuthorID { + notes.append("session-begin: skipped self-authored record \(session.recordName)") + continue + } + if completedByAuthor.contains(Self.compositeKey(gameID: session.gameID, authorID: session.authorID)) { + notes.append("session-begin: suppressed — author completed \(session.gameID.uuidString)") + continue + } + if suppressionGate(session.gameID) { + NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID) + notes.append("session-begin: suppressed — puzzle is active for \(session.gameID.uuidString)") + continue + } + if NotificationState.wasRecentlyShown(gameID: session.gameID, authorID: session.authorID) { + NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID) + notes.append("session-begin: dedup-suppressed for \(session.gameID.uuidString) author=\(session.authorID)") + continue + } + + let content = UNMutableNotificationContent() + content.title = "Crossmate" + content.body = Self.bodyText(for: session) + content.sound = .default + content.userInfo = [ + "crossmateGameID": session.gameID.uuidString, + "crossmateAuthorID": session.authorID, + "crossmateActivity": "session-begin" + ] + + let request = UNNotificationRequest( + identifier: Self.beginIdentifier(for: session.gameID, authorID: session.authorID), + content: content, + trigger: nil + ) + do { + try await notificationCenter.add(request) + NotificationState.recordShown(gameID: session.gameID, authorID: session.authorID) + notes.append("session-begin: queued local notification for \(session.gameID.uuidString) author=\(session.authorID)") + } catch { + notes.append("session-begin: local notification failed — \(error.localizedDescription)") + } + } + return notes + } + /// 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 @@ -215,13 +288,14 @@ actor SessionMonitor { 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. + // superseded by this richer end-of-session summary. Keep the + // begin dedup gate intact, though: this scheduling happens while + // the current session is still active, and clearing it here lets + // later Player-record pushes re-fire "started playing" banners + // for the same session. 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 @@ -256,6 +330,29 @@ actor SessionMonitor { } } + private func completedAuthorKeys(for gameIDs: Set<UUID>) -> Set<String> { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + var keys = Set<String>() + for gameID in gameIDs { + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + req.fetchLimit = 1 + guard let game = try? ctx.fetch(req).first, + game.completedAt != nil, + let authorID = game.completedBy, + !authorID.isEmpty + else { continue } + keys.insert(Self.compositeKey(gameID: gameID, authorID: authorID)) + } + return keys + } + } + + private static func compositeKey(gameID: UUID, authorID: String) -> String { + "\(gameID.uuidString)|\(authorID)" + } + static func endIdentifier(for gameID: UUID, authorID: String) -> String { "session-end-\(gameID.uuidString)-\(authorID)" } @@ -290,4 +387,10 @@ actor SessionMonitor { let action = parts.isEmpty ? "made changes" : parts.joined(separator: " and ") return "\(name) \(action) in \(suffix)" } + + 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)" + } } diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -97,10 +97,7 @@ enum NotificationState { 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. + /// Drops the session-begin dedup entry for `(gameID, authorID)`. static func clearShown(gameID: UUID, authorID: String) { guard let defaults else { return } var map = shownMap() diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -85,6 +85,6 @@ struct PuzzleNotificationTextTests { updatedAt: Date() ) - #expect(AppServices.bodyText(for: session) == "Alice is solving the puzzle 'Saturday Puzzle – 1 January 2001'") + #expect(SessionMonitor.bodyText(for: session) == "Alice is solving the puzzle 'Saturday Puzzle – 1 January 2001'") } } diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -35,6 +35,7 @@ private func makeMonitor( scheduler: RecordingNotificationScheduler, localAuthorID: String? = nil, suppressionGate: @escaping @Sendable (UUID) -> Bool = { _ in false }, + notificationAuthorization: @escaping @Sendable () async -> Bool = { true }, clock: @escaping @Sendable () -> Date = { Date() } ) -> SessionMonitor { SessionMonitor( @@ -43,6 +44,7 @@ private func makeMonitor( notificationCenter: scheduler, nameLookup: { _, authorID in ("Player \(authorID)", "Puzzle") }, suppressionGate: suppressionGate, + notificationAuthorization: notificationAuthorization, clock: clock ) } @@ -80,6 +82,52 @@ struct SessionMonitorTests { #expect(request.trigger is UNTimeIntervalNotificationTrigger) } + @Test("Session-begin presentation schedules once and dedups repeated sightings") + @MainActor func presentBeginSchedulesAndDedups() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor(scheduler: scheduler) + let gameID = UUID() + NotificationState.clearShown(gameID: gameID, authorID: "alice") + let session = Session( + recordName: "player-\(gameID.uuidString)-alice", + gameID: gameID, + authorID: "alice", + playerName: "Alice", + puzzleTitle: "Puzzle", + updatedAt: Date() + ) + + let firstNotes = await monitor.presentBegins([session]) + let secondNotes = await monitor.presentBegins([session]) + + #expect(scheduler.added.count == 1) + #expect(scheduler.added.first?.identifier == SessionMonitor.beginIdentifier(for: gameID, authorID: "alice")) + #expect(firstNotes.contains("session-begin: queued local notification for \(gameID.uuidString) author=alice")) + #expect(secondNotes.contains("session-begin: dedup-suppressed for \(gameID.uuidString) author=alice")) + NotificationState.clearShown(gameID: gameID, authorID: "alice") + } + + @Test("Session-begin presentation respects notification authorization") + @MainActor func presentBeginAuthorizationSkipped() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor( + scheduler: scheduler, + notificationAuthorization: { false } + ) + let gameID = UUID() + let notes = await monitor.presentBegins([Session( + recordName: "player-\(gameID.uuidString)-alice", + gameID: gameID, + authorID: "alice", + playerName: "Alice", + puzzleTitle: "Puzzle", + updatedAt: Date() + )]) + + #expect(scheduler.added.isEmpty) + #expect(notes == ["session-begin: local notification skipped — authorization not granted"]) + } + @Test("Successive deltas for the same key accumulate; the bucket totals grow") @MainActor func tallyAccumulates() async { let scheduler = RecordingNotificationScheduler() @@ -217,6 +265,30 @@ struct SessionMonitorTests { )) } + @Test("Scheduling an end-of-session summary keeps the session-begin dedup gate") + @MainActor func endSchedulingKeepsBeginDedup() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor(scheduler: scheduler) + let gameID = UUID() + NotificationState.clearShown(gameID: gameID, authorID: "alice") + let session = Session( + recordName: "player-\(gameID.uuidString)-alice", + gameID: gameID, + authorID: "alice", + playerName: "Alice", + puzzleTitle: "Puzzle", + updatedAt: Date() + ) + + _ = await monitor.presentBegins([session]) + await monitor.ingest([makeDelta(gameID: gameID, authorID: "alice", added: 1)]) + let notes = await monitor.presentBegins([session]) + + #expect(scheduler.added.filter { $0.identifier == SessionMonitor.beginIdentifier(for: gameID, authorID: "alice") }.count == 1) + #expect(notes.contains("session-begin: dedup-suppressed for \(gameID.uuidString) author=alice")) + NotificationState.clearShown(gameID: gameID, authorID: "alice") + } + @Test("End-of-session summary uses the same puzzle title as session start") @MainActor func endSummaryUsesFormattedPuzzleTitle() async throws { let persistence = makeTestPersistence()