crossmate

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

commit 680423f157152c75168904fbfc84278802510857
parent 2f1e113b928f99a777d93d195b106f2f7c0746a1
Author: Michael Camilleri <[email protected]>
Date:   Mon,  8 Jun 2026 07:45:34 +0900

Prune stale invite and badge rows

This commit makes the game-list invite refresh reconcile durable local
InviteEntity rows against the friend zones that were actually scanned.
When a successful scan no longer returns the Ping record backing a
pending invite, the local row is removed and the badge is refreshed.
This clears invitations that were already consumed on another device
before the newer decline/delete path existed, without treating failed or
unscanned friend zones as authoritative.

Startup badge cleanup now also checks whether provisional badge-ledger
entries are still backed by either Core Data unread state or a delivered
badge-worthy notification. Presence-only notifications such as play,
replay, and zero-count pause no longer keep an old ledger entry alive,
while push-ahead-of-sync unread notifications remain protected by their
delivered payload.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 48+++++++++++++++++++++++++++++++++++++++++++++---
MCrossmate/Sync/CloudQuery.swift | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
2 files changed, 136 insertions(+), 12 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -365,8 +365,9 @@ final class AppServices { guard let self else { return } Task { await self.refreshAppBadge() } } - await refreshAppBadge() importVisibleNotificationReceipts() + await reconcileBadgeLedgerWithDeliveredNotifications() + await refreshAppBadge() await logNotificationStartupSnapshot() appDelegate.onRemoteNotification = { @@ -1770,8 +1771,11 @@ final class AppServices { let catchUpResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) game/moves") { try await self.syncEngine.fetchKnownGameMovesDirect(scope: scope) } - await syncMonitor.run("freshen game list \(reasonLabel): \(label) invites") { - _ = try await self.syncEngine.fetchFriendInvitesDirect(scope: scope) + let inviteResult: Int? = await syncMonitor.run("freshen game list \(reasonLabel): \(label) invites") { + try await self.syncEngine.fetchFriendInvitesDirect(scope: scope) + } + if inviteResult != nil { + await refreshAppBadge() } if catchUpResult != nil { noteGameListFreshenCompleted(scope: scope) @@ -3216,6 +3220,35 @@ final class AppServices { } } + /// Clears stale provisional badge-ledger entries that are no longer backed + /// by either synced unread state or a delivered badge-worthy notification. + /// This keeps presence-only notifications (`play`, `replay`, zero-count + /// pause) from keeping an old ledger entry alive while still preserving + /// push-ahead-of-sync unread entries when their delivered notification is + /// visible on the device. + private func reconcileBadgeLedgerWithDeliveredNotifications() async { + let ledgerUnread = BadgeState.unreadGameIDs() + 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 stale = ledgerUnread.subtracting(coreDataUnread).subtracting(deliveredUnread) + guard !stale.isEmpty else { return } + for gameID in stale { + BadgeState.forget(gameID: gameID) + } + syncMonitor.note( + "app badge ledger reconcile: cleared=\(stale.count) [\(shortIDs(stale))] " + + "coreData=\(coreDataUnread.count) [\(shortIDs(coreDataUnread))] " + + "deliveredUnread=\(deliveredUnread.count) [\(shortIDs(deliveredUnread))]" + ) + } + private func notificationSummary(_ notification: UNNotification) -> String { let request = notification.request let content = request.content @@ -3235,6 +3268,15 @@ final class AppServices { + " body=\"\(logEscaped(content.body))\"" } + private func notificationMarksUnread(_ notification: UNNotification) -> Bool { + let userInfo = notification.request.content.userInfo + if let payload = PushPayload.decode(from: userInfo["payload"] as? String) { + return payload.marksUnread + } + let kind = userInfo["kind"] as? String + return kind == "pause" || kind == "win" || kind == "resign" + } + private func notificationGameID(from userInfo: [AnyHashable: Any]) -> UUID? { if let raw = userInfo["gameID"] as? String, let id = UUID(uuidString: raw) { diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -168,47 +168,51 @@ extension SyncEngine { return 0 } - let zoneIDs = friendZoneIDs(forScope: scopeValue) - guard !zoneIDs.isEmpty else { + let targets = friendInviteScanTargets(forScope: scopeValue) + guard !targets.isEmpty else { await trace("\(label) invite sync: no friend zones") return 0 } struct PerZoneInvites: Sendable { let pings: [Ping] + let recordNames: Set<String> + let scannedAuthorID: String? let orphanedZone: CKRecordZone.ID? } let perZone = await withTaskGroup(of: PerZoneInvites.self) { group in - for zoneID in zoneIDs { + for target in targets { group.addTask { [weak self] in guard let self else { - return PerZoneInvites(pings: [], orphanedZone: nil) + return PerZoneInvites(pings: [], recordNames: [], scannedAuthorID: nil, orphanedZone: nil) } do { let records = try await self.queryRecords( type: "Ping", database: database, - zoneID: zoneID, + zoneID: target.zoneID, predicate: NSPredicate(format: "kind == %@", PingKind.invite.rawValue), desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload", "addressee"] ) return PerZoneInvites( pings: records.compactMap(Ping.parseRecord), + recordNames: Set(records.map(\.recordID.recordName)), + scannedAuthorID: target.authorID, orphanedZone: nil ) } catch { let orphan: CKRecordZone.ID? if scope == .shared, self.isInvalidSharedZoneOwnerError(error as NSError) { - orphan = zoneID + orphan = target.zoneID } else { orphan = nil } await self.trace( - "\(label) invite sync: zone \(zoneID.zoneName) failed: " + + "\(label) invite sync: zone \(target.zoneID.zoneName) failed: " + "\(error.localizedDescription)" ) - return PerZoneInvites(pings: [], orphanedZone: orphan) + return PerZoneInvites(pings: [], recordNames: [], scannedAuthorID: nil, orphanedZone: orphan) } } } @@ -226,13 +230,91 @@ extension SyncEngine { } let pings = perZone.flatMap(\.pings) - await trace("\(label) invite sync: zones=\(zoneIDs.count), invites=\(pings.count)") + let pruned = await pruneMissingPendingInvites( + fromScannedInviters: Set(perZone.compactMap(\.scannedAuthorID)), + livePingRecordNames: perZone.reduce(into: Set<String>()) { $0.formUnion($1.recordNames) }, + label: label + ) + await trace("\(label) invite sync: zones=\(targets.count), invites=\(pings.count), pruned=\(pruned)") if !pings.isEmpty, let onPings { await onPings(pings) } return pings.count } + private func friendInviteScanTargets( + forScope scope: Int16 + ) -> [(zoneID: CKRecordZone.ID, authorID: String)] { + let ctx = persistence.container.newBackgroundContext() + return ctx.performAndWait { + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + req.predicate = NSPredicate( + format: "databaseScope == %d AND isBlocked == NO", + scope + ) + var seen = Set<String>() + var result: [(zoneID: CKRecordZone.ID, authorID: String)] = [] + for friend in (try? ctx.fetch(req)) ?? [] { + guard let zoneName = friend.friendZoneName, + let ownerName = friend.friendZoneOwnerName, + let authorID = friend.authorID + else { continue } + let key = "\(ownerName)|\(zoneName)" + guard seen.insert(key).inserted else { continue } + result.append(( + zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), + authorID: authorID + )) + } + return result + } + } + + /// Friend-zone invite scans are authoritative for the zones that completed. + /// If a durable local InviteEntity still points at a Ping record absent + /// from that scan, the source message has already been consumed elsewhere + /// and the local row is stale. + private func pruneMissingPendingInvites( + fromScannedInviters scannedInviterAuthorIDs: Set<String>, + livePingRecordNames: Set<String>, + label: String + ) async -> Int { + guard !scannedInviterAuthorIDs.isEmpty else { return 0 } + let ctx = persistence.container.newBackgroundContext() + let result: Result<Int, Error> = ctx.performAndWait { + let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + req.predicate = NSPredicate( + format: "status == %@ AND inviterAuthorID IN %@", + "pending", + Array(scannedInviterAuthorIDs) + ) + let rows = (try? ctx.fetch(req)) ?? [] + var removed = 0 + for row in rows { + guard let recordName = row.pingRecordName, + !livePingRecordNames.contains(recordName) + else { continue } + ctx.delete(row) + removed += 1 + } + guard removed > 0 else { return .success(0) } + do { + try ctx.save() + return .success(removed) + } catch { + ctx.rollback() + return .failure(error) + } + } + switch result { + case .success(let removed): + return removed + case .failure(let error): + await trace("\(label) invite sync: stale local invite prune failed: \(error.localizedDescription)") + return 0 + } + } + /// Deletes the `.invite` Ping(s) for `gameID` from the user's friend zones. /// Called when leaving a shared game: the invite Ping is durable and its /// only other cleanup, `consumeStaleInvites`, keys off a local `GameEntity`