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:
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`