commit 6890a9ffc46dea73d83a204454285b81d144d8a6
parent 74ddd12ec4b45a0c07881f0dfd3a1daa98050963
Author: Michael Camilleri <[email protected]>
Date: Fri, 29 May 2026 16:06:13 +0900
Log visible push notifications
Add visible notification receipt logging by having the notification
service extension write each displayed APNs notification body and UTC
receipt time into a small App Group ring buffer, then draining those
entries into the app’s diagnostics event log on startup and foreground.
Foreground-visible notifications that bypass the extension are logged
from the app delegate as well, while suppressed notifications remain
unlogged because they were not actually shown.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
4 files changed, 105 insertions(+), 0 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -75,6 +75,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser
/// Crossmate push worker. Fires on every successful APNs registration —
/// the worker dedupes unchanged triples server-side.
var onAPNsToken: ((Data) -> Void)?
+ /// Tells the app that visible notification receipts may be waiting in the
+ /// App Group ring buffer written by the Notification Service Extension.
+ var onVisibleNotificationReceiptsAvailable: (() -> Void)?
func application(
_ application: UIApplication,
@@ -116,12 +119,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser
) {
let userInfo = notification.request.content.userInfo
guard let gameID = Self.gameID(from: userInfo) else {
+ logForegroundVisibleNotification(notification, source: "foreground")
completionHandler([.banner, .sound])
return
}
if NotificationState.isSuppressed(gameID: gameID) {
completionHandler([])
} else {
+ logForegroundVisibleNotification(notification, source: "foreground")
completionHandler([.banner, .sound])
}
}
@@ -162,6 +167,19 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUser
return UUID(uuidString: String(zoneName.dropFirst("game-".count)))
}
+ private func logForegroundVisibleNotification(
+ _ notification: UNNotification,
+ source: String
+ ) {
+ if (notification.request.content.userInfo["crossmateNSELogged"] as? Bool) != true {
+ VisibleNotificationReceiptLog.record(
+ body: notification.request.content.body,
+ source: source
+ )
+ }
+ onVisibleNotificationReceiptsAvailable?()
+ }
+
/// Asks the user for notification permission only if they haven't yet
/// answered the prompt. Idempotent — once the user has decided either
/// way, this is a no-op.
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -310,6 +310,7 @@ final class AppServices {
Task { await self.refreshAppBadge() }
}
await refreshAppBadge()
+ importVisibleNotificationReceipts()
appDelegate.onRemoteNotification = { summary, scope, isBackground in
await self.handleRemoteNotification(
@@ -318,6 +319,11 @@ final class AppServices {
isBackground: isBackground
)
}
+ appDelegate.onVisibleNotificationReceiptsAvailable = { [weak self] in
+ Task { @MainActor in
+ self?.importVisibleNotificationReceipts()
+ }
+ }
appDelegate.onAPNsRegistrationResult = { [syncMonitor] message in
syncMonitor.note(message)
}
@@ -525,6 +531,7 @@ final class AppServices {
}
func syncOnForeground() async {
+ importVisibleNotificationReceipts()
await movesUpdater.flush()
guard await ensureICloudSyncStarted() else { return }
let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves()
@@ -550,6 +557,12 @@ final class AppServices {
await refreshSnapshot()
}
+ private func importVisibleNotificationReceipts() {
+ for entry in VisibleNotificationReceiptLog.drain() {
+ eventLog.note(VisibleNotificationReceiptLog.message(for: entry))
+ }
+ }
+
func gameListAppeared() async {
isGameListVisible = true
await freshenGameList(reason: .appeared)
diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift
@@ -43,6 +43,14 @@ final class NotificationService: UNNotificationServiceExtension {
let kind = userInfo["kind"] as? String
let gameID = (userInfo["gameID"] as? String).flatMap(UUID.init(uuidString:))
+ VisibleNotificationReceiptLog.record(
+ body: bestAttemptContent.body,
+ source: "notification-service-extension"
+ )
+ var updatedUserInfo = bestAttemptContent.userInfo
+ updatedUserInfo["crossmateNSELogged"] = true
+ bestAttemptContent.userInfo = updatedUserInfo
+
if let gameID, kind == "pause" || kind == "win" || kind == "resign" {
let count = BadgeState.markUnread(gameID: gameID)
bestAttemptContent.badge = NSNumber(value: count)
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -227,3 +227,69 @@ enum BadgeState {
return current.count
}
}
+
+/// Small App Group ring buffer for visible notification receipts. The
+/// Notification Service Extension runs in a separate process, so it cannot
+/// write to the app's in-memory diagnostics log directly; it records here and
+/// the app drains the entries into `EventLog` when it next runs.
+enum VisibleNotificationReceiptLog {
+ struct Entry: Codable, Sendable, Equatable {
+ let timestamp: Date
+ let source: String
+ let body: String
+ }
+
+ private static let entriesKey = "visibleNotificationReceipts.entries"
+ private static let maxEntries = 50
+
+ private static var defaults: UserDefaults? {
+ NotificationState.sharedDefaultsForSiblings
+ }
+
+ static func record(body: String, source: String, at timestamp: Date = Date()) {
+ guard let defaults else { return }
+ var entries = loadEntries(from: defaults)
+ entries.append(Entry(
+ timestamp: timestamp,
+ source: source,
+ body: body
+ ))
+ if entries.count > maxEntries {
+ entries.removeFirst(entries.count - maxEntries)
+ }
+ save(entries, to: defaults)
+ }
+
+ static func drain() -> [Entry] {
+ guard let defaults else { return [] }
+ let entries = loadEntries(from: defaults)
+ defaults.removeObject(forKey: entriesKey)
+ return entries
+ }
+
+ static func message(for entry: Entry) -> String {
+ let escapedBody = entry.body
+ .replacingOccurrences(of: "\n", with: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return "visible notification received: utc=\(utcString(entry.timestamp)) source=\(entry.source) body=\"\(escapedBody)\""
+ }
+
+ private static func loadEntries(from defaults: UserDefaults) -> [Entry] {
+ guard let data = defaults.data(forKey: entriesKey),
+ let entries = try? JSONDecoder().decode([Entry].self, from: data)
+ else { return [] }
+ return entries
+ }
+
+ private static func save(_ entries: [Entry], to defaults: UserDefaults) {
+ guard let data = try? JSONEncoder().encode(entries) else { return }
+ defaults.set(data, forKey: entriesKey)
+ }
+
+ private static func utcString(_ date: Date) -> String {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ return formatter.string(from: date)
+ }
+}