crossmate

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

commit 2b1abbf874af95eb1ea2e501178167e0758e5d47
parent a21ab48f5d5f54a3c4f1ede8634555e5b23404d6
Author: Michael Camilleri <[email protected]>
Date:   Sat, 27 Jun 2026 06:51:29 +0900

Quiet unchanged badge refresh diagnostics

Repeated badge refreshes could dump the same ledger, Core Data and
pending invite ID sets every time a normal sync, invite or notification
callback asked the app icon to reconcile. The repeated snapshots made
device logs harder to scan without adding new badge state.

This commit keeps the badge update itself on every refresh, but only
logs the full badge snapshot when the computed badge inputs change.
Refresh callers now pass a short reason through AppServices and
InviteCoordinator, so the next meaningful badge-state transition still
records where it came from.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 18+++++++++---------
MCrossmate/Services/BadgeCoordinator.swift | 37+++++++++++++++++++++++++++----------
MCrossmate/Services/InviteCoordinator.swift | 12++++++------
3 files changed, 42 insertions(+), 25 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -307,8 +307,8 @@ final class AppServices { shareController: shareController, friendController: friendController, cloudService: cloudService, - refreshAppBadge: { [weak self] in - await self?.badge.refreshAppBadge() + refreshAppBadge: { [weak self] reason in + await self?.badge.refreshAppBadge(reason: reason) } ) @@ -645,10 +645,10 @@ final class AppServices { store.onUnreadOtherMovesChanged = { [weak self] in guard let self else { return } - Task { await self.badge.refreshAppBadge() } + Task { await self.badge.refreshAppBadge(reason: "unread changed") } } importVisibleNotificationReceipts() - await badge.refreshAppBadge() + await badge.refreshAppBadge(reason: "startup") await badge.logNotificationStartupSnapshot() // Heal the App Group nickname directory from Core Data ground truth — @@ -915,7 +915,7 @@ final class AppServices { // The local row is gone, so drop its badge ledger entry: a seen // horizon can't clear it once there's no game left to open. BadgeState.forget(gameID: gameID) - await self?.badge.refreshAppBadge() + await self?.badge.refreshAppBadge(reason: "game removed") // A hard-deleted game (private zone gone, or a shared game left // elsewhere) only needs UI when its puzzle is on screen: a sticky, // input-blocking banner freezes the now-orphaned puzzle until the @@ -943,7 +943,7 @@ final class AppServices { do { try self.invites.removePendingInvite(forGameID: gameID) // The pending invite (if any) is gone; drop it from the badge. - await self.badge.refreshAppBadge() + await self.badge.refreshAppBadge(reason: "game joined") } catch { self.announcements.post(Announcement( id: "remove-pending-invite-error-\(gameID.uuidString)", @@ -967,7 +967,7 @@ final class AppServices { await syncEngine.setOnPingDeleted { [weak self] pings in guard let self else { return } try? self.invites.removePendingInvites(forPingRecordNames: Set(pings.map { $0.recordName })) - await self.badge.refreshAppBadge() + await self.badge.refreshAppBadge(reason: "ping deleted") for gameID in Set(pings.map { $0.gameID }) { await self.badge.dismissDeliveredNotifications( for: gameID, @@ -1184,7 +1184,7 @@ final class AppServices { // the ledger but was since opened — the divergence that otherwise pins // the badge above the count the library list shows. await badge.reconcileBadgeLedgerWithDeliveredNotifications() - await badge.refreshAppBadge() + await badge.refreshAppBadge(reason: "game list freshen") await reconcilePendingJournalUploads() if shouldRunColdLaunchArchiveReconcile { shouldRunColdLaunchArchiveReconcile = false @@ -1320,7 +1320,7 @@ final class AppServices { try await self.syncEngine.fetchFriendInvitesDirect(scope: scope) } if inviteResult != nil { - await badge.refreshAppBadge() + await badge.refreshAppBadge(reason: "invite freshen") } if catchUpResult != nil { noteGameListFreshenCompleted(scope: scope) diff --git a/Crossmate/Services/BadgeCoordinator.swift b/Crossmate/Services/BadgeCoordinator.swift @@ -16,6 +16,17 @@ final class BadgeCoordinator { /// Fans the seen horizon out to sibling devices — /// `AccountPushCoordinator.publishAccountSeenPush(gameID:readAt:)`. private let publishAccountSeenPush: (UUID, Date) async -> Void + private var lastLoggedBadgeSnapshot: BadgeRefreshSnapshot? + + private struct BadgeRefreshSnapshot: Equatable { + let ledgerUnread: Set<UUID> + let coreDataUnread: Set<UUID> + let pendingInvites: Set<UUID> + + var merged: Set<UUID> { + ledgerUnread.union(coreDataUnread).union(pendingInvites) + } + } init( store: GameStore, @@ -78,7 +89,7 @@ final class BadgeCoordinator { ? Date().addingTimeInterval(readLeaseDuration) : Date()) BadgeState.adoptReadHorizon(gameID: gameID, horizon: horizon) - await refreshAppBadge() + await refreshAppBadge(reason: "dismiss delivered") if publishAccountSeen { await publishAccountSeenPush(gameID, horizon) } @@ -97,7 +108,7 @@ final class BadgeCoordinator { /// 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 { + func refreshAppBadge(reason: String = "unspecified") async { if BadgeState.claimLegacyReadThroughHealNeeded() { let deliveredUnread = await deliveredUnreadGameIDs() let healed = store.backfillLegacyReadThrough(excluding: deliveredUnread) @@ -113,15 +124,21 @@ final class BadgeCoordinator { // from the unread-moves set, so the union below never double-counts. let pendingInvites = store.pendingInviteGameIDs() BadgeState.setPendingInvites(pendingInvites) - let merged = BadgeState.unreadGameIDs() - .union(coreDataUnread.keys) - .union(pendingInvites) - syncMonitor.note( - "app badge refresh: count=\(merged.count) " - + "ledger=\(BadgeState.unreadGameIDs().count) [\(shortIDs(BadgeState.unreadGameIDs()))] " - + "coreData=\(coreDataUnread.count) [\(shortIDs(coreDataUnread.keys))] " - + "pendingInvites=\(pendingInvites.count) [\(shortIDs(pendingInvites))]" + let snapshot = BadgeRefreshSnapshot( + ledgerUnread: BadgeState.unreadGameIDs(), + coreDataUnread: Set(coreDataUnread.keys), + pendingInvites: pendingInvites ) + let merged = snapshot.merged + if snapshot != lastLoggedBadgeSnapshot { + syncMonitor.note( + "app badge refresh(\(reason)): count=\(merged.count) " + + "ledger=\(snapshot.ledgerUnread.count) [\(shortIDs(snapshot.ledgerUnread))] " + + "coreData=\(snapshot.coreDataUnread.count) [\(shortIDs(snapshot.coreDataUnread))] " + + "pendingInvites=\(snapshot.pendingInvites.count) [\(shortIDs(snapshot.pendingInvites))]" + ) + lastLoggedBadgeSnapshot = snapshot + } do { try await UNUserNotificationCenter.current().setBadgeCount(merged.count) } catch { diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -35,7 +35,7 @@ final class InviteCoordinator { private let cloudService: CloudService /// Refreshes the app-icon badge — `BadgeCoordinator.refreshAppBadge` — /// whenever invite rows change (pending invites count toward the badge). - private let refreshAppBadge: () async -> Void + private let refreshAppBadge: (String) async -> Void private var claimedPingRecordNames: Set<String> = [] private var claimedPingRecordNameOrder: [String] = [] @@ -52,7 +52,7 @@ final class InviteCoordinator { shareController: ShareController, friendController: FriendController, cloudService: CloudService, - refreshAppBadge: @escaping () async -> Void + refreshAppBadge: @escaping (String) async -> Void ) { self.persistence = persistence self.identity = identity @@ -372,7 +372,7 @@ final class InviteCoordinator { } if ctx.hasChanges { try ctx.save() - await refreshAppBadge() + await refreshAppBadge("delete invite") } } @@ -403,7 +403,7 @@ final class InviteCoordinator { } if ctx.hasChanges { try ctx.save() - await refreshAppBadge() + await refreshAppBadge("decline invite") } let declinerAuthorID = identity.currentID let declinerName = preferences.name @@ -486,7 +486,7 @@ final class InviteCoordinator { for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) } if vctx.hasChanges { try? vctx.save() - await refreshAppBadge() + await refreshAppBadge("block friend") } } @@ -580,7 +580,7 @@ final class InviteCoordinator { // Reflect any newly-stored pending invite in the app-icon badge now — // before the notification-authorization guard — so the badge updates // even when the banner is suppressed or unauthorized. - await refreshAppBadge() + await refreshAppBadge("present pings") // `.friend` is the friendship-bootstrap handshake. `.join` and `.hail` // are legacy live-notification/bootstrap kinds; APNs and Game-record // engagement creds own those jobs now. System pings do not require