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:
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()