crossmate

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

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:
MCrossmate/Persistence/GameStore.swift | 22++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 26+++++++++++++++++++-------
MNotificationService/NotificationService.swift | 4++--
MShared/NotificationState.swift | 18++++++++++++++++++
MTests/Unit/NotificationStateTests.swift | 18++++++++++++++++++
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])) + } }