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>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-07-27 19:57:29 -07:00
parent c02ed4f436
commit 2da2da5ab8
3 changed files with 256 additions and 10 deletions
@@ -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
}
}
}
@@ -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)
}
}
}
@@ -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]*?```"#,