crossmate

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

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:
MCrossmate/CrossmateApp.swift | 18++++++++++++++++++
MCrossmate/Services/AppServices.swift | 13+++++++++++++
MNotificationService/NotificationService.swift | 8++++++++
MShared/NotificationState.swift | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } +}