diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore index 6547384f35..d0234ff66d 100644 --- a/mobile/ios/.gitignore +++ b/mobile/ios/.gitignore @@ -9,6 +9,7 @@ .tags* **/.vagrant/ **/DerivedData/ +BuzzPushKit/Package.resolved Icon? **/Pods/ **/.symlinks/ diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushCatchUp.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushCatchUp.swift new file mode 100644 index 0000000000..20b4b546d6 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushCatchUp.swift @@ -0,0 +1,181 @@ +import Foundation + +public struct PushCatchUpSelection: Sendable { + public let event: VerifiedNostrEvent + public let wasPreviouslyConsumed: Bool + + public init(event: VerifiedNostrEvent, wasPreviouslyConsumed: Bool) { + self.event = event + self.wasPreviouslyConsumed = wasPreviouslyConsumed + } +} + +public enum PushCatchUpStopReason: Equatable, Sendable { + case complete + case pageBudgetExceeded + case deadlineExceeded +} + +public struct PushCatchUpPager { + /// The NSE has about eight seconds. Reserve two seconds for state persistence, + /// duplicate absorption, and content delivery after bounded catch-up. + public static let traversalSeconds: TimeInterval = 6 + public static let pageLimit = 10 + public static let maximumPages = 12 + + private struct Query { + let filter: PushLeaseFilter + let hTag: String? + } + + private let queries: [Query] + private let since: Int? + private let deadline: Date + private let pageLimit: Int + private let maximumPages: Int + private var subscriptionIndex: Int + private var rawTail: PushEventPosition? + private var pagesRequested = 0 + + public private(set) var stopReason: PushCatchUpStopReason? + + public init( + subscriptions: [PushLeaseSubscription], + since: Int?, + scan: PushCatchUpScan = PushCatchUpScan(), + startedAt: Date = Date(), + traversalSeconds: TimeInterval = Self.traversalSeconds, + pageLimit: Int = Self.pageLimit, + maximumPages: Int = Self.maximumPages + ) { + precondition(traversalSeconds > 0, "Push catch-up traversal allowance must be positive") + precondition(pageLimit > 0, "Push catch-up page limit must be positive") + precondition(maximumPages > 0, "Push catch-up page budget must be positive") + queries = subscriptions.flatMap { subscription in + guard let hTags = subscription.filter.hTags, hTags.count > 1 else { + return [Query(filter: subscription.filter, hTag: nil)] + } + return hTags.map { Query(filter: subscription.filter, hTag: $0) } + } + self.since = since + deadline = startedAt.addingTimeInterval(traversalSeconds) + self.pageLimit = pageLimit + self.maximumPages = maximumPages + + // A subscription snapshot can legitimately shrink between NSE wakes. Treat + // a scan beyond the new query set as exhausted so the caller clears it and + // begins a fresh pass on the next wake. + if scan.subscriptionIndex >= queries.count { + subscriptionIndex = queries.count + rawTail = nil + } else { + subscriptionIndex = scan.subscriptionIndex + rawTail = scan.before + } + } + + /// The position to persist for the next wake. A completed traversal clears + /// its scan so newly arrived events are visible from the top of the next pass. + public var scan: PushCatchUpScan { + guard stopReason != .complete else { return PushCatchUpScan() } + return PushCatchUpScan(subscriptionIndex: subscriptionIndex, before: rawTail) + } + + public func remainingTraversalSeconds(now: Date = Date()) -> TimeInterval { + max(0, deadline.timeIntervalSince(now)) + } + + /// Return the next raw relay page. Continuation is derived only from the + /// observed raw page tail, never from post-selection or delivered-ID state. + public mutating func nextFilter(now: Date = Date()) -> [String: Any]? { + guard stopReason == nil else { return nil } + guard subscriptionIndex < queries.count else { + stopReason = .complete + return nil + } + guard pagesRequested < maximumPages else { + stopReason = .pageBudgetExceeded + return nil + } + guard now < deadline else { + stopReason = .deadlineExceeded + return nil + } + + let query = queries[subscriptionIndex] + var filter = query.filter.queryFilter(since: since, limit: pageLimit) + if let hTag = query.hTag { + // The HTTP relay narrows a multi-value #h filter to its first channel. + // One filter per channel avoids depending on that broken contract. + filter["#h"] = [hTag] + } + if let rawTail { + filter["until"] = rawTail.createdAt + filter["before_id"] = rawTail.id + } + pagesRequested += 1 + return filter + } + + public mutating func receive(rawPage: [VerifiedNostrEvent]) { + guard stopReason == nil else { return } + let ordered = rawPage.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + } + if ordered.count < pageLimit { + // `/query` exposes no exhaustion signal, so a short page is treated as + // exhausted even though post-LIMIT rejection makes that inference unsound. + // Relay support for an explicit exhaustion signal is required to fix this. + subscriptionIndex += 1 + rawTail = nil + if subscriptionIndex == queries.count { + stopReason = .complete + } + } else { + rawTail = ordered.last.map { + PushEventPosition(createdAt: $0.createdAt, id: $0.id) + } + } + } +} + +public enum PushCatchUp { + /// Return selectable events first and consumed duplicate fallbacks last. + /// Duplicate cleanup therefore never competes with forward progress. + public static func orderedSelections( + events: [VerifiedNostrEvent], + origin: String, + subscriptions: [PushLeaseSubscription], + consumptionState: PushConsumptionState, + verify: (VerifiedNostrEvent) -> Bool = { $0.hasValidIDAndSignature() } + ) -> [PushCatchUpSelection] { + var eventsByID: [String: VerifiedNostrEvent] = [:] + for event in events where verify(event) { + guard subscriptions.contains(where: { + PushLeaseMatcher.matches(event: event, subscription: $0) + }) else { + continue + } + eventsByID[event.id] = event + } + + let ordered = eventsByID.values.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt < $1.createdAt + } + let selectable = ordered.compactMap { event -> PushCatchUpSelection? in + let position = PushEventPosition(createdAt: event.createdAt, id: event.id) + guard consumptionState.canSelect(position, for: origin) else { return nil } + return PushCatchUpSelection(event: event, wasPreviouslyConsumed: false) + } + let duplicateFallbackID = consumptionState.state(for: origin).lastDisplayed?.id + let duplicates = ordered.compactMap { event -> PushCatchUpSelection? in + guard event.id == duplicateFallbackID, + consumptionState.hasConsumed(eventID: event.id, for: origin) + else { + return nil + } + return PushCatchUpSelection(event: event, wasPreviouslyConsumed: true) + } + return selectable + duplicates + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushConsumptionState.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushConsumptionState.swift new file mode 100644 index 0000000000..2b8d1eb8a0 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushConsumptionState.swift @@ -0,0 +1,284 @@ +import Darwin +import Foundation + +public enum PushConsumptionStateError: Error, Equatable { + case lockFailed(Int32) +} + +public struct PushEventPosition: Codable, Equatable, Comparable, Sendable { + public let createdAt: Int + public let id: String + + enum CodingKeys: String, CodingKey { + case createdAt = "created_at" + case id + } + + public init(createdAt: Int, id: String) { + self.createdAt = createdAt + self.id = id + } + + public static func < (lhs: PushEventPosition, rhs: PushEventPosition) -> Bool { + lhs.createdAt == rhs.createdAt ? lhs.id < rhs.id : lhs.createdAt < rhs.createdAt + } +} + +public struct PushCatchUpScan: Codable, Equatable, Sendable { + public let subscriptionIndex: Int + public let before: PushEventPosition? + + public init(subscriptionIndex: Int = 0, before: PushEventPosition? = nil) { + precondition(subscriptionIndex >= 0, "Push catch-up scan index cannot be negative") + self.subscriptionIndex = subscriptionIndex + self.before = before + } + + enum CodingKeys: String, CodingKey { + case subscriptionIndex + case before + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let subscriptionIndex = try container.decode(Int.self, forKey: .subscriptionIndex) + guard subscriptionIndex >= 0 else { + throw DecodingError.dataCorruptedError( + forKey: .subscriptionIndex, + in: container, + debugDescription: "Push catch-up scan index cannot be negative" + ) + } + self.subscriptionIndex = subscriptionIndex + before = try container.decodeIfPresent(PushEventPosition.self, forKey: .before) + } +} + +public struct PushOriginState: Codable, Equatable, Sendable { + public let cursor: PushEventPosition? + public let delivered: [PushEventPosition] + public let lastDisplayed: PushEventPosition? + public let scan: PushCatchUpScan + + public init( + cursor: PushEventPosition? = nil, + delivered: [PushEventPosition] = [], + lastDisplayed: PushEventPosition? = nil, + scan: PushCatchUpScan = PushCatchUpScan() + ) { + self.cursor = cursor + self.delivered = delivered + self.lastDisplayed = lastDisplayed + self.scan = scan + } + + public func contains(eventID: String) -> Bool { + lastDisplayed?.id == eventID || delivered.contains { $0.id == eventID } + } + + public func consuming( + _ position: PushEventPosition, + advanceFloor: Bool = true, + scan: PushCatchUpScan? = nil + ) -> PushOriginState { + let nextCursor = advanceFloor ? max(cursor ?? position, position) : cursor + var byID = Dictionary(uniqueKeysWithValues: delivered.map { ($0.id, $0) }) + byID[position.id] = position + let retained = byID.values + .filter { displayedPosition in + guard let nextCursor else { return true } + return displayedPosition.createdAt >= nextCursor.createdAt + } + .sorted() + return PushOriginState( + cursor: nextCursor, + delivered: Array(retained), + lastDisplayed: position, + scan: scan ?? self.scan + ) + } +} + +public struct PushConsumptionState: Codable, Equatable, Sendable { + public static let version = 1 + public static let allowedFutureSkewSeconds = 300 + + public var origins: [String: PushOriginState] + + private let storedVersion: Int + + enum CodingKeys: String, CodingKey { + case storedVersion = "version" + case origins + } + + public init(origins: [String: PushOriginState] = [:]) { + storedVersion = Self.version + self.origins = origins + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedVersion = try container.decode(Int.self, forKey: .storedVersion) + guard decodedVersion == Self.version else { + throw DecodingError.dataCorruptedError( + forKey: .storedVersion, + in: container, + debugDescription: "Unsupported push consumption state version \(decodedVersion)" + ) + } + storedVersion = decodedVersion + origins = try container.decode([String: PushOriginState].self, forKey: .origins) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(storedVersion, forKey: .storedVersion) + try container.encode(origins, forKey: .origins) + } + + public func state(for origin: String) -> PushOriginState { + origins[origin] ?? PushOriginState() + } + + public func querySince(for origin: String, now: Int = Int(Date().timeIntervalSince1970)) -> Int? { + guard let timestamp = origins[origin]?.cursor?.createdAt else { return nil } + return min(timestamp, now) + } + + public func hasConsumed(eventID: String, for origin: String) -> Bool { + state(for: origin).contains(eventID: eventID) + } + + public func canSelect( + _ position: PushEventPosition, + for origin: String, + now: Int = Int(Date().timeIntervalSince1970), + allowedFutureSkew: Int = allowedFutureSkewSeconds + ) -> Bool { + let originState = state(for: origin) + return position.createdAt <= now + allowedFutureSkew + && position.createdAt >= (originState.cursor?.createdAt ?? position.createdAt) + && !originState.contains(eventID: position.id) + } + + public mutating func consume( + _ position: PushEventPosition, + for origin: String, + advanceFloor: Bool = true, + scan: PushCatchUpScan? = nil + ) { + origins[origin] = state(for: origin).consuming( + position, + advanceFloor: advanceFloor, + scan: scan + ) + } + + public mutating func updateScan(_ scan: PushCatchUpScan, for origin: String) { + let originState = state(for: origin) + origins[origin] = PushOriginState( + cursor: originState.cursor, + delivered: originState.delivered, + lastDisplayed: originState.lastDisplayed, + scan: scan + ) + } + + public mutating func removeInactiveOrigins(_ activeOrigins: Set) { + origins = origins.filter { activeOrigins.contains($0.key) } + } +} + +public protocol PushConsumptionStateLocking: Sendable { + func withExclusiveLock(_ body: () throws -> T) throws -> T +} + +public protocol PushConsumptionStatePersisting: Sendable { + func read() throws -> Data? + func write(_ data: Data) throws +} + +public struct PushFileLock: PushConsumptionStateLocking { + private let url: URL + + public init(url: URL) { + self.url = url + } + + public func withExclusiveLock(_ body: () throws -> T) throws -> T { + let descriptor = open(url.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw PushConsumptionStateError.lockFailed(errno) } + defer { close(descriptor) } + guard flock(descriptor, LOCK_EX) == 0 else { + throw PushConsumptionStateError.lockFailed(errno) + } + defer { flock(descriptor, LOCK_UN) } + return try body() + } +} + +public struct PushFilePersistence: PushConsumptionStatePersisting { + private let url: URL + + public init(url: URL) { + self.url = url + } + + public func read() throws -> Data? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return try Data(contentsOf: url) + } + + public func write(_ data: Data) throws { + try data.write(to: url, options: [.atomic]) + } +} + +public final class PushConsumptionStateStore: @unchecked Sendable { + private let lock: any PushConsumptionStateLocking + private let persistence: any PushConsumptionStatePersisting + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init( + lock: any PushConsumptionStateLocking, + persistence: any PushConsumptionStatePersisting + ) { + self.lock = lock + self.persistence = persistence + encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + decoder = JSONDecoder() + } + + public convenience init(containerURL: URL) { + self.init( + lock: PushFileLock(url: containerURL.appendingPathComponent("push-consumption-state.lock")), + persistence: PushFilePersistence( + url: containerURL.appendingPathComponent("push-consumption-state.json") + ) + ) + } + + public func read() throws -> PushConsumptionState { + try lock.withExclusiveLock { try loadUnlocked() } + } + + @discardableResult + public func update( + _ body: (inout PushConsumptionState) throws -> Void + ) throws -> PushConsumptionState { + try lock.withExclusiveLock { + var state = try loadUnlocked() + try body(&state) + try persistence.write(encoder.encode(state)) + return state + } + } + + private func loadUnlocked() throws -> PushConsumptionState { + guard let data = try persistence.read() else { return PushConsumptionState() } + return try decoder.decode(PushConsumptionState.self, from: data) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift new file mode 100644 index 0000000000..5c3d883ce6 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -0,0 +1,199 @@ +import Foundation + +public enum PushLeaseError: Error, Equatable { + case unsupportedAuthority(String) + case acceptedAuthorityMissingSubscriptions + case emptySubscriptions +} + +public struct PushLeaseSnapshot: Codable, Equatable, Sendable { + public let communities: [PushLeaseCommunity] + + public init(communities: [PushLeaseCommunity]) { + self.communities = communities + } +} + +public struct PushLeaseCommunity: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let relayUrl: String + public let pubkey: String? + public let pushSubscriptionState: PushLeaseSubscriptionState + + public init( + id: String, + name: String, + relayUrl: String, + pubkey: String?, + pushSubscriptionState: PushLeaseSubscriptionState + ) { + self.id = id + self.name = name + self.relayUrl = relayUrl + self.pubkey = pubkey + self.pushSubscriptionState = pushSubscriptionState + } +} + +public struct PushLeaseSubscriptionState: Codable, Equatable, Sendable { + public enum Authority: String, Codable, Sendable { + case desired + case accepted + } + + public let authority: String + public let desired: [PushLeaseSubscription] + public let accepted: [PushLeaseSubscription]? + + public init( + authority: String, + desired: [PushLeaseSubscription], + accepted: [PushLeaseSubscription]? = nil + ) { + self.authority = authority + self.desired = desired + self.accepted = accepted + } + + /// Workstream A has no lease publisher, so `desired` is the only valid + /// authority today. A later publisher must persist the observed accepted + /// lease and switch this field explicitly. This keeps relay rejection, + /// clamping, or expiry visible rather than assuming acceptance. + public func authoritativeSubscriptions() throws -> [PushLeaseSubscription] { + let subscriptions: [PushLeaseSubscription] + switch authority { + case Authority.desired.rawValue: + subscriptions = desired + case Authority.accepted.rawValue: + guard let accepted else { + throw PushLeaseError.acceptedAuthorityMissingSubscriptions + } + subscriptions = accepted + default: + throw PushLeaseError.unsupportedAuthority(authority) + } + guard !subscriptions.isEmpty else { + throw PushLeaseError.emptySubscriptions + } + return subscriptions + } +} + +public struct PushLeaseSubscription: Codable, Equatable, Sendable { + public let filter: PushLeaseFilter + public let notificationClass: String + public let ignore: [PushLeaseFilter] + public let suppress: PushLeaseSuppression? + + enum CodingKeys: String, CodingKey { + case filter + case notificationClass = "class" + case ignore + case suppress + } + + public init( + filter: PushLeaseFilter, + notificationClass: String, + ignore: [PushLeaseFilter] = [], + suppress: PushLeaseSuppression? = nil + ) { + self.filter = filter + self.notificationClass = notificationClass + self.ignore = ignore + self.suppress = suppress + } +} + +public struct PushLeaseSuppression: Codable, Equatable, Sendable { + public let pTagsMax: Int + + enum CodingKeys: String, CodingKey { + case pTagsMax = "p_tags_max" + } + + public init(pTagsMax: Int) { + self.pTagsMax = pTagsMax + } +} + +public struct PushLeaseFilter: Codable, Equatable, Sendable { + public let kinds: [Int] + public let authors: [String]? + public let pTags: [String]? + public let hTags: [String]? + public let eTags: [String]? + + enum CodingKeys: String, CodingKey { + case kinds + case authors + case pTags = "#p" + case hTags = "#h" + case eTags = "#e" + } + + public init( + kinds: [Int], + authors: [String]? = nil, + pTags: [String]? = nil, + hTags: [String]? = nil, + eTags: [String]? = nil + ) { + self.kinds = kinds + self.authors = authors + self.pTags = pTags + self.hTags = hTags + self.eTags = eTags + } + + public func queryFilter(since: Int?, limit: Int) -> [String: Any] { + var filter: [String: Any] = ["kinds": kinds, "limit": limit] + if let authors { filter["authors"] = authors } + if let pTags { filter["#p"] = pTags } + if let hTags { filter["#h"] = hTags } + if let eTags { filter["#e"] = eTags } + if let since { filter["since"] = since } + return filter + } + + public func matches(_ event: VerifiedNostrEvent) -> Bool { + guard kinds.contains(event.kind) else { return false } + if let authors, !authors.contains(event.pubkey.lowercased()) { return false } + if let pTags, !event.hasAnyTag(named: "p", values: pTags) { return false } + if let hTags, !event.hasAnyTag(named: "h", values: hTags) { return false } + if let eTags, !event.hasAnyTag(named: "e", values: eTags) { return false } + return true + } +} + +public enum PushLeaseMatcher { + public static func matches( + event: VerifiedNostrEvent, + subscription: PushLeaseSubscription + ) -> Bool { + guard subscription.filter.matches(event) else { return false } + if subscription.ignore.contains(where: { $0.matches(event) }) { return false } + if let maximum = subscription.suppress?.pTagsMax, + event.tagCount(named: "p") > maximum + { + return false + } + return true + } +} + +extension VerifiedNostrEvent { + public func tagCount(named name: String) -> Int { + tags.reduce(into: 0) { count, tag in + if tag.count >= 2 && tag[0] == name { count += 1 } + } + } + + public func hasAnyTag(named name: String, values: [String]) -> Bool { + let expected = Set(values.map { $0.lowercased() }) + return tags.contains { tag in + tag.count >= 2 && tag[0] == name && expected.contains(tag[1].lowercased()) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushNotificationIdentity.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushNotificationIdentity.swift new file mode 100644 index 0000000000..4cf827bd0a --- /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/Sources/BuzzPushKit/PushWatermark.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushWatermark.swift deleted file mode 100644 index 34367c8a95..0000000000 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushWatermark.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation - -public enum PushWatermark { - /// Nostr permits modest clock drift, but an authenticated author must not - /// be able to pin notification queries arbitrarily far into the future. - public static let allowedFutureSkewSeconds = 300 - public static let keyPrefix = "buzz.push.watermark." - - public static func key(communityID: String) -> String { - keyPrefix + communityID - } - - public static func persistedTimestamp( - eventTimestamp: Int, - now: Int = Int(Date().timeIntervalSince1970), - allowedFutureSkew: Int = allowedFutureSkewSeconds - ) -> Int { - min(eventTimestamp, now + allowedFutureSkew) - } - - public static func isAcceptable( - eventTimestamp: Int, - now: Int = Int(Date().timeIntervalSince1970), - allowedFutureSkew: Int = allowedFutureSkewSeconds - ) -> Bool { - eventTimestamp <= now + allowedFutureSkew - } - - public static func queryTimestamp( - storedWatermark: Int, - now: Int = Int(Date().timeIntervalSince1970) - ) -> Int { - min(storedWatermark, now) - } - - /// Nostr `since` is inclusive. Keeping the watermark itself allows a later - /// event created in the same second to remain queryable. - public static func querySince(watermark: Int) -> Int? { - watermark > 0 ? watermark : nil - } - - public static func staleKeys( - in storedKeys: [String], - activeCommunityIDs: Set - ) -> [String] { - storedKeys.filter { key in - guard key.hasPrefix(keyPrefix) else { return false } - return !activeCommunityIDs.contains(String(key.dropFirst(keyPrefix.count))) - } - } -} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushCatchUpTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushCatchUpTests.swift new file mode 100644 index 0000000000..32a3d89cf8 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushCatchUpTests.swift @@ -0,0 +1,245 @@ +import XCTest + +@testable import BuzzPushKit + +final class PushCatchUpTests: XCTestCase { + private let mine = String(repeating: "a", count: 64) + private let other = String(repeating: "b", count: 64) + + func testSequentialWakesSelectSameSecondSiblingBeforeConsumedDuplicateFallback() { + let subscription = self.subscription() + let id0 = String(format: "%064x", 0) + let id1 = String(format: "%064x", 1) + let events = [event(id: id0), event(id: id1)] + var state = PushConsumptionState() + + let first = PushCatchUp.orderedSelections( + events: events, + origin: "origin", + subscriptions: [subscription], + consumptionState: state, + verify: { _ in true } + ) + XCTAssertEqual(first.first?.event.id, id0) + XCTAssertEqual(first.first?.wasPreviouslyConsumed, false) + state.consume(PushEventPosition(createdAt: 1_000, id: id0), for: "origin") + + let second = PushCatchUp.orderedSelections( + events: events, + origin: "origin", + subscriptions: [subscription], + consumptionState: state, + verify: { _ in true } + ) + XCTAssertEqual(second.map(\.event.id), [id1, id0]) + XCTAssertEqual(second.map(\.wasPreviouslyConsumed), [false, true]) + } + + func testColdStartSelectsOldestAcrossDistinctSeconds() { + let subscription = self.subscription() + let older = event(id: String(format: "%064x", 1)) + let newer = event(id: String(format: "%064x", 2), createdAt: 1_001) + let state = PushConsumptionState() + + let first = PushCatchUp.orderedSelections( + events: [newer, older], + origin: "origin", + subscriptions: [subscription], + consumptionState: state, + verify: { _ in true } + ).first + + XCTAssertEqual(first?.event.id, older.id) + } + + func testActiveSecondSiblingWinsBeforeNewerSecondCandidate() { + let subscription = self.subscription() + let displayed = event(id: String(format: "%064x", 0)) + let activeSibling = event(id: String(format: "%064x", 1)) + let newer = event(id: String(format: "%064x", 2), createdAt: 1_001) + var state = PushConsumptionState() + state.consume( + PushEventPosition(createdAt: displayed.createdAt, id: displayed.id), + for: "origin" + ) + + let first = PushCatchUp.orderedSelections( + events: [newer, activeSibling, displayed], + origin: "origin", + subscriptions: [subscription], + consumptionState: state, + verify: { _ in true } + ).first + XCTAssertEqual(first?.event.id, activeSibling.id) + + state.consume( + PushEventPosition(createdAt: activeSibling.createdAt, id: activeSibling.id), + for: "origin" + ) + let second = PushCatchUp.orderedSelections( + events: [newer, activeSibling, displayed], + origin: "origin", + subscriptions: [subscription], + consumptionState: state, + verify: { _ in true } + ).first + XCTAssertEqual(second?.event.id, newer.id) + } + + func testLateLowerIDSiblingIsSelectedThenDuplicateFallbackStaysSeparate() { + let subscription = self.subscription() + let higherID = String(repeating: "f", count: 64) + let lowerID = String(repeating: "0", count: 64) + let high = event(id: higherID) + let lateLow = event(id: lowerID) + var state = PushConsumptionState() + state.consume(PushEventPosition(createdAt: 1_000, id: higherID), for: "origin") + + let originState = state.state(for: "origin") + var pager = PushCatchUpPager( + subscriptions: [subscription], + since: state.querySince(for: "origin", now: 2_000), + scan: originState.scan, + pageLimit: 10 + ) + var relayPage: [VerifiedNostrEvent] = [] + while let filter = pager.nextFilter() { + let page = relayResponse(events: [lateLow, high], filters: [filter]) + relayPage.append(contentsOf: page) + pager.receive(rawPage: page) + } + let selections = PushCatchUp.orderedSelections( + events: relayPage, + origin: "origin", + subscriptions: [subscription], + consumptionState: state, + verify: { _ in true } + ) + + XCTAssertEqual(selections.map(\.event.id), [lowerID, higherID]) + XCTAssertEqual(selections.map(\.wasPreviouslyConsumed), [false, true]) + } + + func testRawTailPagingReachesEverySameSecondEvent() { + let subscription = self.subscription() + let events = (0..<25).map { event(id: String(format: "%064x", $0)) } + var pager = PushCatchUpPager( + subscriptions: [subscription], + since: nil, + pageLimit: 10, + maximumPages: 10 + ) + var observed: Set = [] + + while let filter = pager.nextFilter() { + let page = relayResponse(events: events, filters: [filter]) + observed.formUnion(page.map(\.id)) + pager.receive(rawPage: page) + } + + XCTAssertEqual(observed, Set(events.map(\.id))) + XCTAssertEqual(pager.stopReason, .complete) + } + + func testBudgetedTraversalResumesFromPersistedRawTailAndClearsOnlyOnComplete() { + let subscription = self.subscription() + let events = (0..<25).map { + event(id: String(format: "%064x", $0), createdAt: 1_000 + $0) + } + var scan = PushCatchUpScan() + var observations: [String: Int] = [:] + var stops: [PushCatchUpStopReason] = [] + + for _ in 0..<3 { + var pager = PushCatchUpPager( + subscriptions: [subscription], + since: nil, + scan: scan, + pageLimit: 5, + maximumPages: 2 + ) + while let filter = pager.nextFilter() { + let page = relayResponse(events: events, filters: [filter]) + for event in page { observations[event.id, default: 0] += 1 } + pager.receive(rawPage: page) + } + stops.append(try! XCTUnwrap(pager.stopReason)) + scan = pager.scan + } + + XCTAssertEqual(stops, [.pageBudgetExceeded, .pageBudgetExceeded, .complete]) + XCTAssertEqual(observations.count, events.count) + XCTAssertTrue(observations.values.allSatisfy { $0 == 1 }) + XCTAssertEqual(scan, PushCatchUpScan()) + } + + func testMultiChannelSubscriptionEmitsOneHTTPFilterPerHTag() { + let firstChannel = "11111111-1111-4111-8111-111111111111" + let secondChannel = "22222222-2222-4222-8222-222222222222" + let subscription = PushLeaseSubscription( + filter: PushLeaseFilter( + kinds: [9], + hTags: [firstChannel, secondChannel] + ), + notificationClass: "default" + ) + var pager = PushCatchUpPager(subscriptions: [subscription], since: nil) + var emittedHTags: [[String]] = [] + + while let filter = pager.nextFilter() { + emittedHTags.append(filter["#h"] as? [String] ?? []) + pager.receive(rawPage: []) + } + + XCTAssertEqual(emittedHTags, [[firstChannel], [secondChannel]]) + XCTAssertEqual(pager.stopReason, .complete) + } + + private func subscription() -> PushLeaseSubscription { + PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default" + ) + } + + private func event(id: String, createdAt: Int = 1_000) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: id, + pubkey: other, + createdAt: createdAt, + kind: 9, + tags: [["p", mine]], + content: "message", + sig: String(repeating: "c", count: 128) + ) + } + + private func relayResponse( + events: [VerifiedNostrEvent], + filters: [[String: Any]] + ) -> [VerifiedNostrEvent] { + var byID: [String: VerifiedNostrEvent] = [:] + for filter in filters { + let limit = filter["limit"] as? Int ?? events.count + let ids = (filter["ids"] as? [String]).map(Set.init) + let since = filter["since"] as? Int + let until = filter["until"] as? Int + let beforeID = filter["before_id"] as? String + let matches = events.filter { event in + guard ids?.contains(event.id) ?? true else { return false } + guard since.map({ event.createdAt >= $0 }) ?? true else { return false } + if let until, let beforeID { + return event.createdAt < until + || (event.createdAt == until && event.id > beforeID) + } + return until.map({ event.createdAt <= $0 }) ?? true + }.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + }.prefix(limit) + for event in matches { + byID[event.id] = event + } + } + return Array(byID.values) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushConsumptionStateTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushConsumptionStateTests.swift new file mode 100644 index 0000000000..56e41771b7 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushConsumptionStateTests.swift @@ -0,0 +1,226 @@ +import Foundation +import XCTest + +@testable import BuzzPushKit + +final class PushConsumptionStateTests: XCTestCase { + func testSameSecondSiblingsRemainReachable() { + var state = PushConsumptionState() + let first = PushEventPosition(createdAt: 1_000, id: "a") + let sibling = PushEventPosition(createdAt: 1_000, id: "b") + + state.consume(first, for: "origin") + + XCTAssertEqual(state.querySince(for: "origin", now: 2_000), 1_000) + XCTAssertFalse(state.canSelect(first, for: "origin", now: 2_000)) + XCTAssertTrue(state.canSelect(sibling, for: "origin", now: 2_000)) + } + + func testLosingOriginIsNotConsumed() { + var state = PushConsumptionState() + state.consume(PushEventPosition(createdAt: 2_000, id: "winner"), for: "winner-origin") + + XCTAssertTrue( + state.canSelect( + PushEventPosition(createdAt: 1_999, id: "loser"), + for: "loser-origin", + now: 3_000 + ) + ) + XCTAssertNil(state.querySince(for: "loser-origin", now: 3_000)) + } + + func testDeliveredEventIsNeverSelectedAgainAndActiveSecondHistoryIsExact() { + var state = PushConsumptionState() + for index in 0..<70 { + state.consume( + PushEventPosition(createdAt: 1_000, id: String(format: "%064x", index)), + for: "origin" + ) + } + let latest = PushEventPosition(createdAt: 1_000, id: String(format: "%064x", 69)) + + XCTAssertFalse(state.canSelect(latest, for: "origin", now: 2_000)) + XCTAssertEqual(state.state(for: "origin").delivered.count, 70) + } + + func testActiveSecondHistoryPrunesWhenTimestampAdvances() { + var state = PushConsumptionState() + for index in 0..<70 { + state.consume( + PushEventPosition(createdAt: 1_000, id: String(format: "%064x", index)), + for: "origin" + ) + } + let first = PushEventPosition(createdAt: 1_000, id: String(format: "%064x", 0)) + let nextSecond = PushEventPosition(createdAt: 1_001, id: "next") + + XCTAssertTrue(state.hasConsumed(eventID: first.id, for: "origin")) + state.consume(nextSecond, for: "origin") + + XCTAssertFalse(state.hasConsumed(eventID: first.id, for: "origin")) + XCTAssertEqual(state.state(for: "origin").delivered, [nextSecond]) + } + + func testDisplayedIDsAtOrAboveFloorAreRetained() { + var state = PushConsumptionState() + let floor = PushEventPosition(createdAt: 1_000, id: "floor") + let newer = PushEventPosition(createdAt: 1_002, id: "newer") + let middle = PushEventPosition(createdAt: 1_001, id: "middle") + + state.consume(floor, for: "origin") + state.consume(newer, for: "origin", advanceFloor: false) + state.consume(middle, for: "origin", advanceFloor: false) + + let originState = state.state(for: "origin") + XCTAssertEqual(originState.cursor, floor) + XCTAssertEqual(originState.delivered, [floor, middle, newer]) + } + + func testIncompleteTraversalKeepsFloorAndPersistsScanThroughConsumeAndCodable() throws { + let floor = PushEventPosition(createdAt: 1_000, id: "floor") + let displayed = PushEventPosition(createdAt: 1_015, id: "displayed") + let before = PushEventPosition(createdAt: 1_010, id: "raw-tail") + let scan = PushCatchUpScan(subscriptionIndex: 2, before: before) + var state = PushConsumptionState() + state.consume(floor, for: "origin") + + state.consume(displayed, for: "origin", advanceFloor: false, scan: scan) + + let originState = state.state(for: "origin") + XCTAssertEqual(originState.cursor, floor) + XCTAssertEqual(originState.scan, scan) + XCTAssertTrue(state.hasConsumed(eventID: displayed.id, for: "origin")) + let decoded = try JSONDecoder().decode( + PushConsumptionState.self, + from: JSONEncoder().encode(state) + ) + XCTAssertEqual(decoded.state(for: "origin"), originState) + } + + func testNegativePersistedScanIndexIsRejected() throws { + let data = try XCTUnwrap( + #"{"version":1,"origins":{"origin":{"delivered":[],"scan":{"subscriptionIndex":-1}}}}"# + .data(using: .utf8) + ) + + XCTAssertThrowsError(try JSONDecoder().decode(PushConsumptionState.self, from: data)) + } + + func testLowerIDSiblingArrivingAfterHigherIDRemainsSelectable() { + var state = PushConsumptionState() + let higher = PushEventPosition(createdAt: 1_000, id: "ff") + let laterLower = PushEventPosition(createdAt: 1_000, id: "00") + + state.consume(higher, for: "origin") + + XCTAssertTrue(state.canSelect(laterLower, for: "origin", now: 2_000)) + } + + func testEventOlderThanCompositeCursorIsNotSelected() { + var state = PushConsumptionState() + state.consume(PushEventPosition(createdAt: 2_000, id: "newer"), for: "origin") + + XCTAssertFalse( + state.canSelect( + PushEventPosition(createdAt: 1_999, id: "older"), + for: "origin", + now: 3_000 + ) + ) + } + + func testEventBeyondAllowedFutureSkewIsNotSelected() { + let state = PushConsumptionState() + + XCTAssertTrue( + state.canSelect( + PushEventPosition(createdAt: 1_300, id: "boundary"), + for: "origin", + now: 1_000, + allowedFutureSkew: 300 + ) + ) + XCTAssertFalse( + state.canSelect( + PushEventPosition(createdAt: 1_301, id: "future"), + for: "origin", + now: 1_000, + allowedFutureSkew: 300 + ) + ) + } + + func testInterleavedWritersCannotLoseDeliveredIDOrMoveCursorBackward() throws { + let lock = TestLock() + let persistence = TestPersistence() + let storeA = PushConsumptionStateStore(lock: lock, persistence: persistence) + let storeB = PushConsumptionStateStore(lock: lock, persistence: persistence) + let writerARead = expectation(description: "writer A entered") + let letWriterAFinish = DispatchSemaphore(value: 0) + let completed = expectation(description: "writers completed") + completed.expectedFulfillmentCount = 2 + + DispatchQueue.global().async { + defer { completed.fulfill() } + do { + try storeA.update { state in + writerARead.fulfill() + _ = letWriterAFinish.wait(timeout: .now() + 2) + state.consume(PushEventPosition(createdAt: 2_000, id: "b"), for: "origin") + } + } catch { + XCTFail("writer A failed: \(error)") + } + } + wait(for: [writerARead], timeout: 1) + DispatchQueue.global().async { + defer { completed.fulfill() } + do { + try storeB.update { state in + state.consume(PushEventPosition(createdAt: 2_000, id: "a"), for: "origin") + } + } catch { + XCTFail("writer B failed: \(error)") + } + } + letWriterAFinish.signal() + wait(for: [completed], timeout: 3) + + let state = try storeA.read().state(for: "origin") + XCTAssertEqual(state.cursor, PushEventPosition(createdAt: 2_000, id: "b")) + XCTAssertEqual(Set(state.delivered.map(\.id)), ["a", "b"]) + } + + func testStateFileIsVersionedAndDeterministic() throws { + let persistence = TestPersistence() + let store = PushConsumptionStateStore(lock: TestLock(), persistence: persistence) + try store.update { state in + state.consume(PushEventPosition(createdAt: 1_000, id: "event"), for: "origin") + } + + let json = try XCTUnwrap( + persistence.data.flatMap { + try? JSONSerialization.jsonObject(with: $0) as? [String: Any] + }) + XCTAssertEqual(json["version"] as? Int, 1) + XCTAssertNotNil(json["origins"]) + } +} + +private final class TestLock: PushConsumptionStateLocking, @unchecked Sendable { + private let lock = NSRecursiveLock() + + func withExclusiveLock(_ body: () throws -> T) throws -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } +} + +private final class TestPersistence: PushConsumptionStatePersisting, @unchecked Sendable { + var data: Data? + + func read() throws -> Data? { data } + func write(_ data: Data) throws { self.data = data } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift new file mode 100644 index 0000000000..3c8190d393 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift @@ -0,0 +1,109 @@ +import XCTest + +@testable import BuzzPushKit + +final class PushLeaseTests: XCTestCase { + private let mine = String(repeating: "a", count: 64) + private let other = String(repeating: "b", count: 64) + + func testDesiredAuthorityIsExplicitAndAcceptedAuthorityRequiresState() throws { + let subscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default" + ) + XCTAssertEqual( + try PushLeaseSubscriptionState( + authority: "desired", + desired: [subscription] + ).authoritativeSubscriptions(), + [subscription] + ) + XCTAssertThrowsError( + try PushLeaseSubscriptionState( + authority: "accepted", + desired: [subscription] + ).authoritativeSubscriptions() + ) { error in + XCTAssertEqual(error as? PushLeaseError, .acceptedAuthorityMissingSubscriptions) + } + } + + func testFilterBuildsQueryFromLeaseWithoutHardcodedKinds() { + let filter = PushLeaseFilter( + kinds: [7, 1059], + authors: [other], + pTags: [mine], + hTags: ["channel"], + eTags: [String(repeating: "c", count: 64)] + ) + let query = filter.queryFilter(since: 1_000, limit: 10) + + XCTAssertEqual(query["kinds"] as? [Int], [7, 1059]) + XCTAssertEqual(query["authors"] as? [String], [other]) + XCTAssertEqual(query["#p"] as? [String], [mine]) + XCTAssertEqual(query["#h"] as? [String], ["channel"]) + XCTAssertEqual(query["since"] as? Int, 1_000) + } + + func testPushEligibleKindAbsentFromOldConstantMatchesLease() { + let event = makeEvent(kind: 1059, tags: [["p", mine]]) + let subscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [1059], pTags: [mine]), + notificationClass: "default" + ) + + XCTAssertTrue(PushLeaseMatcher.matches(event: event, subscription: subscription)) + } + + func testIgnoreAndHellthreadSuppressionRejectCandidates() { + let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]]) + let ignoreSubscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default", + ignore: [PushLeaseFilter(kinds: [9], authors: [other])] + ) + XCTAssertFalse( + PushLeaseMatcher.matches(event: ignored, subscription: ignoreSubscription) + ) + + let hellthread = makeEvent( + kind: 9, + tags: (0..<21).map { ["p", String(format: "%064x", $0)] } + ) + let suppressed = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], authors: [other]), + notificationClass: "default", + suppress: PushLeaseSuppression(pTagsMax: 20) + ) + XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, subscription: suppressed)) + } + + func testDecodesSnapshotContractFromDartShape() throws { + let json = """ + {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","pushSubscriptionState":{"authority":"desired","desired":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"class":"default","ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}}]} + """ + let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8)) + + XCTAssertEqual(snapshot.communities.count, 1) + XCTAssertEqual( + try snapshot.communities[0].pushSubscriptionState.authoritativeSubscriptions().count, + 1 + ) + } + + private func makeEvent( + kind: Int, + pubkey: String? = nil, + tags: [[String]] = [] + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: String(repeating: "d", count: 64), + pubkey: pubkey ?? other, + createdAt: 1_000, + kind: kind, + tags: tags, + content: "message", + sig: String(repeating: "e", count: 128) + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushNotificationIdentityTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushNotificationIdentityTests.swift new file mode 100644 index 0000000000..87112e3f4e --- /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/BuzzPushKit/Tests/BuzzPushKitTests/PushWatermarkTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushWatermarkTests.swift deleted file mode 100644 index af2558e567..0000000000 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushWatermarkTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -import XCTest -@testable import BuzzPushKit - -final class PushWatermarkTests: XCTestCase { - func testClampsFutureTimestampToAllowedClockSkew() { - XCTAssertEqual( - PushWatermark.persistedTimestamp( - eventTimestamp: 2_000, - now: 1_000, - allowedFutureSkew: 300 - ), - 1_300 - ) - } - - func testPreservesTimestampWithinAllowedClockSkew() { - XCTAssertEqual( - PushWatermark.persistedTimestamp( - eventTimestamp: 1_200, - now: 1_000, - allowedFutureSkew: 300 - ), - 1_200 - ) - } - - func testRejectsEventsBeyondAllowedClockSkew() { - XCTAssertFalse( - PushWatermark.isAcceptable( - eventTimestamp: 1_301, - now: 1_000, - allowedFutureSkew: 300 - ) - ) - XCTAssertTrue( - PushWatermark.isAcceptable( - eventTimestamp: 1_300, - now: 1_000, - allowedFutureSkew: 300 - ) - ) - } - - func testRepairsPoisonedStoredWatermarkToCurrentTime() { - XCTAssertEqual( - PushWatermark.queryTimestamp(storedWatermark: 2_000, now: 1_000), - 1_000 - ) - XCTAssertEqual( - PushWatermark.queryTimestamp(storedWatermark: 900, now: 1_000), - 900 - ) - } - - func testQuerySinceIsInclusiveForSameSecondEvents() { - XCTAssertEqual(PushWatermark.querySince(watermark: 1_000), 1_000) - XCTAssertNil(PushWatermark.querySince(watermark: 0)) - } - - func testFindsOnlyRemovedCommunityWatermarks() { - XCTAssertEqual( - PushWatermark.staleKeys( - in: [ - PushWatermark.key(communityID: "kept"), - PushWatermark.key(communityID: "removed"), - "unrelated", - ], - activeCommunityIDs: ["kept"] - ), - [PushWatermark.key(communityID: "removed")] - ) - } -} diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index b85017ffb4..b90fa76982 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -19,26 +19,40 @@ final class NotificationService: UNNotificationServiceExtension { } bestAttemptContent = content - resolver.resolve { [weak self] resolution in + resolver.resolve { [weak self] result in guard let self else { return } - if let resolution { + switch result { + case .notification(let resolution): content.title = resolution.title content.body = resolution.body - if let subtitle = resolution.subtitle { - content.subtitle = subtitle - } + content.subtitle = resolution.subtitle ?? "" 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 + content.subtitle = "" + content.threadIdentifier = "buzz.push.diagnostic" + case .none: + break } self.finish(content) } } override func serviceExtensionTimeWillExpire() { - if let bestAttemptContent { - finish(bestAttemptContent) - } + if let bestAttemptContent { finish(bestAttemptContent) } } private func finish(_ content: UNNotificationContent) { @@ -48,145 +62,508 @@ final class NotificationService: UNNotificationServiceExtension { } } -struct BuzzPushResolution: Decodable { +struct BuzzPushResolution { let title: String let body: String let subtitle: String? let threadIdentifier: String? + let identity: PushNotificationIdentity +} + +enum BuzzPushResolutionResult { + case notification(BuzzPushResolution) + case diagnostic(String) + case none } protocol BuzzPushNotificationResolving { - func resolve(completion: @escaping (BuzzPushResolution?) -> Void) + 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 + let catchUpStopReason: PushCatchUpStopReason + let catchUpScan: PushCatchUpScan + } + private let session: URLSession private let appGroupIdentifier: String? private let keychainAccessGroup: String? - private let defaults: UserDefaults? + private let notificationCenter: BuzzDeliveredNotificationManaging + private let fileManager: FileManager init( session: URLSession = .shared, - appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String, - keychainAccessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + appGroupIdentifier: String? = Bundle.main.object( + forInfoDictionaryKey: "BuzzAppGroupIdentifier" + ) as? String, + 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 - defaults = appGroupIdentifier.flatMap(UserDefaults.init(suiteName:)) + self.notificationCenter = notificationCenter + self.fileManager = fileManager } - func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { - let loadedCommunities = loadCommunities() - removeStaleWatermarks(activeCommunityIDs: Set(loadedCommunities.map(\.id))) - let communities = loadedCommunities.filter { + func resolve(completion: @escaping (BuzzPushResolutionResult) -> Void) { + let loaded: (communities: [PushLeaseCommunity], store: PushConsumptionStateStore) + do { + loaded = try loadState() + } catch { + completion(.diagnostic("Open Buzz to refresh notification subscriptions.")) + return + } + + let communities = loaded.communities.filter { $0.pubkey?.isEmpty == false && loadPrivateKey(communityID: $0.id) != nil } - guard !communities.isEmpty else { completion(nil); return } + guard !communities.isEmpty else { + completion(.diagnostic("Open Buzz to restore notification credentials.")) + return + } + + let consumptionState: PushConsumptionState + do { + consumptionState = try loaded.store.read() + } catch { + completion(.diagnostic("Buzz could not read notification history.")) + return + } + let group = DispatchGroup() let lock = NSLock() - var candidates: [(BuzzPushResolution, VerifiedNostrEvent, BuzzPushCommunity)] = [] + var outcomes: [String: QueryResult] = [:] for community in communities { group.enter() - query(community) { candidate in - if let candidate { - lock.lock(); candidates.append((candidate.0, candidate.1, community)); lock.unlock() - } + query(community, consumptionState: consumptionState) { result in + lock.lock() + outcomes[community.id] = result + lock.unlock() group.leave() } } group.notify(queue: .global(qos: .userInitiated)) { [weak self] in guard let self else { return } - let newest = candidates.max { - $0.1.createdAt == $1.1.createdAt ? $0.1.id > $1.1.id : $0.1.createdAt < $1.1.createdAt + let candidates = outcomes.values.compactMap { result -> Candidate? in + guard case .candidate(let candidate) = result else { return nil } + return candidate } - for candidate in candidates { - self.defaults?.set( - PushWatermark.persistedTimestamp(eventTimestamp: candidate.1.createdAt), - forKey: PushWatermark.key(communityID: candidate.2.id) - ) + let diagnostics = outcomes.values.compactMap { result -> String? in + switch result { + case .diagnostic(let diagnostic): return diagnostic + case .traversal(let traversal): return traversal.diagnostic + case .candidate(let candidate): + return candidate.catchUpStopReason == .complete + ? nil + : Self.incompleteTraversalMessage + } + } + let traversals = outcomes.values.compactMap { result -> CommunityTraversal? in + switch result { + case .candidate(let candidate): + return CommunityTraversal( + community: candidate.community, + scan: candidate.catchUpScan, + diagnostic: nil + ) + case .traversal(let traversal): return traversal + case .diagnostic: return nil + } } - completion(newest?.0) + 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 + } + if lhs.event.id != rhs.event.id { return lhs.event.id < rhs.event.id } + return lhs.community.id < rhs.community.id + } + let incompleteTraversal = outcomes.values.contains { result in + switch result { + case .candidate(let candidate): + return candidate.catchUpStopReason != .complete + case .traversal(let traversal): + return traversal.diagnostic == Self.incompleteTraversalMessage + case .diagnostic: + return false + } + } + guard let winner = sorted.first else { + do { + try loaded.store.update { state in + state.removeInactiveOrigins(Set(communities.map(\.id))) + for traversal in traversals { + state.updateScan(traversal.scan, for: traversal.community.id) + } + } + } catch { + completion(.diagnostic("Buzz could not save notification history.")) + return + } + completion(diagnostics.sorted().first.map(BuzzPushResolutionResult.diagnostic) ?? .none) + return + } + + let position = PushEventPosition( + createdAt: winner.event.createdAt, + id: winner.event.id + ) + var shouldPresent = winner.wasPreviouslyConsumed + do { + try loaded.store.update { state in + state.removeInactiveOrigins(Set(communities.map(\.id))) + for traversal in traversals { + guard traversal.community.id != winner.community.id else { continue } + state.updateScan(traversal.scan, for: traversal.community.id) + } + if state.hasConsumed(eventID: position.id, for: winner.community.id) { + state.updateScan(winner.catchUpScan, for: winner.community.id) + shouldPresent = true + return + } + guard state.canSelect(position, for: winner.community.id) else { return } + state.consume( + position, + for: winner.community.id, + advanceFloor: winner.catchUpStopReason == .complete, + scan: winner.catchUpScan + ) + shouldPresent = true + } + } catch { + completion(.diagnostic("Buzz could not save notification history.")) + return + } + guard shouldPresent else { + completion(.none) + return + } + guard !incompleteTraversal else { + completion(.diagnostic(Self.incompleteTraversalMessage)) + return + } + absorbSequentialDuplicate(of: winner.resolution, completion: completion) } } + private enum QueryResult { + case candidate(Candidate) + case traversal(CommunityTraversal) + case diagnostic(String) + } + + private struct CommunityTraversal { + let community: PushLeaseCommunity + let scan: PushCatchUpScan + let diagnostic: String? + } + + private static let incompleteTraversalMessage = + "Open Buzz to finish checking for new activity." + private func query( - _ community: BuzzPushCommunity, - completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void + _ community: PushLeaseCommunity, + consumptionState: PushConsumptionState, + completion: @escaping (QueryResult) -> Void ) { - guard let privateKey = loadPrivateKey(communityID: community.id), let pubkey = community.pubkey else { - completion(nil); return - } - var filter: [String: Any] = ["kinds": [9, 40002, 45001, 45003], "#p": [pubkey], "limit": 10] - let watermarkKey = PushWatermark.key(communityID: community.id) - let storedWatermark = defaults?.integer(forKey: watermarkKey) ?? 0 - let watermark = PushWatermark.queryTimestamp(storedWatermark: storedWatermark) - if watermark != storedWatermark { defaults?.set(watermark, forKey: watermarkKey) } - if let since = PushWatermark.querySince(watermark: watermark) { filter["since"] = since } - guard let body = try? JSONSerialization.data(withJSONObject: [filter]) else { completion(nil); return } - let url = URL(string: "/query", relativeTo: community.relayURL)! + guard let privateKey = loadPrivateKey(communityID: community.id) else { + completion(.diagnostic("Open Buzz to restore notification credentials.")) + return + } + let subscriptions: [PushLeaseSubscription] + do { + subscriptions = try community.pushSubscriptionState.authoritativeSubscriptions() + } catch { + completion(.diagnostic("Open Buzz to refresh notification subscriptions.")) + return + } + let originState = consumptionState.state(for: community.id) + let pager = PushCatchUpPager( + subscriptions: subscriptions, + since: consumptionState.querySince(for: community.id), + scan: originState.scan + ) + guard let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL) + else { + completion(.diagnostic("Buzz notification relay URL is invalid.")) + return + } + + queryNextPage( + pager: pager, + eventsByID: [:], + url: url, + privateKey: privateKey + ) { result in + switch result { + case .success(let traversalResult): + let traversal = CommunityTraversal( + community: community, + scan: traversalResult.scan, + diagnostic: traversalResult.stopReason == .complete + ? nil + : Self.incompleteTraversalMessage + ) + let candidate = Self.decodeCandidate( + events: Array(traversalResult.eventsByID.values), + community: community, + subscriptions: subscriptions, + consumptionState: consumptionState, + stopReason: traversalResult.stopReason, + scan: traversalResult.scan + ) + completion(candidate.map(QueryResult.candidate) ?? .traversal(traversal)) + case .failure: + completion( + .traversal( + CommunityTraversal( + community: community, + scan: originState.scan, + diagnostic: nil + ) + ) + ) + } + } + } + + private enum CatchUpQueryError: Error { + case invalidFilters + case authentication + case relay + } + + private struct CatchUpTraversal { + let eventsByID: [String: VerifiedNostrEvent] + let stopReason: PushCatchUpStopReason + let scan: PushCatchUpScan + } + + private func queryNextPage( + pager: PushCatchUpPager, + eventsByID: [String: VerifiedNostrEvent], + url: URL, + privateKey: String, + completion: @escaping (Result) -> Void + ) { + var pager = pager + guard let filter = pager.nextFilter() else { + guard let stopReason = pager.stopReason else { + completion(.failure(.relay)) + return + } + completion( + .success( + CatchUpTraversal( + eventsByID: eventsByID, + stopReason: stopReason, + scan: pager.scan + ) + ) + ) + return + } + guard let body = try? JSONSerialization.data(withJSONObject: [filter]) else { + completion(.failure(.invalidFilters)) + return + } + var request = URLRequest(url: url) - request.httpMethod = "POST"; request.httpBody = body; request.timeoutInterval = 8 + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = max(1, pager.remainingTraversalSeconds()) request.setValue("application/json", forHTTPHeaderField: "Content-Type") - guard let auth = try? NostrHTTPAuth.authorizationHeader( - url: url, method: "POST", body: body, privateKeyHex: privateKey - ) else { completion(nil); return } + guard + let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, + method: "POST", + body: body, + privateKeyHex: privateKey + ) + else { + completion(.failure(.authentication)) + return + } request.setValue(auth, forHTTPHeaderField: "Authorization") - session.dataTask(with: request) { data, response, _ in - guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), - let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) - else { completion(nil); return } - completion(Self.decodeResolution( - events: events.filter { - $0.hasValidIDAndSignature() && PushWatermark.isAcceptable(eventTimestamp: $0.createdAt) - }, - community: community - )) + + session.dataTask(with: request) { [weak self] data, response, _ in + guard let self, + let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + let data, + let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion(.failure(.relay)) + return + } + var nextEventsByID = eventsByID + for event in events { + nextEventsByID[event.id] = event + } + pager.receive(rawPage: events) + self.queryNextPage( + pager: pager, + eventsByID: nextEventsByID, + url: url, + privateKey: privateKey, + completion: completion + ) }.resume() } - private static func decodeResolution( - events: [VerifiedNostrEvent], community: BuzzPushCommunity - ) -> (BuzzPushResolution, VerifiedNostrEvent)? { - guard let mine = community.pubkey?.lowercased() else { return nil } - let event = events.filter { - $0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind) - }.sorted { - $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt - }.first - guard let event else { return nil } - let body = previewBody(event.content) - guard !body.isEmpty else { return nil } - let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1] - return (BuzzPushResolution( - title: shortPubkey(event.pubkey), body: body, subtitle: community.name, - threadIdentifier: channel ?? community.id - ), event) + private static func decodeCandidate( + events: [VerifiedNostrEvent], + community: PushLeaseCommunity, + subscriptions: [PushLeaseSubscription], + consumptionState: PushConsumptionState, + stopReason: PushCatchUpStopReason, + scan: PushCatchUpScan + ) -> Candidate? { + let matching = PushCatchUp.orderedSelections( + events: events, + origin: community.id, + subscriptions: subscriptions, + consumptionState: consumptionState + ) + + for selection in matching { + let event = selection.event + let identity = PushNotificationIdentity(eventID: event.id, origin: community.id) + let resolution: BuzzPushResolution + if event.kind == 9 { + let body = previewBody(event.content) + guard !body.isEmpty else { continue } + let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1] + resolution = BuzzPushResolution( + title: shortPubkey(event.pubkey), + body: body, + subtitle: community.name, + threadIdentifier: channel ?? community.id, + identity: identity + ) + } else { + // This extension renders kind 9 only. Other lease-authorized wake kinds + // stay selectable so issue 10's catch-up sets remain aligned, but use + // neutral activity copy until kind-aware rendering lands in issue 20. + let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1] + resolution = BuzzPushResolution( + title: community.name, + body: "Open Buzz to view your new activity.", + subtitle: nil, + threadIdentifier: channel ?? community.id, + identity: identity + ) + } + return Candidate( + resolution: resolution, + event: event, + community: community, + wasPreviouslyConsumed: selection.wasPreviouslyConsumed, + catchUpStopReason: stopReason, + catchUpScan: scan + ) + } + return nil + } + + 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]*?```"#, with: "[code]", options: .regularExpression) - result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression) - result = result.replacingOccurrences(of: #"!?\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression) - result = result.replacingOccurrences(of: #"https?://\S+"#, with: "[link]", options: .regularExpression) - result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) - return result.count > 180 ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result + var result = content.replacingOccurrences( + of: #"```[\s\S]*?```"#, + with: "[code]", + options: .regularExpression + ) + result = result.replacingOccurrences( + of: #"`([^`]*)`"#, + with: "$1", + options: .regularExpression + ) + result = result.replacingOccurrences( + of: #"!?\[([^\]]*)\]\([^)]*\)"#, + with: "$1", + options: .regularExpression + ) + result = result.replacingOccurrences( + of: #"https?://\S+"#, + with: "[link]", + options: .regularExpression + ) + result = result.replacingOccurrences( + of: #"\s+"#, + with: " ", + options: .regularExpression + ).trimmingCharacters(in: .whitespacesAndNewlines) + return result.count > 180 + ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" + : result } private static func shortPubkey(_ pubkey: String) -> String { pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey } - private func removeStaleWatermarks(activeCommunityIDs: Set) { - guard let defaults else { return } - for key in PushWatermark.staleKeys( - in: Array(defaults.dictionaryRepresentation().keys), - activeCommunityIDs: activeCommunityIDs - ) { - defaults.removeObject(forKey: key) - } - } - private func loadPrivateKey(communityID: String) -> String? { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -195,34 +572,42 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] - if let keychainAccessGroup, !keychainAccessGroup.isEmpty { query[kSecAttrAccessGroup as String] = keychainAccessGroup } + if let keychainAccessGroup, !keychainAccessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = keychainAccessGroup + } var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, - let data = item as? Data else { return nil } + let data = item as? Data + else { return nil } return String(data: data, encoding: .utf8) } - private func loadCommunities() -> [BuzzPushCommunity] { + private func loadState() throws -> ( + communities: [PushLeaseCommunity], + store: PushConsumptionStateStore + ) { guard let appGroupIdentifier, - let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier), - let data = try? Data(contentsOf: container.appendingPathComponent("push-communities.json")), - let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data) - else { return [] } - return decoded.communities + let container = fileManager.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { + throw CocoaError(.fileNoSuchFile) + } + let snapshotURL = container.appendingPathComponent("push-communities.json") + let data = try Data(contentsOf: snapshotURL) + let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: data) + return (snapshot.communities, PushConsumptionStateStore(containerURL: container)) } } -struct BuzzPushSnapshot: Decodable { - let communities: [BuzzPushCommunity] -} - -struct BuzzPushCommunity: Decodable { - let id: String - let name: String - let relayUrl: String - let pubkey: String? - - var relayURL: URL { - URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")! +extension PushLeaseCommunity { + fileprivate var relayURL: URL? { + guard let url = URL(string: relayUrl), + let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http" + else { + return nil + } + return url } } diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 38a7e60aee..21d4aad137 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -13,6 +13,7 @@ import 'features/profile/user_status_cache_provider.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/relay/relay.dart'; +import 'shared/push/push_subscription_provider.dart'; import 'shared/theme/theme.dart'; class App extends HookConsumerWidget { @@ -39,6 +40,7 @@ class App extends HookConsumerWidget { ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); ref.watch(userStatusCacheProvider); + ref.watch(pushSubscriptionSyncProvider); } // Start listening for buzz:// links immediately (even pre-auth) so a diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 1858609e05..35239949d9 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -1,5 +1,7 @@ import 'package:uuid/uuid.dart'; +import '../push/push_subscription.dart'; + const _uuid = Uuid(); const _sentinel = Object(); @@ -9,6 +11,7 @@ class Community { final String relayUrl; final String? pubkey; final String? nsec; + final BuzzPushLeaseSubscriptionState pushSubscriptionState; final DateTime addedAt; const Community({ @@ -17,6 +20,7 @@ class Community { required this.relayUrl, this.pubkey, this.nsec, + this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(), required this.addedAt, }); @@ -41,6 +45,7 @@ class Community { String? relayUrl, Object? pubkey = _sentinel, Object? nsec = _sentinel, + BuzzPushLeaseSubscriptionState? pushSubscriptionState, }) { return Community( id: id, @@ -48,6 +53,8 @@ class Community { relayUrl: relayUrl ?? this.relayUrl, pubkey: pubkey == _sentinel ? this.pubkey : pubkey as String?, nsec: nsec == _sentinel ? this.nsec : nsec as String?, + pushSubscriptionState: + pushSubscriptionState ?? this.pushSubscriptionState, addedAt: addedAt, ); } @@ -58,6 +65,7 @@ class Community { 'relayUrl': relayUrl, if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, + 'pushSubscriptionState': pushSubscriptionState.toJson(), 'addedAt': addedAt.toIso8601String(), }; @@ -67,6 +75,11 @@ class Community { relayUrl: json['relayUrl'] as String, pubkey: json['pubkey'] as String?, nsec: json['nsec'] as String?, + pushSubscriptionState: json['pushSubscriptionState'] == null + ? const BuzzPushLeaseSubscriptionState.desired() + : BuzzPushLeaseSubscriptionState.fromJson( + Map.from(json['pushSubscriptionState'] as Map), + ), addedAt: DateTime.parse(json['addedAt'] as String), ); diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 842877c9cd..bf2f4d7583 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth_provider.dart'; import '../push/push_bridge.dart'; +import '../push/push_subscription.dart'; import 'community.dart'; import 'community_storage.dart'; @@ -40,6 +41,9 @@ class _CommunitySnapshotSync { community.relayUrl, community.pubkey, community.nsec, + buzzPushSubscriptionStateFingerprint( + community.pushSubscriptionState, + ), ].join('\u0000'), ) .join('\u0001'); @@ -141,6 +145,33 @@ class CommunityListNotifier extends AsyncNotifier> { ref.invalidate(authProvider); } + Future updateDesiredPushSubscriptions( + String id, + List desired, + ) async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + + final community = current[index]; + if (buzzPushSubscriptionsFingerprint( + community.pushSubscriptionState.desired, + ) == + buzzPushSubscriptionsFingerprint(desired)) { + return; + } + final updated = community.copyWith( + pushSubscriptionState: community.pushSubscriptionState.withDesired( + desired, + ), + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + } + Future renameCommunity(String id, String name) async { final storage = ref.read(communityStorageProvider); final current = state.value ?? []; diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 24021af194..2777db2632 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -37,6 +37,7 @@ Future registerBuzzPushCommunitySnapshot( name: community.name, relayUrl: community.relayUrl, pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), + pushSubscriptionState: community.pushSubscriptionState, ), ]; final signingKeys = {}; diff --git a/mobile/lib/shared/push/push_models.dart b/mobile/lib/shared/push/push_models.dart index f741e5b9ec..2649486af7 100644 --- a/mobile/lib/shared/push/push_models.dart +++ b/mobile/lib/shared/push/push_models.dart @@ -1,4 +1,5 @@ import '../relay/nostr_models.dart'; +import 'push_subscription.dart'; const buzzPushFallbackBody = 'Reconnect to your relay now'; @@ -7,12 +8,14 @@ class BuzzPushCommunitySnapshot { final String name; final String relayUrl; final String? pubkey; + final BuzzPushLeaseSubscriptionState pushSubscriptionState; const BuzzPushCommunitySnapshot({ required this.id, required this.name, required this.relayUrl, this.pubkey, + required this.pushSubscriptionState, }); Map toJson() => { @@ -20,6 +23,7 @@ class BuzzPushCommunitySnapshot { 'name': name, 'relayUrl': relayUrl, if (pubkey != null) 'pubkey': pubkey, + 'pushSubscriptionState': pushSubscriptionState.toJson(), }; factory BuzzPushCommunitySnapshot.fromJson(Map json) { @@ -28,6 +32,9 @@ class BuzzPushCommunitySnapshot { name: json['name'] as String, relayUrl: json['relayUrl'] as String, pubkey: json['pubkey'] as String?, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.fromJson( + Map.from(json['pushSubscriptionState'] as Map), + ), ); } } diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart new file mode 100644 index 0000000000..fb4b4057b1 --- /dev/null +++ b/mobile/lib/shared/push/push_subscription.dart @@ -0,0 +1,394 @@ +import 'dart:convert'; + +const buzzPushEligibleKinds = [7, 9, 1059, 40007, 46010]; +// Buzz reaction events carry no `p` tag, so kind 7 cannot match this exact +// self-directed filter even though the relay remains eligible to wake on it. +const buzzPushSelfDirectedKinds = [9, 1059, 40007, 46010]; +const buzzPushRenderableKinds = [9]; +const buzzPushChannelKinds = [9]; +const buzzPushChannelChunkSize = 50; +const buzzPushMaxSubscriptions = 16; +const buzzPushMaxIgnoreFilters = 8; +const buzzPushHellthreadParticipantLimit = 20; + +const _supportedNotificationClasses = {'silent', 'default', 'time_sensitive'}; +const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; +final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); +final _channelIdPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', +); + +enum BuzzPushLeaseSubscriptionAuthority { desired, accepted } + +class BuzzPushFilter { + final List kinds; + final List? authors; + final List? pTags; + final List? hTags; + final List? eTags; + + BuzzPushFilter({ + required Iterable kinds, + Iterable? authors, + Iterable? pTags, + Iterable? hTags, + Iterable? eTags, + }) : kinds = List.unmodifiable(kinds), + authors = _optionalList(authors), + pTags = _optionalList(pTags), + hTags = _optionalList(hTags), + eTags = _optionalList(eTags) { + _validate(); + } + + Map toJson() => { + 'kinds': kinds, + if (authors != null) 'authors': authors, + if (pTags != null) '#p': pTags, + if (hTags != null) '#h': hTags, + if (eTags != null) '#e': eTags, + }; + + factory BuzzPushFilter.fromJson(Map json) { + _rejectUnknownKeys(json, _filterKeys, 'push filter'); + return BuzzPushFilter( + kinds: _intList(json, 'kinds'), + authors: _optionalStringList(json, 'authors'), + pTags: _optionalStringList(json, '#p'), + hTags: _optionalStringList(json, '#h'), + eTags: _optionalStringList(json, '#e'), + ); + } + + void _validate() { + if (kinds.isEmpty || + kinds.any((kind) => !buzzPushEligibleKinds.contains(kind))) { + throw const FormatException('Push filter contains invalid kinds.'); + } + for (final value in [...?authors, ...?pTags, ...?eTags]) { + if (!_exactHexPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains a non-exact hex value.', + ); + } + } + for (final value in hTags ?? const []) { + if (!_channelIdPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains an invalid channel ID.', + ); + } + } + } +} + +class BuzzPushSuppression { + final int pTagsMax; + + const BuzzPushSuppression({required this.pTagsMax}) : assert(pTagsMax > 0); + + Map toJson() => {'p_tags_max': pTagsMax}; + + factory BuzzPushSuppression.fromJson(Map json) { + _rejectUnknownKeys(json, const {'p_tags_max'}, 'push suppression'); + final value = json['p_tags_max']; + if (value is! int || value <= 0) { + throw const FormatException('p_tags_max must be a positive integer.'); + } + return BuzzPushSuppression(pTagsMax: value); + } +} + +class BuzzPushSubscription { + final BuzzPushFilter filter; + final String notificationClass; + final List ignore; + final BuzzPushSuppression? suppress; + + BuzzPushSubscription({ + required this.filter, + required this.notificationClass, + Iterable ignore = const [], + this.suppress, + }) : ignore = List.unmodifiable(ignore) { + if (!_supportedNotificationClasses.contains(notificationClass)) { + throw const FormatException('Unsupported push notification class.'); + } + if (filter.authors == null && + filter.pTags == null && + filter.hTags == null) { + throw const FormatException('Push subscription filter is not narrowed.'); + } + if (this.ignore.length > buzzPushMaxIgnoreFilters) { + throw const FormatException( + 'Push subscription has too many ignore filters.', + ); + } + } + + Map toJson() => { + 'filter': filter.toJson(), + 'class': notificationClass, + if (ignore.isNotEmpty) + 'ignore': [for (final filter in ignore) filter.toJson()], + if (suppress != null) 'suppress': suppress!.toJson(), + }; + + factory BuzzPushSubscription.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'filter', + 'class', + 'ignore', + 'suppress', + }, 'push subscription'); + final filter = json['filter']; + final notificationClass = json['class']; + final ignore = json['ignore']; + final suppress = json['suppress']; + if (filter is! Map || notificationClass is! String) { + throw const FormatException('Malformed push subscription.'); + } + if (ignore != null && ignore is! List) { + throw const FormatException('Push subscription ignore must be a list.'); + } + if (suppress != null && suppress is! Map) { + throw const FormatException( + 'Push subscription suppress must be an object.', + ); + } + return BuzzPushSubscription( + filter: BuzzPushFilter.fromJson(Map.from(filter)), + notificationClass: notificationClass, + ignore: [ + for (final raw in ignore as List? ?? const []) + if (raw is Map) + BuzzPushFilter.fromJson(Map.from(raw)) + else + throw const FormatException('Malformed push ignore filter.'), + ], + suppress: suppress == null + ? null + : BuzzPushSuppression.fromJson(Map.from(suppress)), + ); + } +} + +/// Desired and relay-accepted lease subscriptions are intentionally separate. +/// +/// Workstream A has no lease publisher, so snapshots use [desired] authority. +/// A later publisher must persist an observed [accepted] lease before changing +/// authority. Keeping both sets makes relay rejection, clamping, or expiry +/// detectable instead of assuming the desired lease was accepted unchanged. +class BuzzPushLeaseSubscriptionState { + final BuzzPushLeaseSubscriptionAuthority authority; + final List desired; + final List? accepted; + + const BuzzPushLeaseSubscriptionState.desired({ + this.desired = const [], + this.accepted, + }) : authority = BuzzPushLeaseSubscriptionAuthority.desired; + + BuzzPushLeaseSubscriptionState.accepted({ + required Iterable desired, + required Iterable acceptedSubscriptions, + }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, + desired = List.unmodifiable(desired), + accepted = List.unmodifiable(acceptedSubscriptions); + + List get authoritative => switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => desired, + BuzzPushLeaseSubscriptionAuthority.accepted => accepted!, + }; + + BuzzPushLeaseSubscriptionState withDesired( + Iterable subscriptions, + ) { + final updated = List.unmodifiable(subscriptions); + return switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => + BuzzPushLeaseSubscriptionState.desired( + desired: updated, + accepted: accepted, + ), + BuzzPushLeaseSubscriptionAuthority.accepted => + BuzzPushLeaseSubscriptionState.accepted( + desired: updated, + acceptedSubscriptions: accepted!, + ), + }; + } + + Map toJson() => { + 'authority': authority.name, + 'desired': [for (final subscription in desired) subscription.toJson()], + if (accepted != null) + 'accepted': [for (final subscription in accepted!) subscription.toJson()], + }; + + factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'authority', + 'desired', + 'accepted', + }, 'push subscription state'); + final authority = json['authority']; + final desired = _subscriptionList( + json['desired'], + 'desired', + allowEmpty: authority == 'desired', + ); + final acceptedRaw = json['accepted']; + final accepted = acceptedRaw == null + ? null + : _subscriptionList(acceptedRaw, 'accepted'); + return switch (authority) { + 'desired' => BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + ), + 'accepted' when accepted != null => + BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: accepted, + ), + 'accepted' => throw const FormatException( + 'Accepted push authority requires accepted subscriptions.', + ), + _ => throw const FormatException('Unknown push subscription authority.'), + }; + } +} + +List buildDesiredBuzzPushSubscriptions({ + required String myPubkey, + Iterable channelIds = const [], + Iterable mutedChannelIds = const [], +}) { + final normalizedPubkey = myPubkey.toLowerCase(); + if (!_exactHexPattern.hasMatch(normalizedPubkey)) { + throw const FormatException('Push subscription pubkey must be exact hex.'); + } + + final normalizedChannelIds = channelIds.map(_normalizeChannelID).toSet(); + final normalizedMuted = mutedChannelIds.map(_normalizeChannelID).toSet(); + final activeMuted = + normalizedMuted.intersection(normalizedChannelIds).toList()..sort(); + final mutedIgnoreFilters = []; + for (final chunk in _chunks(activeMuted, buzzPushChannelChunkSize)) { + mutedIgnoreFilters.add( + BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + ); + } + if (mutedIgnoreFilters.length + 1 > buzzPushMaxIgnoreFilters) { + throw const FormatException('Too many muted channels for a push lease.'); + } + + final selfAuthored = BuzzPushFilter( + kinds: buzzPushRenderableKinds, + authors: [normalizedPubkey], + ); + final ignores = [selfAuthored, ...mutedIgnoreFilters]; + const suppression = BuzzPushSuppression( + pTagsMax: buzzPushHellthreadParticipantLimit, + ); + final subscriptions = [ + BuzzPushSubscription( + filter: BuzzPushFilter( + kinds: buzzPushSelfDirectedKinds, + pTags: [normalizedPubkey], + ), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ]; + + final channels = normalizedChannelIds.difference(normalizedMuted).toList() + ..sort(); + for (final chunk in _chunks(channels, buzzPushChannelChunkSize)) { + subscriptions.add( + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ); + } + if (subscriptions.length > buzzPushMaxSubscriptions) { + throw const FormatException('Too many channels for a push lease.'); + } + return List.unmodifiable(subscriptions); +} + +String buzzPushSubscriptionsFingerprint( + List subscriptions, +) => jsonEncode([ + for (final subscription in subscriptions) subscription.toJson(), +]); + +String buzzPushSubscriptionStateFingerprint( + BuzzPushLeaseSubscriptionState state, +) => jsonEncode(state.toJson()); + +List> _chunks(List values, int size) => [ + for (var offset = 0; offset < values.length; offset += size) + values.sublist(offset, (offset + size).clamp(0, values.length)), +]; + +String _normalizeChannelID(String value) { + final normalized = value.toLowerCase(); + if (!_channelIdPattern.hasMatch(normalized)) { + throw const FormatException('Push subscription channel ID is invalid.'); + } + return normalized; +} + +List? _optionalList(Iterable? values) => + values == null ? null : List.unmodifiable(values); + +List _intList(Map json, String key) { + final raw = json[key]; + if (raw is! List || raw.any((value) => value is! int)) { + throw FormatException('$key must be an integer list.'); + } + return raw.cast(); +} + +List? _optionalStringList(Map json, String key) { + if (!json.containsKey(key)) return null; + final raw = json[key]; + if (raw is! List || raw.isEmpty || raw.any((value) => value is! String)) { + throw FormatException('$key must be a non-empty string list.'); + } + return raw.cast(); +} + +List _subscriptionList( + Object? raw, + String label, { + bool allowEmpty = false, +}) { + if (raw is! List || (!allowEmpty && raw.isEmpty)) { + throw FormatException('$label subscriptions must be a non-empty list.'); + } + return [ + for (final item in raw) + if (item is Map) + BuzzPushSubscription.fromJson(Map.from(item)) + else + throw FormatException('Malformed $label subscription.'), + ]; +} + +void _rejectUnknownKeys( + Map json, + Set allowed, + String label, +) { + final unknown = json.keys.where((key) => !allowed.contains(key)); + if (unknown.isNotEmpty) { + throw FormatException('$label contains unknown field ${unknown.first}.'); + } +} diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart new file mode 100644 index 0000000000..5a3e835f02 --- /dev/null +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../features/channels/channel.dart'; +import '../../features/channels/channel_mutes/channel_mutes_provider.dart'; +import '../../features/channels/channels_provider.dart'; +import '../community/community.dart'; +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import 'push_subscription.dart'; + +/// Keeps the persisted desired lease and the App Group snapshot aligned with +/// the active identity, joined channels, and mute state. This is desired client +/// policy only. Relay-accepted authority is introduced by lease publication. +final pushSubscriptionSyncProvider = Provider((ref) { + final active = ref.watch(activeCommunityProvider).value; + final channels = ref.watch(channelsProvider).value; + final mutes = ref.watch(channelMutesProvider); + if (active == null || channels == null || !mutes.isReady) return; + + final subscriptions = desiredBuzzPushSubscriptions( + community: active, + channels: channels, + mutedChannelIds: [ + for (final entry in mutes.store.channels.entries) + if (entry.value.muted) entry.key, + ], + ); + if (subscriptions == null) return; + unawaited( + ref + .read(communityListProvider.notifier) + .updateDesiredPushSubscriptions(active.id, subscriptions), + ); +}); + +List? desiredBuzzPushSubscriptions({ + required Community community, + required Iterable channels, + required Iterable mutedChannelIds, +}) { + final pubkey = community.pubkey ?? pubkeyFromNsec(community.nsec); + if (pubkey == null || pubkey.isEmpty) return null; + return buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: [ + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel.id, + ], + mutedChannelIds: mutedChannelIds, + ); +} diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index dc0835e622..40ba6903d7 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; /// In-memory fake that extends Fake to satisfy all FlutterSecureStorage /// interface methods, but implements the core read/write/delete with real @@ -117,6 +118,36 @@ void main() { expect(loaded.first.name, 'Test'); expect(loaded.first.relayUrl, 'https://relay.example.com'); expect(loaded.first.pubkey, 'abc123'); + expect( + loaded.first.pushSubscriptionState.authority, + BuzzPushLeaseSubscriptionAuthority.desired, + ); + }); + + test('round-trips desired push subscription state', () async { + final pubkey = 'a' * 64; + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: const ['123e4567-e89b-42d3-a456-426614174000'], + ); + final community = + Community.create( + name: 'Push', + relayUrl: 'https://relay.example.com', + pubkey: pubkey, + ).copyWith( + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ), + ); + + await storage.save(community); + final loaded = (await storage.loadAll()).single; + + expect( + loaded.pushSubscriptionState.toJson(), + community.pushSubscriptionState.toJson(), + ); }); test('save updates existing community with same id', () async { diff --git a/mobile/test/shared/push/push_models_test.dart b/mobile/test/shared/push/push_models_test.dart index ed0ee46d66..3643c66973 100644 --- a/mobile/test/shared/push/push_models_test.dart +++ b/mobile/test/shared/push/push_models_test.dart @@ -1,8 +1,32 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/push/push_models.dart'; import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; void main() { + test('push community snapshot carries explicit subscription authority', () { + final subscription = buildDesiredBuzzPushSubscriptions( + myPubkey: 'a' * 64, + ).single; + final snapshot = BuzzPushCommunitySnapshot( + id: 'community', + name: 'Team', + relayUrl: 'https://relay.example.com', + pubkey: 'a' * 64, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ), + ); + + final decoded = BuzzPushCommunitySnapshot.fromJson(snapshot.toJson()); + + expect(decoded.toJson(), snapshot.toJson()); + expect( + decoded.pushSubscriptionState.authority, + BuzzPushLeaseSubscriptionAuthority.desired, + ); + }); + test('resolves newest user-visible event into notification content', () { final mine = 'a' * 64; final alice = 'b' * 64; diff --git a/mobile/test/shared/push/push_subscription_provider_test.dart b/mobile/test/shared/push/push_subscription_provider_test.dart new file mode 100644 index 0000000000..27f0494d6f --- /dev/null +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -0,0 +1,66 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/push/push_subscription_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + const activeID = '123e4567-e89b-42d3-a456-426614174000'; + const archivedID = '123e4567-e89b-42d3-a456-426614174001'; + const nonMemberID = '123e4567-e89b-42d3-a456-426614174002'; + + test( + 'derives desired subscriptions from nsec, membership, and mute state', + () { + final nsec = nostr.Keys.generate().nsec; + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + nsec: nsec, + ), + channels: [ + channel(activeID), + channel(archivedID, archived: true), + channel(nonMemberID, isMember: false), + ], + mutedChannelIds: const [activeID, archivedID], + ); + + expect(subscriptions, isNotNull); + expect(subscriptions, hasLength(1)); + expect(subscriptions!.single.filter.pTags, hasLength(1)); + expect(subscriptions.single.ignore, hasLength(2)); + expect(subscriptions.single.ignore.first.kinds, buzzPushRenderableKinds); + expect(subscriptions.single.ignore.last.hTags, [activeID]); + }, + ); + + test('returns no desired subscriptions without a signing identity', () { + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + ), + channels: [channel(activeID)], + mutedChannelIds: const [], + ); + + expect(subscriptions, isNull); + }); +} + +Channel channel(String id, {bool isMember = true, bool archived = false}) => + Channel( + id: id, + name: id, + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'author', + createdAt: DateTime(2026), + memberCount: 1, + isMember: isMember, + archivedAt: archived ? DateTime(2026) : null, + ); diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart new file mode 100644 index 0000000000..efb46ba0d9 --- /dev/null +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final me = 'a' * 64; + const channelA = '123e4567-e89b-42d3-a456-426614174000'; + const channelB = '123e4567-e89b-42d3-a456-426614174001'; + + test( + 'desired subscription state round-trips with an accepted-state seam', + () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [channelB, channelA], + ); + final state = BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ); + + final decoded = BuzzPushLeaseSubscriptionState.fromJson( + jsonDecode(jsonEncode(state.toJson())) as Map, + ); + + expect(decoded.authority, BuzzPushLeaseSubscriptionAuthority.desired); + expect(decoded.accepted, isNull); + expect(decoded.toJson(), state.toJson()); + expect(decoded.authoritative, hasLength(2)); + expect(decoded.authoritative.last.filter.hTags, [channelA, channelB]); + }, + ); + + test('accepted authority requires observed accepted subscriptions', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + + expect( + () => BuzzPushLeaseSubscriptionState.fromJson({ + 'authority': 'accepted', + 'desired': [subscription.toJson()], + }), + throwsFormatException, + ); + }); + + test('builds aligned self and unmuted channel subscriptions', () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me.toUpperCase(), + channelIds: [channelB, channelA], + mutedChannelIds: [channelB, '123e4567-e89b-42d3-a456-426614174099'], + ); + + expect(subscriptions, hasLength(2)); + expect(subscriptions.first.filter.kinds, buzzPushSelfDirectedKinds); + expect(subscriptions.first.filter.kinds, isNot(contains(7))); + expect(subscriptions.first.filter.pTags, [me]); + expect(subscriptions.last.filter.kinds, buzzPushChannelKinds); + expect(subscriptions.last.filter.hTags, [channelA]); + expect(subscriptions.first.ignore, hasLength(2)); + expect(subscriptions.first.ignore.first.kinds, buzzPushRenderableKinds); + expect(subscriptions.first.ignore.last.hTags, [channelB]); + expect( + subscriptions.first.suppress?.pTagsMax, + buzzPushHellthreadParticipantLimit, + ); + }); + + test('chunks channel subscriptions to relay limits deterministically', () { + final channels = [ + for (var i = 0; i < 51; i++) + '00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}', + ]..shuffle(); + + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: channels, + ); + + expect(subscriptions, hasLength(3)); + expect(subscriptions[1].filter.hTags, hasLength(50)); + expect(subscriptions[2].filter.hTags, hasLength(1)); + expect( + subscriptions[1].filter.hTags, + orderedEquals([...subscriptions[1].filter.hTags!]..sort()), + ); + }); + + test('rejects malformed and unsupported subscription fields', () { + final valid = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + ).single.toJson(); + + expect( + () => buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: const ['not-a-channel'], + ), + throwsFormatException, + ); + + expect( + () => BuzzPushSubscription.fromJson({...valid, 'unexpected': true}), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [40002], + '#p': [me], + }, + 'class': 'default', + }), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [9], + '#p': ['not-a-pubkey'], + }, + 'class': 'default', + }), + throwsFormatException, + ); + }); +}