commit a11d8034301264c3ac2e352cf27a597530590cea
parent d262e0f16d5542be8b852c75d37e20a3aa195f74
Author: Michael Camilleri <[email protected]>
Date: Fri, 26 Jun 2026 18:55:07 +0900
Heal legacy games that are erroneously unread
Diffstat:
5 files changed, 111 insertions(+), 6 deletions(-)
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -841,6 +841,36 @@ final class GameStore {
})
}
+ /// One-shot upgrade heal for the read-watermark split. Older installs only
+ /// had `lastReadOtherMoveAt`, which has since become a presence lease; rows
+ /// with nil `readThroughAt` can therefore all look unread after upgrading.
+ /// Backfill them to their current latest peer move unless a delivered
+ /// unread notification still represents that game.
+ @discardableResult
+ func backfillLegacyReadThrough(excluding excludedGameIDs: Set<UUID>) -> Int {
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(
+ format: "(databaseScope == 1 OR ckShareRecordName != nil) "
+ + "AND latestOtherMoveAt != nil "
+ + "AND readThroughAt == nil"
+ )
+ request.propertiesToFetch = ["id", "latestOtherMoveAt", "readThroughAt"]
+ let rows = (try? context.fetch(request)) ?? []
+ var changed = 0
+ for entity in rows {
+ guard let id = entity.id,
+ !excludedGameIDs.contains(id),
+ let latest = entity.latestOtherMoveAt
+ else { continue }
+ entity.readThroughAt = latest
+ changed += 1
+ }
+ guard changed > 0 else { return 0 }
+ saveContext("backfillLegacyReadThrough")
+ onUnreadOtherMovesChanged?()
+ return changed
+ }
+
private var unreadOtherMovesPredicate: NSPredicate {
// Keyed off the read *watermark* (`readThroughAt`), not the forward-
// dated presence lease (`lastReadOtherMoveAt`) — matches
diff --git a/Crossmate/Services/BadgeCoordinator.swift b/Crossmate/Services/BadgeCoordinator.swift
@@ -98,6 +98,14 @@ final class BadgeCoordinator {
/// 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 {
+ if BadgeState.claimLegacyReadThroughHealNeeded() {
+ let deliveredUnread = await deliveredUnreadGameIDs()
+ let healed = store.backfillLegacyReadThrough(excluding: deliveredUnread)
+ syncMonitor.note(
+ "app badge legacy readThrough heal: changed=\(healed) "
+ + "preservedDelivered=\(deliveredUnread.count) [\(shortIDs(deliveredUnread))]"
+ )
+ }
let coreDataUnread = store.unreadOtherMovesGameTimes()
BadgeState.seedUnread(coreDataUnread)
// Pending invites are binary (not a read horizon), so publish them as a
@@ -151,12 +159,7 @@ final class BadgeCoordinator {
guard !ledgerUnread.isEmpty else { return }
let coreDataUnread = Set(store.unreadOtherMovesGameTimes().keys)
let delivered = await UNUserNotificationCenter.current().deliveredNotifications()
- let deliveredUnread = Set(delivered.compactMap { notification -> UUID? in
- guard notificationMarksUnread(notification),
- let gameID = notificationGameID(from: notification.request.content.userInfo)
- else { return nil }
- return gameID
- })
+ let deliveredUnread = deliveredUnreadGameIDs(from: delivered)
let stale = ledgerUnread.subtracting(coreDataUnread).subtracting(deliveredUnread)
guard !stale.isEmpty else { return }
for gameID in stale {
@@ -197,6 +200,20 @@ final class BadgeCoordinator {
return kind == "pause" || kind == "win" || kind == "resign"
}
+ private func deliveredUnreadGameIDs() async -> Set<UUID> {
+ let delivered = await UNUserNotificationCenter.current().deliveredNotifications()
+ return deliveredUnreadGameIDs(from: delivered)
+ }
+
+ private func deliveredUnreadGameIDs(from delivered: [UNNotification]) -> Set<UUID> {
+ Set(delivered.compactMap { notification -> UUID? in
+ guard notificationMarksUnread(notification),
+ let gameID = notificationGameID(from: notification.request.content.userInfo)
+ else { return nil }
+ return gameID
+ })
+ }
+
private func notificationGameID(from userInfo: [AnyHashable: Any]) -> UUID? {
if let raw = userInfo["gameID"] as? String,
let id = UUID(uuidString: raw) {
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -205,6 +205,7 @@ enum BadgeState {
private static let legacyLedgerKey = "badge.ledger.v1"
private static let legacyUnreadKey = "badge.unreadGameIDs"
private static let pendingInvitesKey = "badge.pendingInvites.v1"
+ private static let legacyReadThroughHealKey = "badge.legacyReadThroughHeal.v1"
private struct Entry: Codable, Equatable {
var unreadAt: Date? = nil
@@ -364,6 +365,16 @@ enum BadgeState {
return Set(raw.compactMap(UUID.init(uuidString:)))
}
+ /// Returns true once per install after the read-watermark split, so the app
+ /// can backfill `Game.readThroughAt` for pre-split rows that only carried
+ /// the older `readAt`/presence cursor. The NSE never calls this.
+ static func claimLegacyReadThroughHealNeeded() -> Bool {
+ guard let defaults else { return false }
+ guard defaults.bool(forKey: legacyReadThroughHealKey) == false else { return false }
+ defaults.set(true, forKey: legacyReadThroughHealKey)
+ return true
+ }
+
/// Clears the entire ledger (and any legacy stores). Used by the
/// diagnostics "reset all data" path, which deletes every game at once.
static func reset() {
@@ -372,6 +383,7 @@ enum BadgeState {
defaults.removeObject(forKey: legacyLedgerKey)
defaults.removeObject(forKey: legacyUnreadKey)
defaults.removeObject(forKey: pendingInvitesKey)
+ defaults.removeObject(forKey: legacyReadThroughHealKey)
}
private static func loadLedger() -> [String: Entry] {
diff --git a/Tests/Unit/GameStoreUnreadMovesTests.swift b/Tests/Unit/GameStoreUnreadMovesTests.swift
@@ -422,6 +422,43 @@ struct GameStoreUnreadMovesTests {
#expect(store.hasUnreadOtherMoves(gameID: gameID))
}
+ @Test("Legacy read-through backfill clears phantom pre-watermark unread rows")
+ func legacyReadThroughBackfillClearsPhantomRows() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+ let (entity, gameID) = try makeSharedGame(in: ctx)
+
+ let latest = Date(timeIntervalSinceNow: -60)
+ entity.latestOtherMoveAt = latest
+ entity.readThroughAt = nil
+ entity.lastReadOtherMoveAt = nil
+ try ctx.save()
+
+ #expect(store.unreadOtherMovesGameCount() == 1)
+
+ #expect(store.backfillLegacyReadThrough(excluding: []) == 1)
+ #expect(entity.readThroughAt == latest)
+ #expect(store.unreadOtherMovesGameCount() == 0)
+ #expect(!store.hasUnreadOtherMoves(gameID: gameID))
+ }
+
+ @Test("Legacy read-through backfill preserves delivered unread games")
+ func legacyReadThroughBackfillPreservesDeliveredUnread() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+ let (entity, gameID) = try makeSharedGame(in: ctx)
+
+ entity.latestOtherMoveAt = Date(timeIntervalSinceNow: -60)
+ entity.readThroughAt = nil
+ try ctx.save()
+
+ #expect(store.backfillLegacyReadThrough(excluding: [gameID]) == 0)
+ #expect(entity.readThroughAt == nil)
+ #expect(store.unreadOtherMovesGameCount() == 1)
+ }
+
@Test("Active read leases refresh only when the horizon is below the floor")
func activeReadLeaseRefreshesAtFloor() throws {
let persistence = makeTestPersistence()
diff --git a/Tests/Unit/NotificationStateTests.swift b/Tests/Unit/NotificationStateTests.swift
@@ -258,4 +258,13 @@ struct NotificationStateTests {
#expect(BadgeState.unreadGameIDs().isEmpty)
#expect(BadgeState.pendingInviteGameIDs().isEmpty)
}
+
+ @Test("Legacy read-through heal is claimed once")
+ func legacyReadThroughHealClaimedOnce() {
+ #expect(BadgeState.claimLegacyReadThroughHealNeeded())
+ #expect(!BadgeState.claimLegacyReadThroughHealNeeded())
+
+ BadgeState.reset()
+ #expect(BadgeState.claimLegacyReadThroughHealNeeded())
+ }
}