commit f747c91fc21655078a03d9046302309c06b2ec3e
parent 62d0d4c9e96386e502269dc77c3c29bb568b3a31
Author: Michael Camilleri <[email protected]>
Date: Wed, 27 May 2026 12:37:38 +0900
Send play/pause notifications more judiciously
Diffstat:
2 files changed, 62 insertions(+), 7 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -637,18 +637,35 @@ private struct PuzzleDisplayView: View {
private func updateActiveNotificationPuzzleID(for phase: ScenePhase) {
let id = gameID
- if phase == .active {
+ switch phase {
+ case .active:
NotificationState.setActivePuzzleID(gameID)
- // Hand any pending end-of-session tallies off to the
- // in-puzzle banner instead of letting the local notification
- // fire a few minutes from now.
+ // If a pause was queued in the grace window and the user
+ // returned before it fired, drop it and skip the matching play
+ // push — peers should see one continuous session, not a
+ // pause/play pair, for a brief absence (phone sleep, a call,
+ // app-switcher peek that escalated to background).
+ let resumed = services.cancelPendingSessionEndPush(gameID: id)
Task {
await services.handlePuzzleOpened(gameID: id)
- await services.publishSessionStartPush(gameID: id)
+ if !resumed {
+ await services.publishSessionStartPush(gameID: id)
+ }
}
- } else {
+ case .background:
NotificationState.clearActivePuzzleID(if: gameID)
- Task { await services.publishSessionEndPush(gameID: id) }
+ services.scheduleSessionEndPush(
+ gameID: id,
+ after: AppServices.sessionEndGrace
+ )
+ case .inactive:
+ // Transient (lock animation, app switcher, Control Center,
+ // banners) — the user is still on the puzzle. Toggling the
+ // active-puzzle ID or firing a pause push here would thrash
+ // both on every interruption.
+ break
+ @unknown default:
+ break
}
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -59,6 +59,12 @@ final class AppServices {
let identity: AuthorIdentity
let pushClient: PushClient?
let localSessionTracker = LocalSessionTracker()
+ /// Grace window before a backgrounded session is treated as ended. A
+ /// briefly-backgrounded puzzle (phone sleep, app switcher peek, taking a
+ /// call) should not fan out pause/play pings to peers on every flicker —
+ /// only a sustained absence does.
+ static let sessionEndGrace: TimeInterval = 120
+ private var pendingSessionEndTasks: [UUID: Task<Void, Never>] = [:]
let shareController: ShareController
let friendController: FriendController
let cursorStore: GameCursorStore
@@ -637,7 +643,39 @@ final class AppServices {
/// background-publish) skips the push entirely. Kind is `pause` — the
/// user is stepping away from a puzzle they may return to, not
/// permanently leaving the game.
+ /// Defer the session-end push by `seconds`. Cancels any previously
+ /// scheduled pause for the same game. If the user resumes within the
+ /// grace window, call `cancelPendingSessionEndPush` to drop the timer
+ /// and skip the matching session-start push so peers don't get a
+ /// pause/play pair for a brief absence.
+ func scheduleSessionEndPush(gameID: UUID, after seconds: TimeInterval) {
+ pendingSessionEndTasks[gameID]?.cancel()
+ pendingSessionEndTasks[gameID] = Task { [weak self] in
+ try? await Task.sleep(for: .seconds(seconds))
+ guard !Task.isCancelled else { return }
+ guard let self else { return }
+ self.pendingSessionEndTasks[gameID] = nil
+ await self.publishSessionEndPush(gameID: gameID)
+ }
+ }
+
+ /// Cancel any pending scheduled session-end push for `gameID`. Returns
+ /// `true` if a pending task was dropped, i.e. the caller is inside the
+ /// grace window and should suppress the matching session-start push.
+ @discardableResult
+ func cancelPendingSessionEndPush(gameID: UUID) -> Bool {
+ guard let task = pendingSessionEndTasks.removeValue(forKey: gameID) else {
+ return false
+ }
+ task.cancel()
+ return true
+ }
+
func publishSessionEndPush(gameID: UUID) async {
+ // A direct call (e.g. from `.onDisappear`) supersedes any pending
+ // grace-window timer for this game — drop it so we don't fire a
+ // second pause once the timer elapses.
+ pendingSessionEndTasks.removeValue(forKey: gameID)?.cancel()
let counts = localSessionTracker.consume(gameID: gameID)
guard counts.added > 0 || counts.cleared > 0 else {
syncMonitor.note("push(pause): skipped (no edits)")