crossmate

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

commit 6ecdf25204cf657953f53fd4bd87a7c039ad4057
parent 3af6dc76e6e28c64f20ca62236af95f467e80878
Author: Michael Camilleri <[email protected]>
Date:   Fri,  5 Jun 2026 22:39:50 +0900

Add logging for badges on launch

Diffstat:
MCrossmate/Services/AppServices.swift | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 65 insertions(+), 0 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -355,6 +355,7 @@ final class AppServices { } await refreshAppBadge() importVisibleNotificationReceipts() + await logNotificationStartupSnapshot() appDelegate.onRemoteNotification = { summary, scope, event, gameID, kind, senderDeviceID, readAt, isBackground in @@ -3043,6 +3044,70 @@ final class AppServices { } } + /// One startup-only snapshot for badge debugging. Kept out of + /// `refreshAppBadge` so normal notification and sync churn does not flood + /// diagnostics. + private func logNotificationStartupSnapshot() async { + let delivered = await UNUserNotificationCenter.current().deliveredNotifications() + let ledgerUnread = BadgeState.unreadGameIDs() + let coreDataUnread = store.unreadOtherMovesGameTimes() + let merged = ledgerUnread.union(coreDataUnread.keys) + syncMonitor.note( + "notif startup: delivered=\(delivered.count) " + + "badgeLedger=\(ledgerUnread.count) [\(shortIDs(ledgerUnread))] " + + "coreDataUnread=\(coreDataUnread.count) [\(shortIDs(coreDataUnread.keys))] " + + "merged=\(merged.count) [\(shortIDs(merged))]" + ) + for notification in delivered.sorted(by: { $0.date < $1.date }) { + syncMonitor.note("notif delivered: \(notificationSummary(notification))") + } + } + + private func notificationSummary(_ notification: UNNotification) -> String { + let request = notification.request + let content = request.content + let userInfo = content.userInfo + let gameID = notificationGameID(from: userInfo) + let kind = userInfo["kind"] as? String + let pingKind = userInfo["pingKind"] as? String + let nseLogged = (userInfo["crossmateNSELogged"] as? Bool) == true + return "id=\(request.identifier)" + + " date=\(notification.date.ISO8601Format())" + + " game=\(gameID.map { shortID($0) } ?? "nil")" + + " kind=\(kind ?? "nil")" + + " pingKind=\(pingKind ?? "nil")" + + " badge=\(content.badge?.stringValue ?? "nil")" + + " nseLogged=\(nseLogged)" + + " title=\"\(logEscaped(content.title))\"" + + " body=\"\(logEscaped(content.body))\"" + } + + private func notificationGameID(from userInfo: [AnyHashable: Any]) -> UUID? { + if let raw = userInfo["gameID"] as? String, + let id = UUID(uuidString: raw) { + return id + } + guard let ck = userInfo["ck"] as? [AnyHashable: Any], + let qry = ck["qry"] as? [AnyHashable: Any], + let zoneName = qry["zid"] as? String, + zoneName.hasPrefix("game-") + else { return nil } + return UUID(uuidString: String(zoneName.dropFirst("game-".count))) + } + + private func shortIDs<S: Sequence>(_ ids: S) -> String where S.Element == UUID { + ids.map(shortID).sorted().joined(separator: ",") + } + + private func shortID(_ id: UUID) -> String { + String(id.uuidString.prefix(8)) + } + + private func logEscaped(_ text: String) -> String { + text.replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + /// Assembled replay timelines, keyed by game. A finished game's journals are /// frozen (edit-lockout), so its timeline never changes once built — caching /// it here lets a `ReplayController` recreated by rapid finish-banner nav