From 2da2da5ab876f173e9baf5e0c7a7702fd4faebf6 Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Mon, 27 Jul 2026 19:57:29 -0700 Subject: [PATCH] fix(mobile/ios): absorb sequential duplicate wakes Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> --- .../PushNotificationIdentity.swift | 78 +++++++++++++ .../PushNotificationIdentityTests.swift | 80 +++++++++++++ .../NotificationService.swift | 108 ++++++++++++++++-- 3 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushNotificationIdentity.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushNotificationIdentityTests.swift diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushNotificationIdentity.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushNotificationIdentity.swift new file mode 100644 index 000000000..4cf827bd0 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushNotificationIdentity.swift @@ -0,0 +1,78 @@ +import Foundation + +public enum PushNotificationIdentityError: Error, Equatable { + case missingIdentity + case malformedIdentity +} + +public struct PushNotificationIdentity: Codable, Equatable, Sendable { + public static let userInfoKey = "buzz_push_identity" + + public let eventID: String + public let origin: String + + enum CodingKeys: String, CodingKey { + case eventID = "event_id" + case origin + } + + public init(eventID: String, origin: String) { + self.eventID = eventID.lowercased() + self.origin = origin + } + + public var userInfoValue: [String: String] { + ["event_id": eventID, "origin": origin] + } + + public static func decodeIfPresent( + from userInfo: [AnyHashable: Any] + ) throws -> PushNotificationIdentity? { + guard let raw = userInfo[userInfoKey] else { return nil } + guard let fields = raw as? [String: String], + let eventID = fields["event_id"], + let origin = fields["origin"], + fields.count == 2, + !eventID.isEmpty, + !origin.isEmpty + else { + throw PushNotificationIdentityError.malformedIdentity + } + return PushNotificationIdentity(eventID: eventID, origin: origin) + } + + public static func require( + from userInfo: [AnyHashable: Any] + ) throws -> PushNotificationIdentity { + guard let identity = try decodeIfPresent(from: userInfo) else { + throw PushNotificationIdentityError.missingIdentity + } + return identity + } +} + +public struct PushDeliveredNotificationRecord { + public let requestIdentifier: String + public let userInfo: [AnyHashable: Any] + + public init(requestIdentifier: String, userInfo: [AnyHashable: Any]) { + self.requestIdentifier = requestIdentifier + self.userInfo = userInfo + } +} + +public enum PushDuplicateAbsorption { + public static func requestIdentifiersToRemove( + matching identity: PushNotificationIdentity, + from delivered: [PushDeliveredNotificationRecord] + ) throws -> [String] { + try delivered.compactMap { notification in + guard let deliveredIdentity = try PushNotificationIdentity.decodeIfPresent( + from: notification.userInfo + ) else { + return nil + } + return deliveredIdentity == identity ? notification.requestIdentifier : nil + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushNotificationIdentityTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushNotificationIdentityTests.swift new file mode 100644 index 000000000..87112e3f4 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushNotificationIdentityTests.swift @@ -0,0 +1,80 @@ +import XCTest + +@testable import BuzzPushKit + +final class PushNotificationIdentityTests: XCTestCase { + private let identity = PushNotificationIdentity(eventID: "EVENT", origin: "origin") + + func testSequentialDuplicateRemovalStopsStackGrowthButDuplicateWakeCanStillAlertBeforeOlderCopyIsRemoved() throws { + let requestIdentifiers = try PushDuplicateAbsorption.requestIdentifiersToRemove( + matching: identity, + from: [ + PushDeliveredNotificationRecord( + requestIdentifier: "older-request", + userInfo: [PushNotificationIdentity.userInfoKey: identity.userInfoValue] + ) + ] + ) + + XCTAssertEqual(requestIdentifiers, ["older-request"]) + } + + func testConcurrentNSEInvocationsCanEachMissTheOthersDeliveredNotificationAndBothLeaveACopy() throws { + let firstObserved = try PushDuplicateAbsorption.requestIdentifiersToRemove( + matching: identity, + from: [] + ) + let secondObserved = try PushDuplicateAbsorption.requestIdentifiersToRemove( + matching: identity, + from: [] + ) + + XCTAssertTrue(firstObserved.isEmpty) + XCTAssertTrue(secondObserved.isEmpty) + } + + func testMissingOrMalformedCurrentIdentityFailsLoudly() { + XCTAssertThrowsError(try PushNotificationIdentity.require(from: [:])) { error in + XCTAssertEqual(error as? PushNotificationIdentityError, .missingIdentity) + } + XCTAssertThrowsError( + try PushNotificationIdentity.require( + from: [PushNotificationIdentity.userInfoKey: ["event_id": "event"]] + ) + ) { error in + XCTAssertEqual(error as? PushNotificationIdentityError, .malformedIdentity) + } + } + + func testUnrelatedDeliveredNotificationWithoutBuzzIdentityIsIgnored() throws { + let requestIdentifiers = try PushDuplicateAbsorption.requestIdentifiersToRemove( + matching: identity, + from: [ + PushDeliveredNotificationRecord( + requestIdentifier: "unrelated", + userInfo: ["other": "value"] + ) + ] + ) + + XCTAssertTrue(requestIdentifiers.isEmpty) + } + + func testMalformedDeliveredBuzzIdentityFailsLoudly() { + XCTAssertThrowsError( + try PushDuplicateAbsorption.requestIdentifiersToRemove( + matching: identity, + from: [ + PushDeliveredNotificationRecord( + requestIdentifier: "malformed", + userInfo: [ + PushNotificationIdentity.userInfoKey: ["event_id": "event"] + ] + ) + ] + ) + ) { error in + XCTAssertEqual(error as? PushNotificationIdentityError, .malformedIdentity) + } + } +} diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index 8fffcf504..df4380afb 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -29,6 +29,16 @@ final class NotificationService: UNNotificationServiceExtension { if let threadIdentifier = resolution.threadIdentifier { content.threadIdentifier = threadIdentifier } + var userInfo = content.userInfo + userInfo[PushNotificationIdentity.userInfoKey] = resolution.identity.userInfoValue + content.userInfo = userInfo + do { + _ = try PushNotificationIdentity.require(from: content.userInfo) + } catch { + content.title = "Buzz notification needs attention" + content.body = "Buzz could not persist notification identity." + content.subtitle = "" + } case .diagnostic(let message): content.title = "Buzz notification needs attention" content.body = message @@ -57,6 +67,7 @@ struct BuzzPushResolution { let body: String let subtitle: String? let threadIdentifier: String? + let identity: PushNotificationIdentity } enum BuzzPushResolutionResult { @@ -69,16 +80,29 @@ protocol BuzzPushNotificationResolving { func resolve(completion: @escaping (BuzzPushResolutionResult) -> Void) } +protocol BuzzDeliveredNotificationManaging { + func deliveredNotifications(completion: @escaping ([UNNotification]) -> Void) + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) +} + +extension UNUserNotificationCenter: BuzzDeliveredNotificationManaging { + func deliveredNotifications(completion: @escaping ([UNNotification]) -> Void) { + getDeliveredNotifications(completionHandler: completion) + } +} + final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { private struct Candidate { let resolution: BuzzPushResolution let event: VerifiedNostrEvent let community: PushLeaseCommunity + let wasPreviouslyConsumed: Bool } private let session: URLSession private let appGroupIdentifier: String? private let keychainAccessGroup: String? + private let notificationCenter: BuzzDeliveredNotificationManaging private let fileManager: FileManager init( @@ -89,11 +113,14 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { keychainAccessGroup: String? = Bundle.main.object( forInfoDictionaryKey: "BuzzKeychainAccessGroup" ) as? String, + notificationCenter: BuzzDeliveredNotificationManaging = + UNUserNotificationCenter.current(), fileManager: FileManager = .default ) { self.session = session self.appGroupIdentifier = appGroupIdentifier self.keychainAccessGroup = keychainAccessGroup + self.notificationCenter = notificationCenter self.fileManager = fileManager } @@ -105,6 +132,7 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { completion(.diagnostic("Open Buzz to refresh notification subscriptions.")) return } + let communities = loaded.communities.filter { $0.pubkey?.isEmpty == false && loadPrivateKey(communityID: $0.id) != nil } @@ -141,6 +169,9 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { group.notify(queue: .global(qos: .userInitiated)) { [weak self] in guard let self else { return } let sorted = candidates.sorted { lhs, rhs in + if lhs.wasPreviouslyConsumed != rhs.wasPreviouslyConsumed { + return !lhs.wasPreviouslyConsumed + } if lhs.event.createdAt != rhs.event.createdAt { return lhs.event.createdAt > rhs.event.createdAt } @@ -151,14 +182,19 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { completion(diagnostics.sorted().first.map(BuzzPushResolutionResult.diagnostic) ?? .none) return } + let position = PushEventPosition( createdAt: winner.event.createdAt, id: winner.event.id ) - var shouldPresent = false + var shouldPresent = winner.wasPreviouslyConsumed do { try loaded.store.update { state in state.removeInactiveOrigins(Set(communities.map(\.id))) + if state.hasConsumed(eventID: position.id, for: winner.community.id) { + shouldPresent = true + return + } guard state.canSelect(position, for: winner.community.id) else { return } state.consume(position, for: winner.community.id) shouldPresent = true @@ -171,7 +207,7 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { completion(.none) return } - completion(.notification(winner.resolution)) + absorbSequentialDuplicate(of: winner.resolution, completion: completion) } } @@ -260,15 +296,15 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { else { return false } - return consumptionState.canSelect( - PushEventPosition(createdAt: event.createdAt, id: event.id), - for: community.id - ) + let position = PushEventPosition(createdAt: event.createdAt, id: event.id) + return consumptionState.hasConsumed(eventID: event.id, for: community.id) + || consumptionState.canSelect(position, for: community.id) }.sorted { $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt } for event in matching { + let identity = PushNotificationIdentity(eventID: event.id, origin: community.id) let resolution: BuzzPushResolution if event.kind == 9 { let body = previewBody(event.content) @@ -278,23 +314,75 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { title: shortPubkey(event.pubkey), body: body, subtitle: community.name, - threadIdentifier: channel ?? community.id + threadIdentifier: channel ?? community.id, + identity: identity ) } else { // Wake-only kinds are fetched from the authoritative lease, but this - // extension cannot render them yet. + // extension cannot render them yet. The explicit result distinguishes + // that state from a catch-up network failure. resolution = BuzzPushResolution( title: "Buzz notification needs attention", body: "Buzz received a kind \(event.kind) wake that this app version cannot display.", subtitle: community.name, - threadIdentifier: "buzz.push.unsupported-kind.\(event.kind)" + threadIdentifier: "buzz.push.unsupported-kind.\(event.kind)", + identity: identity ) } - return .candidate(Candidate(resolution: resolution, event: event, community: community)) + return .candidate( + Candidate( + resolution: resolution, + event: event, + community: community, + wasPreviouslyConsumed: consumptionState.hasConsumed( + eventID: event.id, + for: community.id + ) + ) + ) } return .none } + private func absorbSequentialDuplicate( + of resolution: BuzzPushResolution, + completion: @escaping (BuzzPushResolutionResult) -> Void + ) { + notificationCenter.deliveredNotifications { [weak self] notifications in + guard let self else { return } + let records = notifications.map { + PushDeliveredNotificationRecord( + requestIdentifier: $0.request.identifier, + userInfo: $0.request.content.userInfo + ) + } + let requestIdentifiers: [String] + do { + requestIdentifiers = try PushDuplicateAbsorption.requestIdentifiersToRemove( + matching: resolution.identity, + from: records + ) + } catch { + completion(.diagnostic("Buzz could not inspect notification identity.")) + return + } + if !requestIdentifiers.isEmpty { + self.notificationCenter.removeDeliveredNotifications( + withIdentifiers: requestIdentifiers + ) + } + + // Limitation 1: A duplicate wake can still alert before the older copy is + // removed. The stack stops growing; the user can still be notified twice + // for one event. + // Limitation 2: Two concurrent NSE invocations can each miss the other's + // delivered notification and both leave a copy. Sequential duplicates are + // absorbed; truly concurrent delivery still races. Exact absorption needs + // gateway-side apns-collapse-id, which is issue 18 and out of scope. + completion(.notification(resolution)) + } + } + private static func previewBody(_ content: String) -> String { var result = content.replacingOccurrences( of: #"```[\s\S]*?```"#,