commit c4b2efa3eec3297105a08e2d1891c3c4583245b3
parent 7f5113748ab91f3cb612eb77a82513f7213d5e74
Author: Michael Camilleri <[email protected]>
Date: Thu, 4 Jun 2026 14:43:21 +0900
Track badge unread state with horizons
This commit replaces the shared badge unread set with a per-game horizon
ledger stored in the app group. Each game now records the latest unread
activity time and the latest seen time, and only counts toward the badge
when unreadAt is newer than seenAt. This lets the app and notification
service apply idempotent unread and seen updates without an older
delivery resurrecting a cleared badge.
The app and the NSE now use that ledger when clearing delivered
notifications for an opened puzzle. The app badge refresh reads the
notification-service ledger and Core Data unread games together, but no
longer writes Core Data unread IDs back into the shared ledger. That
avoids preserving stale notification-service state after a game has
already been marked seen.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
4 files changed, 121 insertions(+), 52 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -2790,7 +2790,7 @@ final class AppServices {
/// copy), and the unread-moves badge converges via `Player.readAt`.
///
/// Every dismissal path is also a "user has seen this game" signal, so
- /// we drop `gameID` from `BadgeState.unreadGameIDs` and refresh the
+ /// we advance the App Group badge ledger's seen horizon and refresh the
/// app-icon badge. Without this, pause/win/resign entries added by the
/// Notification Service Extension would otherwise linger past the point
/// where their banners have already been withdrawn.
@@ -2808,7 +2808,7 @@ final class AppServices {
center.removeDeliveredNotifications(withIdentifiers: identifiers)
syncMonitor.note("notif: dismissed \(identifiers.count) delivered for \(gameID.uuidString)")
}
- BadgeState.clearUnread(gameID: gameID)
+ BadgeState.markSeen(gameID: gameID)
await refreshAppBadge()
}
@@ -2905,17 +2905,16 @@ final class AppServices {
}
}
- /// Sets the app icon badge to the cardinality of `BadgeState.unreadGameIDs`
- /// — Core Data ground truth (the `hasUnreadOtherMoves` heuristic that
- /// drives the per-row dot in the library list) unioned with any
- /// pause/win/resign games the Notification Service Extension added since
- /// the last refresh. Silently no-ops when the user hasn't granted badge
- /// permission; iOS just won't render the value.
+ /// Sets the app icon badge to Core Data ground truth (the
+ /// `hasUnreadOtherMoves` heuristic that drives the per-row dot in the
+ /// library list) unioned with provisional push-side unread entries the
+ /// Notification Service Extension added since the last refresh. Core Data
+ /// unread IDs are not written back into `BadgeState`; that ledger is only
+ /// the NSE/local-notification input, so stale push entries can be defeated
+ /// by `markSeen`.
func refreshAppBadge() async {
let coreDataUnread = store.unreadOtherMovesGameIDs()
- var merged = BadgeState.unreadGameIDs()
- merged.formUnion(coreDataUnread)
- BadgeState.setUnreadGameIDs(merged)
+ let merged = BadgeState.unreadGameIDs().union(coreDataUnread)
do {
try await UNUserNotificationCenter.current().setBadgeCount(merged.count)
} catch {
diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift
@@ -4,19 +4,19 @@ import UserNotifications
/// arrives with `mutable-content: 1`, with ~30s to mutate the content before
/// iOS displays it.
///
-/// Crossmate uses the NSE for one job only: keep the app-icon badge accurate
-/// when push notifications land while the main app is suspended or
-/// terminated. The badge model is "shared games with new activity worth
-/// surfacing"; we track that as a set of game UUIDs in App Group
-/// UserDefaults (`BadgeState`) and stamp the resulting count on the outgoing
-/// notification's `badge` field. Once the main app foregrounds it unions
-/// Core Data ground truth into the same set and re-stamps.
+/// Crossmate uses the NSE for one job only: keep the app-icon badge close to
+/// accurate when push notifications land while the main app is suspended or
+/// terminated. The push-side badge model is a per-game horizon ledger in App
+/// Group UserDefaults (`BadgeState`): pushes advance `unreadAt`, while the app
+/// advances `seenAt` when the user opens the puzzle. Once the main app runs it
+/// unions this provisional push ledger with Core Data ground truth and
+/// re-stamps the badge.
///
/// Whether a push marks its game unread is decided from the per-recipient
/// `PushPayload` the sender encodes (forwarded opaquely by the worker):
/// - a `pause` with unseen cells, or a `win` / `resign` — mark `gameID`
-/// unread. The set semantics make repeats idempotent (a pause followed by
-/// a win for the same game is one badge unit, not two).
+ /// unread. Per-game horizon semantics make repeats idempotent (a pause
+ /// followed by a win for the same game is one badge unit, not two).
/// - a `pause` with zero counts (a presence-only "stopped solving") or a
/// `play` — presence only; the grid has nothing unseen for this recipient,
/// so stamp the current count without growing it.
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -177,54 +177,96 @@ enum NotificationState {
static var sharedDefaultsForSiblings: UserDefaults? { defaults }
}
-/// App-icon badge model, persisted in the same App Group as
+/// App-icon badge ledger, persisted in the same App Group as
/// `NotificationState` so the Notification Service Extension can mutate it
-/// from a separate process when an APNs alert arrives. The badge value is
-/// the cardinality of `unreadGameIDs` — a set of shared games with unread
-/// other-author activity (moves merged via the sync engine, plus pause /
-/// win / resign pushes the NSE added between launches). The main app's
-/// `refreshAppBadge` unions the Core Data-derived ground truth into this
-/// set on every refresh; opening a game clears its entry.
+/// from a separate process when an APNs alert arrives. This ledger is only the
+/// provisional push-side input; Core Data remains the app's synced ground truth
+/// for Moves-derived unread state.
+///
+/// Each game tracks the newest push-side unread event and the newest local
+/// seen horizon. A game is unread from this ledger only when
+/// `unreadAt > seenAt`, so opening a puzzle can defeat stale NSE entries rather
+/// than fighting the old "union forever" set semantics.
enum BadgeState {
- private static let unreadKey = "badge.unreadGameIDs"
+ private static let ledgerKey = "badge.ledger.v1"
+ private static let legacyUnreadKey = "badge.unreadGameIDs"
+
+ private struct Entry: Codable, Equatable {
+ var unreadAt: Date? = nil
+ var seenAt: Date? = nil
+ }
private static var defaults: UserDefaults? {
NotificationState.sharedDefaultsForSiblings
}
static func unreadGameIDs() -> Set<UUID> {
- guard let raw = defaults?.array(forKey: unreadKey) as? [String] else {
- return []
+ let ledger = loadLedger()
+ return Set(ledger.compactMap { key, entry in
+ guard let gameID = UUID(uuidString: key),
+ let unreadAt = entry.unreadAt,
+ unreadAt > (entry.seenAt ?? .distantPast)
+ else { return nil }
+ return gameID
+ })
+ }
+
+ /// Records a push-side unread event. Returns the resulting ledger-only
+ /// unread count so the NSE can stamp the outgoing APNs badge.
+ @discardableResult
+ static func markUnread(gameID: UUID, at time: Date = Date()) -> Int {
+ var ledger = loadLedger()
+ var entry = ledger[gameID.uuidString] ?? Entry()
+ if (entry.unreadAt ?? .distantPast) < time {
+ entry.unreadAt = time
}
- return Set(raw.compactMap(UUID.init(uuidString:)))
+ ledger[gameID.uuidString] = entry
+ saveLedger(ledger)
+ return unreadGameIDs().count
}
- static func setUnreadGameIDs(_ ids: Set<UUID>) {
- guard let defaults else { return }
- if ids.isEmpty {
- defaults.removeObject(forKey: unreadKey)
- } else {
- defaults.set(ids.map(\.uuidString), forKey: unreadKey)
+ /// Records that the user has seen this game on this device. Returns the
+ /// resulting ledger-only unread count.
+ @discardableResult
+ static func markSeen(gameID: UUID, at time: Date = Date()) -> Int {
+ var ledger = loadLedger()
+ var entry = ledger[gameID.uuidString] ?? Entry()
+ if (entry.seenAt ?? .distantPast) < time {
+ entry.seenAt = time
}
+ ledger[gameID.uuidString] = entry
+ saveLedger(ledger)
+ return unreadGameIDs().count
}
- /// Adds `gameID` to the unread set. Returns the resulting count so the
- /// caller (typically the NSE) can stamp the APNs badge in one step.
- @discardableResult
- static func markUnread(gameID: UUID) -> Int {
- var current = unreadGameIDs()
- current.insert(gameID)
- setUnreadGameIDs(current)
- return current.count
+ private static func loadLedger() -> [String: Entry] {
+ guard let defaults else { return [:] }
+ if let data = defaults.data(forKey: ledgerKey),
+ let ledger = try? JSONDecoder().decode([String: Entry].self, from: data) {
+ return ledger
+ }
+ guard let raw = defaults.array(forKey: legacyUnreadKey) as? [String] else {
+ return [:]
+ }
+ let now = Date()
+ let migrated = Dictionary(uniqueKeysWithValues: raw.compactMap { rawID -> (String, Entry)? in
+ guard UUID(uuidString: rawID) != nil else { return nil }
+ return (rawID, Entry(unreadAt: now, seenAt: nil))
+ })
+ saveLedger(migrated)
+ defaults.removeObject(forKey: legacyUnreadKey)
+ return migrated
}
- /// Removes `gameID` from the unread set. Returns the resulting count.
- @discardableResult
- static func clearUnread(gameID: UUID) -> Int {
- var current = unreadGameIDs()
- current.remove(gameID)
- setUnreadGameIDs(current)
- return current.count
+ private static func saveLedger(_ ledger: [String: Entry]) {
+ guard let defaults else { return }
+ if ledger.isEmpty {
+ defaults.removeObject(forKey: ledgerKey)
+ return
+ }
+ if let data = try? JSONEncoder().encode(ledger) {
+ defaults.set(data, forKey: ledgerKey)
+ }
}
}
diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift
@@ -63,4 +63,32 @@ struct NotificationStateTests {
))
NotificationState.setActivePuzzleID(nil)
}
+
+ @Test("Badge ledger marks unread then seen by horizon")
+ func badgeLedgerMarksUnreadThenSeen() {
+ let gameID = UUID()
+ let unreadAt = Date(timeIntervalSince1970: 10_000)
+ let seenAt = unreadAt.addingTimeInterval(1)
+
+ #expect(BadgeState.markUnread(gameID: gameID, at: unreadAt) == 1)
+ #expect(BadgeState.unreadGameIDs() == Set([gameID]))
+
+ #expect(BadgeState.markSeen(gameID: gameID, at: seenAt) == 0)
+ #expect(BadgeState.unreadGameIDs().isEmpty)
+ }
+
+ @Test("Older unread events do not beat newer seen horizon")
+ func olderUnreadDoesNotBeatSeen() {
+ let gameID = UUID()
+ let seenAt = Date(timeIntervalSince1970: 20_000)
+ let staleUnread = seenAt.addingTimeInterval(-10)
+ let freshUnread = seenAt.addingTimeInterval(10)
+
+ #expect(BadgeState.markSeen(gameID: gameID, at: seenAt) == 0)
+ #expect(BadgeState.markUnread(gameID: gameID, at: staleUnread) == 0)
+ #expect(BadgeState.unreadGameIDs().isEmpty)
+
+ #expect(BadgeState.markUnread(gameID: gameID, at: freshUnread) == 1)
+ #expect(BadgeState.unreadGameIDs() == Set([gameID]))
+ }
}