commit c4f2236fc7d50c327630403eec22624ba37916d4
parent 273d4a33bb6d22e17d5cc0954fa19b1d566978dc
Author: Michael Camilleri <[email protected]>
Date: Thu, 4 Jun 2026 15:33:32 +0900
Seed Core Data unread values into the badge ledger for the NSE
The badge refresh unioned Core Data's unread-other-moves games into the
displayed count but no longer wrote them back into the app-group ledger.
The Notification Service Extension can't reach Core Data, so when a push
landed on a suspended app it re-stamped the badge from the ledger alone
and dropped any game whose unread state had arrived purely via CloudKit
sync — an undercount that only corrected on the next foreground.
The app now seeds those games back into the ledger as unreadAt horizons,
each stamped with the game's newest unseen other-author move time
(latestOtherMoveAt). This is reliable in a way per-move pushes are not,
and it is safe only because the ledger is a horizon map rather than a
set: re-seeding a game the user has since opened is a no-op, because its
newer seenAt still wins. That is the case the old set-based ledger
couldn't express, which is why the write-back was dropped when horizons
first landed and why it is safe to reinstate now.
This commit also stops re-publishing the account push-address Decision
on its existing-address fast path. enqueueDecision is durable across
launches and convergence rides the LWW conflict callback, so
re-asserting it on every accountSeen/reconcile only stamped a fresh
createdAt and re-sent an unchanged record.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
5 files changed, 79 insertions(+), 9 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -483,6 +483,28 @@ final class GameStore {
return Set(rows.compactMap(\.id))
}
+ /// The same heuristic as `unreadOtherMovesGameIDs`, but paired with each
+ /// game's newest unseen other-author move time (`latestOtherMoveAt`, which
+ /// the predicate guarantees is non-nil). The app seeds these into the App
+ /// Group `BadgeState` ledger as `unreadAt` horizons so the Notification
+ /// Service Extension — which can't reach Core Data — inherits this ground
+ /// truth when it stamps the badge for a push that lands while the app is
+ /// suspended. The timestamp is what keeps the seed safe: a game the user
+ /// has since opened carries a newer `seenAt` and won't resurrect.
+ func unreadOtherMovesGameTimes() -> [UUID: Date] {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = unreadOtherMovesPredicate
+ request.propertiesToFetch = ["id", "latestOtherMoveAt"]
+ let rows = (try? context.fetch(request)) ?? []
+ var result: [UUID: Date] = [:]
+ for row in rows {
+ if let id = row.id, let at = row.latestOtherMoveAt {
+ result[id] = at
+ }
+ }
+ return result
+ }
+
private var unreadOtherMovesPredicate: NSPredicate {
NSPredicate(
format: "(databaseScope == 1 OR ckShareRecordName != nil) "
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -746,7 +746,12 @@ final class AppServices {
private func ensureAccountPushAddress(authorID: String) -> String {
let key = accountPushAddressDefaultsKey(authorID: authorID)
if let existing = UserDefaults.standard.string(forKey: key), !existing.isEmpty {
- publishAccountPushAddressDecision(existing)
+ // Already minted and published (or learned from a sibling). The
+ // Decision write is durable across launches via CKSyncEngine's
+ // pending-change queue and convergence rides the LWW conflict
+ // callback, so there's nothing to re-assert here — re-publishing
+ // would stamp a fresh `createdAt` and re-upload the record on every
+ // `accountSeen`/reconcile for a value that never changes.
return existing
}
let address = "acct-\(UUID().uuidString)"
@@ -2930,13 +2935,20 @@ final class AppServices {
/// 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`.
+ /// Notification Service Extension added since the last refresh.
+ ///
+ /// Core Data unread games are seeded into `BadgeState` as `unreadAt`
+ /// horizons stamped with each game's newest unseen other-author move time.
+ /// The NSE can't reach Core Data, so without this seed a push landing on a
+ /// suspended app would re-stamp the badge from the ledger alone and drop
+ /// any game whose unread state arrived purely via CloudKit sync. Seeding is
+ /// safe under horizon semantics — a game the user has since opened carries a
+ /// newer `seenAt` and won't resurrect — which the old set-based ledger
+ /// couldn't express, hence why this write-back was previously dropped.
func refreshAppBadge() async {
- let coreDataUnread = store.unreadOtherMovesGameIDs()
- let merged = BadgeState.unreadGameIDs().union(coreDataUnread)
+ let coreDataUnread = store.unreadOtherMovesGameTimes()
+ BadgeState.seedUnread(coreDataUnread)
+ let merged = BadgeState.unreadGameIDs().union(coreDataUnread.keys)
do {
try await UNUserNotificationCenter.current().setBadgeCount(merged.count)
} catch {
diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift
@@ -15,8 +15,8 @@ import UserNotifications
/// 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. Per-game horizon 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
@@ -239,6 +239,24 @@ enum BadgeState {
return unreadGameIDs().count
}
+ /// Bulk-applies push-side unread horizons in a single load/save — one
+ /// `markUnread`-equivalent per entry. The app uses this to seed Core Data
+ /// ground truth into the ledger so the NSE inherits it while the app is
+ /// suspended; horizon semantics make a re-seed of an already-seen game a
+ /// no-op (its newer `seenAt` still wins).
+ static func seedUnread(_ times: [UUID: Date]) {
+ guard !times.isEmpty else { return }
+ var ledger = loadLedger()
+ for (gameID, time) in times {
+ var entry = ledger[gameID.uuidString] ?? Entry()
+ if (entry.unreadAt ?? .distantPast) < time {
+ entry.unreadAt = time
+ }
+ ledger[gameID.uuidString] = entry
+ }
+ saveLedger(ledger)
+ }
+
private static func loadLedger() -> [String: Entry] {
guard let defaults else { return [:] }
if let data = defaults.data(forKey: ledgerKey),
diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift
@@ -91,4 +91,22 @@ struct NotificationStateTests {
#expect(BadgeState.markUnread(gameID: gameID, at: freshUnread) == 1)
#expect(BadgeState.unreadGameIDs() == Set([gameID]))
}
+
+ @Test("Seeding Core Data unread surfaces games but never resurrects a seen one")
+ func seedUnreadRespectsSeenHorizon() {
+ let fresh = UUID()
+ let alreadySeen = UUID()
+ let base = Date(timeIntervalSince1970: 30_000)
+
+ // The user has already opened `alreadySeen` more recently than its
+ // latest move — the NSE seed must not bring it back.
+ BadgeState.markSeen(gameID: alreadySeen, at: base.addingTimeInterval(10))
+
+ BadgeState.seedUnread([
+ fresh: base,
+ alreadySeen: base
+ ])
+
+ #expect(BadgeState.unreadGameIDs() == Set([fresh]))
+ }
}