crossmate

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

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:
MCrossmate/Persistence/GameStore.swift | 30++++++++++++++++++++++++++++++
MCrossmate/Services/BadgeCoordinator.swift | 29+++++++++++++++++++++++------
MShared/NotificationState.swift | 12++++++++++++
MTests/Unit/GameStoreUnreadMovesTests.swift | 37+++++++++++++++++++++++++++++++++++++
MTests/Unit/NotificationStateTests.swift | 9+++++++++
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()) + } }