diff --git a/docs/native-notification-enrichment.md b/docs/native-notification-enrichment.md new file mode 100644 index 0000000000..f4e6c71d56 --- /dev/null +++ b/docs/native-notification-enrichment.md @@ -0,0 +1,75 @@ +# Sender information in iOS notifications + +Buzz must show a sender's name and avatar even when the main app has never +loaded that sender's profile. The notification service extension fetches and +caches the information it needs. The main app supplies signed-in accounts, +relay configuration and credentials in the shared Keychain. It does not export +profiles, channel details or avatar images for notifications. + +## Display behavior + +The extension uses one freshness threshold of one hour for profiles, channel +details, and avatar refresh checks. The period starts at the last successful +check. Reading a record does not extend it. When a record is older, the +extension tries to refresh it and can use the previous record if the refresh +fails. This setting does not extend authorization. Image requests also follow +HTTP caching rules, which can avoid downloading unchanged image data again. + +After it resolves the message, the extension allows up to 1.5 seconds for the +sender and channel details. It then delivers the best available notification. +If no sender name or avatar is available, it omits those values where iOS +supports this. It must not use a public key as the displayed name. + +When a sender changes their avatar, the previous image remains usable until +the replacement arrives. When a verified profile removes the avatar, the old +image is no longer shown. A late response must not restore a removed avatar. + +The extension supports the same avatar sources as the app: Buzz uploads, +external HTTP(S) images and supported inline images. It uses existing server +thumbnails for eligible Buzz uploads. A separate resized-image cache is not +required. Animated avatars use the sender-selected still image. Inline SVGs +are rendered natively. Image decoding has size limits; SVG decoding also limits +nesting and repeated references. Embedded fonts, external SVG resources and +recursive references are rejected. An image that cannot be decoded follows the +same stale-image or missing-image behavior as a failed download. +An empty profile query is not a verified removal and does not clear stale data. + +## Fetching and storage + +The extension uses native networking and its own persistent storage. Image +responses use URLSession and URLCache; verified profiles and channel details +have a separate bounded store. Each account and relay has separate cache data. +Credentials are not stored in these caches. Profile and channel requests run +independently, so a large or slow channel roster cannot block a sender profile +or avatar that is available before the delivery deadline. +When only channel metadata or membership refreshes successfully, the extension +keeps that record and removes its old counterpart in the same write. It does +not derive a group identity or recipient count from a mixed pair. If neither +request succeeds, the previous pair remains available. + +Delivery happens only once. Ordinary requests can continue after delivery if +iOS keeps the process alive. A late profile response can start an avatar fetch, +and completed results can be saved for the next notification. These requests +use normal network timeouts. There are no background sessions, and successful +notification delivery does not depend on work continuing after delivery. + +A cache miss is normal. Interrupted writes must leave complete old or new +records, not partial records. Invalid cache entries are reported and treated +as misses. The extension must work after iOS removes cache files or starts a +new process. + +Removing an account immediately prevents access to its cached information. +The extension deletes the account's cache files when it next runs. Results +from requests started before removal cannot become usable in a new sign-in. + +## Validation + +Tests cover a sender never loaded by the app, cache hits and misses, stale +records, avatar replacement and removal, account removal, late responses and +exactly-once delivery. Storage tests cover concurrent access and interruption. +Device validation checks actual notification appearance, authenticated image +cache reuse, process restarts and notification bursts. In particular, accepting +nil name and image values in an API does not by itself prove how iOS renders +those values. + +Persistent image caching for the main app is separate from this change. diff --git a/mobile/ios/BuzzPushKit/Package.swift b/mobile/ios/BuzzPushKit/Package.swift index 5af3d0a6c4..8426b076e8 100644 --- a/mobile/ios/BuzzPushKit/Package.swift +++ b/mobile/ios/BuzzPushKit/Package.swift @@ -8,12 +8,13 @@ let package = Package( .library(name: "BuzzPushKit", targets: ["BuzzPushKit"]) ], dependencies: [ - .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.21.1") + .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.21.1"), + .package(url: "https://github.com/swhitty/SwiftDraw.git", exact: "0.29.0") ], targets: [ .target( name: "BuzzPushKit", - dependencies: [.product(name: "P256K", package: "swift-secp256k1")] + dependencies: [.product(name: "P256K", package: "swift-secp256k1"), .product(name: "SwiftDraw", package: "SwiftDraw")] ), .testTarget( name: "BuzzPushKitTests", diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift index 8ae0a5f81e..8f54c752ea 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift @@ -2,7 +2,7 @@ import Foundation /// Verified local values used to specialize an ordinary notification as communication. public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { - public let senderDisplayName: String + public let senderDisplayName: String? public let senderIdentifier: String public let senderAvatarPNG: Data? public let messageBody: String @@ -12,7 +12,7 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { public let recipientCount: Int public init( - senderDisplayName: String, + senderDisplayName: String?, senderIdentifier: String, senderAvatarPNG: Data?, messageBody: String, @@ -39,7 +39,7 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { recipientCount > 0 else { return nil } self.init( - senderDisplayName: resolution.title, + senderDisplayName: resolution.title.isEmpty ? nil : resolution.title, senderIdentifier: BuzzPushPresentationIdentity.sender( communityID: target.communityID, pubkey: senderPubkey diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationAvatarLoader.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationAvatarLoader.swift new file mode 100644 index 0000000000..1fa6436374 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationAvatarLoader.swift @@ -0,0 +1,228 @@ +import CryptoKit +import Foundation +import ImageIO +import P256K +import UniformTypeIdentifiers + +/// Fetches an avatar with the extension's private HTTP cache and bounded native decoding. +public final class BuzzNotificationAvatarLoader { + private let cacheLock = NSLock() + private var httpCaches: [String: URLCache] = [:] + private let directory: URL + private let privateKey: (String) -> String? + private let isCurrent: (BuzzNotificationCacheScope) -> Bool + private static let maximumDownloadBytes = 2 * 1024 * 1024 + + public init( + directory: URL, privateKey: @escaping (String) -> String?, + isCurrent: @escaping (BuzzNotificationCacheScope) -> Bool + ) { + self.directory = directory + self.privateKey = privateKey + self.isCurrent = isCurrent + } + + /// Returns a bounded PNG suitable for INImage. HTTP caching retains the original response. + public func load( + source: String, scope: BuzzNotificationCacheScope, + completion: @escaping (Result) -> Void + ) { + guard isCurrent(scope) else { + completion(.failure(BuzzNotificationCacheError.revoked)) + return + } + if source.hasPrefix("data:") { + do { completion(.success(try Self.decode(Self.inlineData(source)))) } catch { + completion(.failure(error)) + } + return + } + guard let original = Self.posterURL(source) + else { + completion(.failure(BuzzNotificationCacheError.invalidRecord)) + return + } + let thumbnail = Self.thumbnailURL(original, scope: scope) + fetch(thumbnail ?? original, scope: scope) { result in + if thumbnail != nil, case .failure(BuzzNotificationDownload.Failure.status(404)) = result { + // Older or externally populated media may not have a generated thumbnail. + self.fetch(original, scope: scope, completion: completion) + } else { + completion(result) + } + } + } + + private func fetch( + _ url: URL, scope: BuzzNotificationCacheScope, + completion: @escaping (Result) -> Void + ) { + guard isCurrent(scope) else { + completion(.failure(BuzzNotificationCacheError.revoked)) + return + } + var request = URLRequest(url: url) + if Self.isRelayMedia(url, scope: scope) { + guard let key = privateKey(scope.communityID) else { + completion(.failure(BuzzNotificationCacheError.revoked)) + return + } + do { + request.setValue( + try Self.authorization(url: url, key: key, account: scope.account), + forHTTPHeaderField: "Authorization") + } catch { + completion(.failure(error)) + return + } + } + let configuration = URLSessionConfiguration.default + configuration.httpCookieStorage = nil + configuration.urlCredentialStorage = nil + configuration.urlCache = httpCache(scope) + BuzzNotificationDownload.start( + request: request, configuration: configuration, maximumBytes: Self.maximumDownloadBytes + ) { result in + guard self.isCurrent(scope) else { + completion(.failure(BuzzNotificationCacheError.revoked)) + return + } + completion(result.flatMap { bytes in Result { try Self.decode(bytes) } }) + } + } + + private func httpCache(_ scope: BuzzNotificationCacheScope) -> URLCache { + cacheLock.lock() + defer { cacheLock.unlock() } + if let cache = httpCaches[scope.directoryName] { return cache } + let cache = URLCache( + memoryCapacity: 512 * 1024, diskCapacity: 8 * 1024 * 1024, + directory: directory.appendingPathComponent(scope.directoryName).appendingPathComponent( + "http")) + httpCaches[scope.directoryName] = cache + return cache + } + + static func normalizedSource(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("data:image/svg+xml"), trimmed.utf8.count <= 256 * 1024, + let comma = trimmed.firstIndex(of: ","), + [ + "data:image/svg+xml", "data:image/svg+xml;base64", "data:image/svg+xml;utf8", + "data:image/svg+xml;charset=utf-8", + ].contains(String(trimmed[.. URL? { + guard let normalized = BuzzPushPresentationCacheStore.normalizedAvatarURL(source), + var components = URLComponents(string: normalized) + else { return nil } + components.fragment = nil + return components.url + } + + static func thumbnailURL(_ url: URL, scope: BuzzNotificationCacheScope) -> URL? { + guard isRelayMedia(url, scope: scope) else { return nil } + let parts = url.lastPathComponent.split(separator: ".") + guard let hash = parts.first, hash.count == 64, + VerifiedNostrEvent.hexBytes(String(hash))?.count == 32, + parts.count <= 2 + else { return nil } + return url.deletingLastPathComponent().appendingPathComponent("\(hash).thumb.jpg") + } + + private static func isRelayMedia(_ url: URL, scope: BuzzNotificationCacheScope) -> Bool { + guard let origin = URL(string: scope.relayOrigin) else { return false } + func effectivePort(_ value: URL) -> Int? { + value.port ?? (value.scheme?.lowercased() == "https" ? 443 : 80) + } + return url.scheme?.lowercased() == origin.scheme?.lowercased() + && url.host?.lowercased() == origin.host?.lowercased() + && effectivePort(url) == effectivePort(origin) + && url.path.hasPrefix("/media/") + } + + private static func inlineData(_ source: String) throws -> Data { + guard normalizedSource(source) != nil, let comma = source.firstIndex(of: ",") else { + throw BuzzNotificationCacheError.invalidRecord + } + let header = source[.. Data { + if let text = String(data: data.prefix(256), encoding: .utf8), + text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("<") + { + return try BuzzNotificationSVG.decode(data) + } + guard !data.isEmpty, data.count <= maximumDownloadBytes, + let imageSource = CGImageSourceCreateWithData( + data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + width > 0, height > 0, width <= 16384, height <= 16384, + Int64(width) * Int64(height) <= 64_000_000, + let image = CGImageSourceCreateThumbnailAtIndex( + imageSource, 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 320, + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary) + else { throw BuzzNotificationCacheError.invalidRecord } + let output = NSMutableData() + guard + let destination = CGImageDestinationCreateWithData( + output, UTType.png.identifier as CFString, 1, nil) + else { throw BuzzNotificationCacheError.invalidRecord } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination), + output.length <= BuzzNotificationCache.maximumAvatarBytes + else { throw BuzzNotificationCacheError.oversizedRecord } + return output as Data + } + + static func authorization(url: URL, key: String, account: String) throws -> String { + guard let bytes = VerifiedNostrEvent.hexBytes(key), bytes.count == 32 else { + throw NostrHTTPAuthError.invalidHex + } + let signingKey = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes) + let pubkey = VerifiedNostrEvent.hex(signingKey.xonly.bytes) + guard pubkey == account else { throw BuzzNotificationCacheError.revoked } + let timestamp = Int(Date().timeIntervalSince1970) + // URLComponents preserves the brackets required around IPv6 authorities. + let host = URLComponents(url: url, resolvingAgainstBaseURL: false)?.host ?? "" + let authority = host + (url.port.map { ":\($0)" } ?? "") + let tags = [["t", "get"], ["expiration", String(timestamp + 60)], ["server", authority]] + let content = "Get buzz-media" + let serialization = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, createdAt: timestamp, kind: 24242, tags: tags, content: content) + var digest = Array(SHA256.hash(data: serialization)) + let id = VerifiedNostrEvent.hex(digest) + let signature = try signingKey.signature(message: &digest, auxiliaryRand: nil) + let event = VerifiedNostrEvent( + id: id, pubkey: pubkey, createdAt: timestamp, + kind: 24242, tags: tags, content: content, + sig: VerifiedNostrEvent.hex(signature.dataRepresentation)) + return "Nostr " + (try JSONEncoder().encode(event)).base64EncodedString() + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationCache.swift new file mode 100644 index 0000000000..cffbb79aa7 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationCache.swift @@ -0,0 +1,357 @@ +import CryptoKit +import Foundation +import P256K + +/// Account and relay identity for one local sign-in's notification cache. +public struct BuzzNotificationCacheScope: Codable, Equatable, Sendable { + public let communityID: String + public let relayOrigin: String + public let account: String + public let sessionID: String + + public init?(community: PushLeaseCommunity) { + guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(community.relayUrl), + let account = community.pubkey, !account.isEmpty, + community.notificationSessionID?.isEmpty != true + else { return nil } + communityID = community.id + relayOrigin = origin + self.account = account.lowercased() + // Before the first launch after upgrade, the account snapshot has no session + // field. Its scope is still isolated by account, relay and community. The new + // app replaces this marker with a UUID on its first sync and every re-add. + sessionID = community.notificationSessionID ?? "legacy-before-first-sync-v1" + } + + /// Confirms the loaded credential belongs to this scope's account. + public func matchesAccountKey(_ privateKey: String?) -> Bool { + guard let privateKey, let bytes = VerifiedNostrEvent.hexBytes(privateKey), bytes.count == 32, + let key = try? P256K.Schnorr.PrivateKey(dataRepresentation: bytes) + else { return false } + return VerifiedNostrEvent.hex(key.xonly.bytes) == account + } + + /// A filename-safe identity that contains no credentials. + public var directoryName: String { + let fields = [communityID, relayOrigin, account, sessionID] + let bytes = fields.flatMap { Array("\($0.utf8.count):\($0)".utf8) } + return VerifiedNostrEvent.hex(SHA256.hash(data: bytes)) + } +} + +/// Verified metadata plus the time it was last checked with the relay. +public struct BuzzNotificationMetadata: Codable, Equatable, Sendable { + public let event: VerifiedNostrEvent + public let checkedAt: Date + + /// One shared freshness threshold for profile and channel presentation metadata. + public static let freshness: TimeInterval = 60 * 60 + + public func isFresh(at date: Date) -> Bool { + let age = date.timeIntervalSince(checkedAt) + return age >= 0 && age < Self.freshness + } +} + +/// Last successfully decoded image for a sender, retained during replacement fetches. +public struct BuzzNotificationAvatar: Codable, Equatable, Sendable { + public let pubkey: String + public let source: String + public let data: Data + public let savedAt: Date +} + +/// Failures callers must report; revoked accounts must never use stale cache results. +public enum BuzzNotificationCacheError: Error { + case revoked, invalidRecord, oversizedRecord, corruptStore +} + +/// Extension-owned metadata storage. Each operation reads current disk state under +/// a process lock and publishes one complete snapshot with an atomic replacement. +/// Image HTTP response storage remains URLCache's responsibility. +public final class BuzzNotificationCache: @unchecked Sendable { + public static let maximumBytes = 8 * 1024 * 1024 + public static let maximumAvatarBytes = 256 * 1024 + private static let processLock = NSLock() + private let root: URL + private let now: () -> Date + private let isCurrent: (BuzzNotificationCacheScope) -> Bool + + private struct State: Codable { + var version = 1 + var metadata: [BuzzNotificationMetadata] = [] + var avatars: [BuzzNotificationAvatar] = [] + var avatarRequests: [String: String]? + } + + public init( + directory: URL, + now: @escaping () -> Date = Date.init, + isCurrent: @escaping (BuzzNotificationCacheScope) -> Bool + ) { + root = directory + self.now = now + self.isCurrent = isCurrent + } + + /// Returns a complete cached record, including stale records for presentation fallback. + public func metadata( + scope: BuzzNotificationCacheScope, kind: Int, author: String, identifier: String? = nil + ) throws -> BuzzNotificationMetadata? { + try access(scope) { state in + state.metadata.first { + $0.event.kind == kind && $0.event.pubkey.lowercased() == author.lowercased() + && Self.identifier($0.event) == identifier + } + } + } + + /// Saves verified replaceable metadata without allowing an older response to win. + public func save(event: VerifiedNostrEvent, scope: BuzzNotificationCacheScope) throws { + try save(events: [event], scope: scope) + } + + /// Commits records returned together in a single atomic snapshot. + func save(events: [VerifiedNostrEvent], scope: BuzzNotificationCacheScope) throws { + try save(events: events, scope: scope, channelRefresh: false) + } + + /// Keeps a partial channel response without combining it with an old counterpart. + func saveChannel( + channel: VerifiedNostrEvent?, membership: VerifiedNostrEvent?, scope: BuzzNotificationCacheScope + ) throws { + try save(events: [channel, membership].compactMap { $0 }, scope: scope, channelRefresh: true) + } + + private func save( + events: [VerifiedNostrEvent], scope: BuzzNotificationCacheScope, channelRefresh: Bool + ) throws { + guard !events.isEmpty else { return } + for event in events { + guard [0, 39_000, 39_002].contains(event.kind), event.hasValidIDAndSignature(), + event.content.utf8.count <= 256 * 1024 + else { throw BuzzNotificationCacheError.invalidRecord } + if event.kind == 0 { + guard let data = event.content.data(using: .utf8), + (try? JSONSerialization.jsonObject(with: data)) is [String: Any] + else { throw BuzzNotificationCacheError.invalidRecord } + } + } + try access(scope, write: true) { state in + if channelRefresh { + // Reject the whole refresh if any returned record predates its cached + // version. Accepting only the other record would mix refreshes again. + for event in events { + let existing = state.metadata.first { + $0.event.kind == event.kind && $0.event.pubkey == event.pubkey + && Self.identifier($0.event) == Self.identifier(event) + }?.event + guard BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: existing?.createdAt, existingID: existing?.id, + candidateCreatedAt: event.createdAt, candidateID: event.id + ) else { return } + } + } + for event in events { + let index = state.metadata.firstIndex { + $0.event.kind == event.kind && $0.event.pubkey == event.pubkey + && Self.identifier($0.event) == Self.identifier(event) + } + let existing = index.map { state.metadata[$0].event } + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: existing?.createdAt, existingID: existing?.id, + candidateCreatedAt: event.createdAt, candidateID: event.id + ) + else { continue } + let record = BuzzNotificationMetadata(event: event, checkedAt: now()) + if let index { state.metadata[index] = record } else { state.metadata.append(record) } + if channelRefresh, events.count == 1, [39000, 39002].contains(event.kind) { + let counterpartKind = event.kind == 39000 ? 39002 : 39000 + state.metadata.removeAll { + $0.event.kind == counterpartKind && $0.event.pubkey == event.pubkey + && Self.identifier($0.event) == Self.identifier(event) + } + } + if event.kind == 0, Self.removesPicture(event) { + state.avatars.removeAll { $0.pubkey == event.pubkey } + } + } + } + } + + struct Presentation { + var profile: BuzzNotificationMetadata? + var avatar: BuzzNotificationAvatar? + var channel: BuzzNotificationMetadata? + var membership: BuzzNotificationMetadata? + } + + /// Reads related fields from one snapshot, so a concurrent update cannot mix generations. + func presentation( + scope: BuzzNotificationCacheScope, sender: String, + channelID: String?, authority: String? + ) throws -> Presentation { + try access(scope) { state in + let records = state.metadata + return Presentation( + profile: records.first { $0.event.kind == 0 && $0.event.pubkey == sender }, + avatar: state.avatars.first { $0.pubkey == sender }, + channel: records.first { + channelID != nil && authority != nil && $0.event.kind == 39000 + && $0.event.pubkey == authority && Self.identifier($0.event) == channelID + }, + membership: records.first { + channelID != nil && authority != nil && $0.event.kind == 39002 + && $0.event.pubkey == authority && Self.identifier($0.event) == channelID + }) + } + } + + /// Returns the previous image even when the current profile references a replacement. + public func avatar(scope: BuzzNotificationCacheScope, pubkey: String) throws + -> BuzzNotificationAvatar? + { + try access(scope) { state in state.avatars.first { $0.pubkey == pubkey } } + } + + /// Registers the newest image request under the same lock used by all cache writers. + public func beginAvatarRequest( + profileID: String, pubkey: String, scope: BuzzNotificationCacheScope + ) throws -> String? { + try access(scope, write: true) { state in + guard + state.metadata.contains(where: { + $0.event.kind == 0 && $0.event.pubkey == pubkey && $0.event.id == profileID + }) + else { return nil } + let requestID = UUID().uuidString + if state.avatarRequests == nil { state.avatarRequests = [:] } + state.avatarRequests?[pubkey] = requestID + return requestID + } + } + + /// Publishes only the latest request while the current profile still identifies its source. + public func saveAvatar( + _ data: Data, source: String, profileID: String, requestID: String, pubkey: String, + scope: BuzzNotificationCacheScope + ) throws { + guard !data.isEmpty, data.count <= Self.maximumAvatarBytes else { + throw BuzzNotificationCacheError.oversizedRecord + } + try access(scope, write: true) { state in + guard + let profile = state.metadata.first(where: { + $0.event.kind == 0 && $0.event.pubkey == pubkey + }), profile.event.id == profileID, Self.picture(profile.event) == source, + state.avatarRequests?[pubkey] == requestID + else { return } + state.avatars.removeAll { $0.pubkey == pubkey } + state.avatars.append( + BuzzNotificationAvatar( + pubkey: pubkey, source: source, data: data, savedAt: now())) + } + } + + /// Deletes abandoned sign-ins on the extension's next execution. + public func removeRevokedScopes(keeping scopes: [BuzzNotificationCacheScope]) throws { + try locked { + let retained = Set(scopes.map(\.directoryName)) + for url in try FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: [.isDirectoryKey] + ) where url.lastPathComponent != "cache.lock" { + guard !retained.contains(url.lastPathComponent) else { continue } + try FileManager.default.removeItem(at: url) + } + } + } + + /// Normalized source from a verified profile. Missing or empty picture means removal. + static func picture(_ event: VerifiedNostrEvent) -> String? { + guard let data = event.content.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return BuzzNotificationAvatarLoader.normalizedSource(object["picture"] as? String) + } + + private static func removesPicture(_ event: VerifiedNostrEvent) -> Bool { + guard let data = event.content.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return false } + guard let value = object["picture"], !(value is NSNull) else { return true } + guard let value = value as? String else { return false } + return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private static func identifier(_ event: VerifiedNostrEvent) -> String? { + event.kind == 0 ? nil : event.tags.first { $0.count > 1 && $0[0] == "d" }?[1] + } + + private func access( + _ scope: BuzzNotificationCacheScope, write: Bool = false, + _ operation: (inout State) throws -> T + ) throws -> T { + try locked { + guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked } + let directory = root.appendingPathComponent(scope.directoryName, isDirectory: true) + let file = directory.appendingPathComponent("metadata.json") + var state = State() + if FileManager.default.fileExists(atPath: file.path) { + let attributes = try file.resourceValues(forKeys: [.fileSizeKey]) + guard let size = attributes.fileSize, size <= Self.maximumBytes else { + try FileManager.default.removeItem(at: file) + throw BuzzNotificationCacheError.corruptStore + } + do { + state = try JSONDecoder().decode(State.self, from: Data(contentsOf: file)) + } catch { + try FileManager.default.removeItem(at: file) + throw BuzzNotificationCacheError.corruptStore + } + guard state.version == 1 else { + try FileManager.default.removeItem(at: file) + throw BuzzNotificationCacheError.corruptStore + } + } + let result = try operation(&state) + guard isCurrent(scope) else { throw BuzzNotificationCacheError.revoked } + if write { + state.metadata = Array(state.metadata.sorted { $0.checkedAt > $1.checkedAt }.prefix(512)) + state.avatars = Array(state.avatars.sorted { $0.savedAt > $1.savedAt }.prefix(128)) + let profileAuthors = Set( + state.metadata.filter { $0.event.kind == 0 }.map { $0.event.pubkey }) + state.avatarRequests = state.avatarRequests?.filter { profileAuthors.contains($0.key) } + let encoder = JSONEncoder() + var data = try encoder.encode(state) + while data.count > Self.maximumBytes { + if !state.avatars.isEmpty { + state.avatars.removeLast() + } else if !state.metadata.isEmpty { + state.metadata.removeLast() + } else { + throw BuzzNotificationCacheError.oversizedRecord + } + data = try encoder.encode(state) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: file, options: .atomic) + } + return result + } + } + + private func locked(_ operation: () throws -> T) throws -> T { + Self.processLock.lock() + defer { Self.processLock.unlock() } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let descriptor = open(root.appendingPathComponent("cache.lock").path, O_CREAT | O_RDWR, 0o600) + guard descriptor >= 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } + defer { close(descriptor) } + guard flock(descriptor, LOCK_EX) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + defer { flock(descriptor, LOCK_UN) } + return try operation() + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationDownload.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationDownload.swift new file mode 100644 index 0000000000..8a8c7685ae --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationDownload.swift @@ -0,0 +1,96 @@ +import Foundation + +/// A bounded ordinary URLSession transfer. Completion does not require a background session. +final class BuzzNotificationDownload: NSObject, URLSessionDataDelegate, @unchecked Sendable { + enum Failure: Error { + case tooLarge + case status(Int) + case empty, invalidResponse + } + private let maximumBytes: Int + private let completion: (Result) -> Void + private var received = Data() + private var failure: Error? + private var session: URLSession? + + private init(maximumBytes: Int, completion: @escaping (Result) -> Void) { + self.maximumBytes = maximumBytes + self.completion = completion + } + + static func start( + request: URLRequest, configuration: URLSessionConfiguration, + maximumBytes: Int, completion: @escaping (Result) -> Void + ) { + let transfer = BuzzNotificationDownload(maximumBytes: maximumBytes, completion: completion) + let session = URLSession(configuration: configuration, delegate: transfer, delegateQueue: nil) + transfer.session = session + session.dataTask(with: request).resume() + } + + func urlSession( + _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + guard let response = response as? HTTPURLResponse else { + failure = Failure.invalidResponse + completionHandler(.cancel) + return + } + guard (200..<300).contains(response.statusCode) else { + failure = Failure.status(response.statusCode) + completionHandler(.cancel) + return + } + guard response.expectedContentLength <= Int64(maximumBytes) else { + failure = Failure.tooLarge + completionHandler(.cancel) + return + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard received.count + data.count <= maximumBytes else { + failure = Failure.tooLarge + dataTask.cancel() + return + } + received.append(data) + } + + func urlSession( + _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? + ) { + defer { + session.finishTasksAndInvalidate() + self.session = nil + } + if let error = failure ?? error { + completion(.failure(error)) + } else if received.isEmpty { + completion(.failure(Failure.empty)) + } else { + completion(.success(received)) + } + } + + func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + // Signed relay requests must not follow redirects or forward their proof. + guard task.originalRequest?.value(forHTTPHeaderField: "Authorization") == nil, + let url = request.url, ["https", "http"].contains(url.scheme?.lowercased() ?? ""), + url.user == nil, url.password == nil, + !(task.originalRequest?.url?.scheme == "https" && url.scheme != "https") + else { + completionHandler(nil) + return + } + var redirected = request + redirected.setValue(nil, forHTTPHeaderField: "Authorization") + completionHandler(redirected) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationEnrichmentOperation.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationEnrichmentOperation.swift new file mode 100644 index 0000000000..54863e3a2a --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationEnrichmentOperation.swift @@ -0,0 +1,220 @@ +import Foundation + +/// One notification's presentation deadline, independent of the lifetime of its fetches. +final class BuzzNotificationEnrichmentOperation { + enum RefreshResult { + case profile(VerifiedNostrEvent?) + case channel(VerifiedNostrEvent?, VerifiedNostrEvent?) + } + typealias Refresh = (Bool, Bool, @escaping (RefreshResult) -> Void) -> Void + private let event: VerifiedNostrEvent + private let community: PushLeaseCommunity + private let scope: BuzzNotificationCacheScope + private let cache: BuzzNotificationCache + typealias LoadAvatar = ( + String, BuzzNotificationCacheScope, @escaping (Result) -> Void + ) -> Void + typealias ScheduleDeadline = (DispatchQueue, @escaping () -> Void) -> Void + private let loadAvatar: LoadAvatar? + private let scheduleDeadline: ScheduleDeadline + private let now: () -> Date + private let isCurrent: () -> Bool + private let refresh: Refresh + private var completion: ((BuzzPushResolution?) -> Void)? + private let queue = DispatchQueue(label: "buzz.notification.enrichment") + private let workQueue = DispatchQueue( + label: "buzz.notification.enrichment.storage", qos: .utility) + private var pendingProfile = false + private var pendingChannel = false + private var avatarLoading = false + private var profile: BuzzNotificationMetadata? + private var channel: BuzzNotificationMetadata? + private var membership: BuzzNotificationMetadata? + private var avatar: BuzzNotificationAvatar? + + init( + event: VerifiedNostrEvent, community: PushLeaseCommunity, scope: BuzzNotificationCacheScope, + cache: BuzzNotificationCache, avatarLoader: BuzzNotificationAvatarLoader?, + now: @escaping () -> Date, isCurrent: @escaping () -> Bool, + loadAvatar: LoadAvatar? = nil, + scheduleDeadline: @escaping ScheduleDeadline = { queue, action in + queue.asyncAfter(deadline: .now() + 1.5, execute: DispatchWorkItem(block: action)) + }, + refresh: @escaping Refresh, completion: @escaping (BuzzPushResolution?) -> Void + ) { + self.event = event + self.community = community + self.scope = scope + self.cache = cache + self.loadAvatar = + loadAvatar + ?? avatarLoader.map { loader in + { source, scope, completion in + loader.load(source: source, scope: scope, completion: completion) + } + } + self.scheduleDeadline = scheduleDeadline + self.now = now + self.isCurrent = isCurrent + self.refresh = refresh + self.completion = completion + } + + func start() { + queue.async { [self] in + scheduleDeadline(queue) { [self] in deliver() } + readCache { [self] in + let refreshProfile = profile?.isFresh(at: now()) != true + let refreshChannel = + channelID != nil + && (channel?.isFresh(at: now()) != true || membership?.isFresh(at: now()) != true) + if refreshProfile || refreshChannel { + pendingProfile = refreshProfile + pendingChannel = refreshChannel + refresh(refreshProfile, refreshChannel) { [self] result in + workQueue.async { [self] in + if isCurrent() { + do { + switch result { + case .profile(let profile): + if let profile { try cache.save(event: profile, scope: scope) } + case .channel(let channel, let membership): + try cache.saveChannel(channel: channel, membership: membership, scope: scope) + } + } catch { report(error) } + } + readCache { [self] in + switch result { + case .profile: pendingProfile = false + case .channel: pendingChannel = false + } + if !pendingProfile { fetchAvatar() } + } + } + } + if !pendingProfile { fetchAvatar() } + } else { + fetchAvatar() + } + } + } + } + + private var channelID: String? { + BuzzPushNotificationResolver.tagValue("h", in: event) + } + + private func readCache(completion: @escaping () -> Void) { + workQueue.async { [self] in + var loaded = BuzzNotificationCache.Presentation() + do { + loaded = try cache.presentation( + scope: scope, sender: event.pubkey, + channelID: channelID, authority: community.relayMetadataPubkey?.lowercased()) + } catch { report(error) } + let snapshot = loaded + queue.async { [self] in + profile = snapshot.profile + avatar = snapshot.avatar + channel = snapshot.channel + membership = snapshot.membership + completion() + } + } + } + + private var avatarIsFresh: Bool { + guard let avatar else { return false } + let age = now().timeIntervalSince(avatar.savedAt) + return age >= 0 && age < BuzzNotificationMetadata.freshness + } + + private func fetchAvatar() { + guard !avatarLoading else { return } + guard isCurrent(), let profile, let source = BuzzNotificationCache.picture(profile.event), + avatar?.source != source || !avatarIsFresh, let loadAvatar + else { + deliverIfReady() + return + } + avatarLoading = true + // Decoding and persistence cannot occupy the delivery deadline's queue. + workQueue.async { [self] in + let requestID: String + do { + guard + let registered = try cache.beginAvatarRequest( + profileID: profile.event.id, pubkey: event.pubkey, scope: scope) + else { + readCache { [self] in + avatarLoading = false + deliverIfReady() + } + return + } + requestID = registered + } catch { + report(error) + readCache { [self] in + avatarLoading = false + deliverIfReady() + } + return + } + loadAvatar(source, scope) { [self] result in + workQueue.async { [self] in + switch result { + case .success(let data): + do { + try cache.saveAvatar( + data, source: source, profileID: profile.event.id, requestID: requestID, + pubkey: event.pubkey, scope: scope) + } catch { report(error) } + case .failure(let error): report(error) + } + readCache { [self] in + avatarLoading = false + deliverIfReady() + } + } + } + } + } + + private func deliverIfReady() { + if !pendingProfile && !pendingChannel && !avatarLoading { deliver() } + } + + private func deliver() { + guard let completion else { return } + self.completion = nil + guard isCurrent() else { + completion(nil) + return + } + let cachedProfile = profile.map { record in + let metadata = BuzzPushPresentationCacheStore.profileMetadata(record.event) + return BuzzPushCachedProfile( + communityID: community.id, relayOrigin: scope.relayOrigin, + pubkey: event.pubkey, displayName: metadata.displayName, + pictureHash: metadata.pictureHash, avatarPNG: avatar?.data, + eventID: record.event.id, eventCreatedAt: record.event.createdAt, + cachedAt: Int(record.checkedAt.timeIntervalSince1970)) + } + let cachedChannel = community.relayMetadataPubkey.flatMap { + BuzzPushNotificationResolver.ephemeralChannel( + metadataEvent: channel?.event, membershipEvent: membership?.event, cached: nil, + communityID: community.id, relayOrigin: scope.relayOrigin, relayMetadataPubkey: $0, + cachedAt: Int(now().timeIntervalSince1970)) + } + completion( + BuzzPushNotificationResolver.makeResolution( + event: event, community: community, profile: cachedProfile, channel: cachedChannel)) + } + + private func report(_ error: Error) { + // These records are a rebuildable cache. Failure is observable, and the + // next notification retries rather than treating it as an authoritative empty profile. + NSLog("Notification enrichment failed: %@", String(describing: error)) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationSVG.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationSVG.swift new file mode 100644 index 0000000000..f8abe3e004 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationSVG.swift @@ -0,0 +1,168 @@ +import Foundation +import SwiftDraw + +/// Validates bounded SVG input before passing it to the native renderer. +enum BuzzNotificationSVG { + static func decode(_ data: Data) throws -> Data { + try validate(data) + guard let svg = SVG(data: data), svg.size.width.isFinite, svg.size.height.isFinite, + svg.size.width > 0, svg.size.height > 0, + svg.size.width <= 16384, svg.size.height <= 16384 + else { throw BuzzNotificationCacheError.invalidRecord } + let factor = min(1, 320 / max(svg.size.width, svg.size.height)) + let png = try svg.sized( + CGSize(width: svg.size.width * factor, height: svg.size.height * factor) + ).pngData(scale: 1) + guard png.count <= BuzzNotificationCache.maximumAvatarBytes else { + throw BuzzNotificationCacheError.oversizedRecord + } + return png + } + + static func validate(_ data: Data) throws { + guard data.count <= 128 * 1024, let text = String(data: data, encoding: .utf8), + !text.lowercased().contains(" 16384 { reject(parser) } + } + + func parser(_ parser: XMLParser, foundCDATA data: Data) { + guard let string = String(data: data, encoding: .utf8) else { + reject(parser) + return + } + self.parser(parser, foundCharacters: string) + } + + private func reject(_ parser: XMLParser) { + valid = false + parser.abortParsing() + } + + func checkReferences() throws { + var budget = 4096 + func visit(_ index: Int, ancestors: Set) throws { + guard !ancestors.contains(index), budget > 0, ancestors.count < 32 else { + throw BuzzNotificationCacheError.oversizedRecord + } + budget -= 1 + var ancestors = ancestors + ancestors.insert(index) + for child in nodes[index].children { try visit(child, ancestors: ancestors) } + for reference in nodes[index].references { + guard let target = identifiers[reference] else { + throw BuzzNotificationCacheError.invalidRecord + } + try visit(target, ancestors: ancestors) + } + } + try visit(0, ancestors: []) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index d195748021..1428d3fa5f 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -6,6 +6,8 @@ import Foundation /// Content resolved from unread Buzz events for a mutable push notification. public struct BuzzPushResolution: Decodable, Equatable, Sendable { + /// Local sign-in that authorized this result; checked again before delivery. + public let notificationScope: BuzzNotificationCacheScope? public let title: String public let body: String public let subtitle: String? @@ -28,8 +30,10 @@ public struct BuzzPushResolution: Decodable, Equatable, Sendable { senderAvatarPNG: Data? = nil, conversationIdentifier: String? = nil, conversationDisplayName: String? = nil, - conversationRecipientCount: Int? = nil + conversationRecipientCount: Int? = nil, + notificationScope: BuzzNotificationCacheScope? = nil ) { + self.notificationScope = notificationScope self.title = title self.body = body self.subtitle = subtitle @@ -50,39 +54,39 @@ public protocol BuzzPushNotificationResolving { /// Reads configured Buzz communities and resolves their newest unread event. public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { - // Verified profiles may carry bounded inline raster avatars. Keep the refresh - // response capped, but large enough to recover the sender name from those - // otherwise valid kind-0 events when the app cache has not been populated. - static let maximumPresentationResponseBytes = 256 * 1_024 + // Each independent refresh has a hard transfer limit, allowing bounded + // content plus JSON escaping, tags, and envelopes. + // Each record still passes the cache's separate content and signature checks. + static let maximumPresentationResponseBytes = 2 * 1_024 * 1_024 private let session: URLSession private let loadCommunitiesData: () -> Data? - private let loadPresentationCacheData: () -> Data? private let loadPrivateKey: (String) -> String? private let now: () -> Date - private let presentationCacheLifetime: TimeInterval + private let notificationCache: BuzzNotificationCache? + private let avatarLoader: BuzzNotificationAvatarLoader? /// Creates a resolver around the notification extension's App Group and Keychain I/O. public init( session: URLSession, loadCommunitiesData: @escaping () -> Data?, loadPrivateKey: @escaping (String) -> String?, - loadPresentationCacheData: @escaping () -> Data? = { nil }, - now: @escaping () -> Date = Date.init, - presentationCacheLifetime: TimeInterval = BuzzPushPresentationCacheStore.freshnessLifetime + notificationCache: BuzzNotificationCache? = nil, + avatarLoader: BuzzNotificationAvatarLoader? = nil, + now: @escaping () -> Date = Date.init ) { + self.notificationCache = notificationCache + self.avatarLoader = avatarLoader self.session = session self.loadCommunitiesData = loadCommunitiesData - self.loadPresentationCacheData = loadPresentationCacheData self.loadPrivateKey = loadPrivateKey self.now = now - self.presentationCacheLifetime = presentationCacheLifetime } public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { let communities = loadCommunities().filter { $0.pubkey?.isEmpty == false - && loadPrivateKey($0.id) != nil + && accountPrivateKey(for: $0) != nil && !$0.policies.isEmpty } guard !communities.isEmpty else { @@ -115,11 +119,27 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { } } + private func matchingPrivateKey(for community: PushLeaseCommunity) -> String? { + guard let scope = BuzzNotificationCacheScope(community: community), + loadCommunities().contains(where: { BuzzNotificationCacheScope(community: $0) == scope }) + else { return nil } + return accountPrivateKey(for: community) + } + + // Initial requests share the snapshot loaded by resolve. Asynchronous results + // use matchingPrivateKey to check that the account and session are still current. + private func accountPrivateKey(for community: PushLeaseCommunity) -> String? { + guard let scope = BuzzNotificationCacheScope(community: community), + let key = loadPrivateKey(community.id), scope.matchesAccountKey(key) + else { return nil } + return key + } + private func query( _ community: PushLeaseCommunity, completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void ) { - guard let privateKey = loadPrivateKey(community.id), community.pubkey?.isEmpty == false else { + guard let privateKey = accountPrivateKey(for: community), community.pubkey?.isEmpty == false else { completion(nil) return } @@ -149,7 +169,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { } request.setValue(auth, forHTTPHeaderField: "Authorization") session.dataTask(with: request) { data, response, _ in - guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), + guard self.matchingPrivateKey(for: community) != nil, + let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) else { completion(nil) @@ -173,241 +194,116 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { community: PushLeaseCommunity, completion: @escaping (BuzzPushResolution?) -> Void ) { - let snapshot = BuzzPushPresentationCacheSnapshot.decode(loadPresentationCacheData()) - let relayOrigin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(community.relayUrl) - let cachedProfile = relayOrigin.flatMap { - snapshot.profile( - communityID: community.id, - relayOrigin: $0, - pubkey: event.pubkey - ) - } - let channelID = Self.tagValue("h", in: event) - let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased() - let cachedChannel = channelID.flatMap { channelID in - relayOrigin.flatMap { - snapshot.channel( - communityID: community.id, - relayOrigin: $0, - channelID: channelID - ) - } - }.flatMap { channel in - channel.relayMetadataPubkey == relayMetadataPubkey ? channel : nil - } - let timestamp = Int(now().timeIntervalSince1970) - let profileNeedsRefresh = Self.isStale( - cachedAt: cachedProfile?.cachedAt, - now: timestamp, - lifetime: presentationCacheLifetime - ) - let membershipNeedsRefresh: Bool = { - guard let cachedChannel else { return true } - if Self.isStale( - cachedAt: cachedChannel.membershipCachedAt, - now: timestamp, - lifetime: presentationCacheLifetime - ) { - return true - } - guard let memberCount = cachedChannel.memberCount else { return true } - guard memberCount <= BuzzPushPresentationCacheStore.maximumMembersPerChannel - else { return false } - return cachedChannel.memberDigests?.count != memberCount - }() - let channelNeedsRefresh = - channelID != nil - && (Self.isStale( - cachedAt: cachedChannel?.cachedAt, - now: timestamp, - lifetime: presentationCacheLifetime - ) || membershipNeedsRefresh) - let fallback = Self.makeResolution( - event: event, - community: community, - profile: cachedProfile, - channel: cachedChannel - ) - guard profileNeedsRefresh || channelNeedsRefresh else { - completion(fallback) + guard matchingPrivateKey(for: community) != nil else { + completion(nil) return } - - refreshPresentation( - event: event, - community: community, - refreshProfile: profileNeedsRefresh, - refreshChannel: channelNeedsRefresh - ) { refreshedProfileEvent, refreshedChannelEvent, refreshedMembershipEvent in - let profile = - refreshedProfileEvent.flatMap { - guard - BuzzPushPresentationCacheStore.shouldReplace( - existingCreatedAt: cachedProfile?.eventCreatedAt, - existingID: cachedProfile?.eventID, - candidateCreatedAt: $0.createdAt, - candidateID: $0.id - ) - else { return nil } - return Self.ephemeralProfile( - event: $0, - communityID: community.id, - relayOrigin: relayOrigin ?? community.relayUrl, - cached: cachedProfile, - cachedAt: timestamp - ) - } ?? cachedProfile - let newerChannelEvent: VerifiedNostrEvent? = refreshedChannelEvent.flatMap { event in - guard - BuzzPushPresentationCacheStore.shouldReplace( - existingCreatedAt: cachedChannel?.eventCreatedAt, - existingID: cachedChannel?.eventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) - else { return nil } - return event + if let notificationCache { + guard let scope = BuzzNotificationCacheScope(community: community) else { + completion(nil) + return } - let newerMembershipEvent: VerifiedNostrEvent? = refreshedMembershipEvent.flatMap { event in - guard - BuzzPushPresentationCacheStore.shouldReplace( - existingCreatedAt: cachedChannel?.membershipEventCreatedAt, - existingID: cachedChannel?.membershipEventID, - candidateCreatedAt: event.createdAt, - candidateID: event.id - ) - else { return nil } - return event - } - let channel = - relayMetadataPubkey.flatMap { - Self.ephemeralChannel( - metadataEvent: newerChannelEvent, - membershipEvent: newerMembershipEvent, - cached: cachedChannel, - communityID: community.id, - relayOrigin: relayOrigin ?? community.relayUrl, - relayMetadataPubkey: $0, - cachedAt: timestamp - ) - } ?? cachedChannel - completion( - Self.makeResolution( - event: event, - community: community, - profile: profile, - channel: channel - ) ?? fallback - ) + let operation = BuzzNotificationEnrichmentOperation( + event: event, community: community, scope: scope, cache: notificationCache, + avatarLoader: avatarLoader, now: now, + isCurrent: { [self] in + matchingPrivateKey(for: community) != nil + }, + refresh: { [self] profile, channel, done in + refreshPresentation(event: event, community: community, + refreshProfile: profile, refreshChannel: channel, completion: done) + }, completion: completion) + operation.start() + return } + // Without extension storage, keep the verified message but omit enrichment. + completion(Self.makeResolution(event: event, community: community, profile: nil, channel: nil)) } private func refreshPresentation( - event: VerifiedNostrEvent, - community: PushLeaseCommunity, - refreshProfile: Bool, - refreshChannel: Bool, - completion: - @escaping ( - VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent? - ) -> Void + event: VerifiedNostrEvent, community: PushLeaseCommunity, + refreshProfile: Bool, refreshChannel: Bool, + completion: @escaping (BuzzNotificationEnrichmentOperation.RefreshResult) -> Void ) { - guard let privateKey = loadPrivateKey(community.id), - let relayURL = community.relayURL, - let url = URL(string: "/query", relativeTo: relayURL) - else { - completion(nil, nil, nil) - return - } - let channelID = Self.tagValue("h", in: event) - let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased() - var filters: [[String: Any]] = [] + // A full channel roster can be much larger than a profile. Keep these + // transfers independent so its size or latency cannot hide the sender. if refreshProfile { - filters.append(["kinds": [0], "authors": [event.pubkey.lowercased()], "limit": 1]) + queryPresentation( + filters: [["kinds": [0], "authors": [event.pubkey.lowercased()], "limit": 1]], + community: community + ) { events in + completion(.profile(Self.newest(events.filter { + $0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased() + }))) + } } - if refreshChannel, let channelID, let relayMetadataPubkey { - filters.append([ - "kinds": [39_000], - "authors": [relayMetadataPubkey], - "#d": [channelID], - "limit": 1, - ]) - filters.append([ - "kinds": [39_002], - "authors": [relayMetadataPubkey], - "#d": [channelID], - "limit": 1, - ]) + if refreshChannel { + guard let channelID = Self.tagValue("h", in: event), + let authority = community.relayMetadataPubkey?.lowercased() + else { + completion(.channel(nil, nil)) + return + } + let group = DispatchGroup() + let lock = NSLock() + var records: [Int: VerifiedNostrEvent] = [:] + for kind in [39000, 39002] { + group.enter() + queryPresentation(filters: [[ + "kinds": [kind], "authors": [authority], "#d": [channelID], "limit": 1, + ]], community: community) { events in + let record = Self.newest(events.filter { + $0.kind == kind && $0.pubkey.lowercased() == authority + && Self.tagValue("d", in: $0) == channelID + }) + lock.lock() + records[kind] = record + lock.unlock() + group.leave() + } + } + group.notify(queue: .global(qos: .utility)) { + completion(.channel(records[39000], records[39002])) + } } - guard !filters.isEmpty, + } + + private func queryPresentation( + filters: [[String: Any]], community: PushLeaseCommunity, + completion: @escaping ([VerifiedNostrEvent]) -> Void + ) { + guard let privateKey = matchingPrivateKey(for: community), + let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL), let body = try? JSONSerialization.data(withJSONObject: filters) else { - completion(nil, nil, nil) + completion([]) return } - var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = body - // A verified kind-0 profile may include a bounded inline raster avatar. - // Railway can take longer than one second to return that larger response, - // while three seconds remains a small fraction of the NSE execution budget. - request.timeoutInterval = 3 request.setValue("application/json", forHTTPHeaderField: "Content-Type") - guard - let auth = try? NostrHTTPAuth.authorizationHeader( - url: url, - method: "POST", - body: body, - privateKeyHex: privateKey - ) + guard let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, method: "POST", body: body, privateKeyHex: privateKey) else { - completion(nil, nil, nil) + completion([]) return } request.setValue(auth, forHTTPHeaderField: "Authorization") - session.downloadTask(with: request) { fileURL, response, _ in - guard let response = response as? HTTPURLResponse, - (200..<300).contains(response.statusCode), - let fileURL, - let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), - let fileSize = values.fileSize, - fileSize <= Self.maximumPresentationResponseBytes, - let data = try? Data(contentsOf: fileURL), + BuzzNotificationDownload.start( + request: request, configuration: session.configuration, + maximumBytes: Self.maximumPresentationResponseBytes + ) { result in + guard self.matchingPrivateKey(for: community) != nil, + case .success(let data) = result, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) else { - completion(nil, nil, nil) + completion([]) return } - let verified = events.filter { $0.hasValidIDAndSignature() } - let profile = - refreshProfile - ? Self.newest( - verified.filter { - $0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased() - }) : nil - let channel = - refreshChannel - ? channelID.flatMap { channelID in - Self.newest( - verified.filter { - $0.kind == 39_000 - && $0.pubkey.lowercased() == relayMetadataPubkey - && Self.tagValue("d", in: $0) == channelID - }) - } : nil - let membership = - refreshChannel - ? channelID.flatMap { channelID in - Self.newest( - verified.filter { - $0.kind == 39_002 - && $0.pubkey.lowercased() == relayMetadataPubkey - && Self.tagValue("d", in: $0) == channelID - }) - } : nil - completion(profile, channel, membership) - }.resume() + completion(events.filter { $0.hasValidIDAndSignature() }) + } } static func decodeResolution( @@ -438,7 +334,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { }.first } - private static func makeResolution( + static func makeResolution( event: VerifiedNostrEvent, community: PushLeaseCommunity, profile: BuzzPushCachedProfile?, @@ -454,7 +350,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { communicationConversation(event: event, community: community, channel: $0) } return BuzzPushResolution( - title: profile?.displayName ?? shortPubkey(event.pubkey), + title: profile?.displayName ?? "", body: body, subtitle: community.name, threadIdentifier: conversationIdentifier ?? community.id, @@ -470,32 +366,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { senderAvatarPNG: profile?.avatarPNG, conversationIdentifier: conversationIdentifier, conversationDisplayName: conversation?.displayName, - conversationRecipientCount: conversation?.recipientCount + conversationRecipientCount: conversation?.recipientCount, + notificationScope: BuzzNotificationCacheScope(community: community) ) } - private static func ephemeralProfile( - event: VerifiedNostrEvent, - communityID: String, - relayOrigin: String, - cached: BuzzPushCachedProfile?, - cachedAt: Int - ) -> BuzzPushCachedProfile { - let metadata = BuzzPushPresentationCacheStore.profileMetadata(event) - return BuzzPushCachedProfile( - communityID: communityID, - relayOrigin: relayOrigin, - pubkey: event.pubkey.lowercased(), - displayName: metadata.displayName, - pictureHash: metadata.pictureHash, - avatarPNG: cached?.pictureHash == metadata.pictureHash ? cached?.avatarPNG : nil, - eventID: event.id, - eventCreatedAt: event.createdAt, - cachedAt: cachedAt - ) - } - - private static func ephemeralChannel( + static func ephemeralChannel( metadataEvent: VerifiedNostrEvent?, membershipEvent: VerifiedNostrEvent?, cached: BuzzPushCachedChannel?, @@ -585,19 +461,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { }.first } - private static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? { + static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? { event.tags.first { $0.count >= 2 && $0[0] == name }?[1] } - private static func isStale( - cachedAt: Int?, - now: Int, - lifetime: TimeInterval - ) -> Bool { - guard let cachedAt else { return true } - return TimeInterval(max(0, now - cachedAt)) > lifetime - } - static func previewBody(_ content: String) -> String { var result = content.replacingOccurrences( of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift index f4a27e8a9f..8619dee4da 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -185,10 +185,19 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable { lock.lock() defer { lock.unlock() } var snapshot = loadLocked() - snapshot.communities = communities - let retained = Set(communities.map(\.id)) - snapshot.profiles.removeAll { !retained.contains($0.communityID) } - snapshot.channels.removeAll { !retained.contains($0.communityID) } + snapshot.communities = communities.map { community in + var community = community + let previous = snapshot.communities.first { + $0.id == community.id && $0.pubkey == community.pubkey + && Self.canonicalRelayOrigin($0.relayUrl) == Self.canonicalRelayOrigin(community.relayUrl) + } + community.notificationSessionID = previous?.notificationSessionID ?? UUID().uuidString + return community + } + // Account configuration is the only app-owned notification data. Discard + // presentation exports left by older app versions, including retained accounts. + snapshot.profiles.removeAll() + snapshot.channels.removeAll() try writeLocked(snapshot) } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift index 3912370456..f8b18fdbdd 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift @@ -104,6 +104,8 @@ public enum NostrHTTPAuth { ["u", url.absoluteString], ["method", method.uppercased()], ["payload", payload], + // Separate requests in the same second must not reuse an event ID. + ["nonce", UUID().uuidString.lowercased()], ] let serialized = try VerifiedNostrEvent.canonicalSerialization( pubkey: pubkey, createdAt: createdAt, kind: 27235, diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift index cb7eb79108..969520c2cd 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -15,6 +15,8 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable { /// Relay NIP-11 `self` key used to verify NIP-29 channel metadata. public let relayMetadataPubkey: String? public let pubkey: String? + /// Identity of this local sign-in, renewed after account removal. + public var notificationSessionID: String? public let policies: [PushResolutionPolicy] public init( @@ -23,7 +25,8 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable { relayUrl: String, relayMetadataPubkey: String? = nil, pubkey: String?, - policies: [PushResolutionPolicy] + policies: [PushResolutionPolicy], + notificationSessionID: String? = nil ) { self.id = id self.name = name @@ -31,6 +34,7 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable { self.relayMetadataPubkey = relayMetadataPubkey self.pubkey = pubkey self.policies = policies + self.notificationSessionID = notificationSessionID } } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationAvatarLoaderTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationAvatarLoaderTests.swift new file mode 100644 index 0000000000..be8922f610 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationAvatarLoaderTests.swift @@ -0,0 +1,74 @@ +import Foundation +import ImageIO +import Testing + +@testable import BuzzPushKit + +@Suite("Native notification avatar decoding") +struct BuzzNotificationAvatarLoaderTests { + @Test("Avatar proofs preserve IPv6 brackets and explicit ports") + func signedServerAuthority() throws { + let key = String(repeating: "0", count: 63) + "1" + let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + for authority in ["[::1]:3000", "[2001:db8::1]", "relay.example:443", "relay.example"] { + let url = try #require(URL(string: "https://\(authority)/media/avatar.png")) + let header = try BuzzNotificationAvatarLoader.authorization(url: url, key: key, account: account) + let data = try #require(Data(base64Encoded: String(header.dropFirst(6)))) + let event = try JSONDecoder().decode(VerifiedNostrEvent.self, from: data) + #expect(event.tags.contains(["server", authority])) + #expect(event.kind == 24242) + #expect(event.hasValidIDAndSignature()) + } + } + + @Test("Relay media uses canonical hosts and default ports without trusting other origins") + func mediaOrigins() throws { + let hash = String(repeating: "a", count: 64) + for (relay, media, expected) in [ + ("wss://relay.example:443", "https://relay.example", true), + ("wss://relay.example", "https://RELAY.EXAMPLE:443", true), + ("ws://relay.example:80", "http://RELAY.EXAMPLE", true), + ("wss://relay.example:8443", "https://relay.example:8443", true), + ("wss://relay.example", "https://relay.example:8443", false), + ("wss://relay.example", "http://relay.example", false), + ("wss://relay.example", "https://other.example", false), + ] { + let scope = try #require( + BuzzNotificationCacheScope( + community: PushLeaseCommunity( + id: "community", name: "Test", relayUrl: relay, pubkey: "account", policies: [], + notificationSessionID: "session"))) + let url = try #require(URL(string: "\(media)/media/\(hash).png")) + #expect((BuzzNotificationAvatarLoader.thumbnailURL(url, scope: scope) != nil) == expected) + } + } + + @Test("Animated avatar selects the poster without fetching the animation") + func animatedPoster() throws { + let source = + "https://images.example/poster.png#buzz-anim=https%3A%2F%2Fimages.example%2Fanimation.apng" + #expect( + BuzzNotificationAvatarLoader.posterURL(source)?.absoluteString + == "https://images.example/poster.png") + #expect(BuzzNotificationAvatarLoader.posterURL("file:///private/avatar.png") == nil) + } + + @Test("Raster decoder produces a bounded PNG and rejects non-image data") + func raster() throws { + let bytes = try #require( + Data( + base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=" + )) + let png = try BuzzNotificationAvatarLoader.decode(bytes) + let source = try #require(CGImageSourceCreateWithData(png as CFData, nil)) + let properties = try #require( + CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]) + #expect(properties[kCGImagePropertyPixelWidth] as? Int == 1) + #expect(properties[kCGImagePropertyPixelHeight] as? Int == 1) + #expect(png.count <= BuzzNotificationCache.maximumAvatarBytes) + #expect(throws: BuzzNotificationCacheError.self) { + try BuzzNotificationAvatarLoader.decode(Data("not an image".utf8)) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationCacheTests.swift new file mode 100644 index 0000000000..b0259fc40f --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationCacheTests.swift @@ -0,0 +1,336 @@ +import CryptoKit +import Foundation +import P256K +import Testing + +@testable import BuzzPushKit + +@Suite("Extension-owned notification cache") +struct BuzzNotificationCacheTests { + private let key = String(repeating: "0", count: 63) + "1" + + private func scope(_ session: String = "login-one") throws -> BuzzNotificationCacheScope { + try #require( + BuzzNotificationCacheScope( + community: PushLeaseCommunity( + id: "community", name: "Test", relayUrl: "wss://relay.example", + pubkey: "account", policies: [], notificationSessionID: session))) + } + + private func directory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test("Replacement retains the previous avatar; removal rejects a late replacement") + func replacementAndRemoval() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try scope() + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + let first = try signedEvent( + privateKey: key, createdAt: 1, kind: 0, + content: #"{"name":"A","picture":"https://image.example/a.png"}"#) + try cache.save(event: first, scope: scope) + let firstRequest = try #require( + try cache.beginAvatarRequest( + profileID: first.id, pubkey: first.pubkey, scope: scope)) + let bytes = Data([1, 2, 3]) + try cache.saveAvatar( + bytes, source: "https://image.example/a.png", + profileID: first.id, requestID: firstRequest, pubkey: first.pubkey, scope: scope) + let replacement = try signedEvent( + privateKey: key, createdAt: 2, kind: 0, + content: #"{"name":"A","picture":"https://image.example/b.png"}"#) + try cache.save(event: replacement, scope: scope) + #expect(try cache.avatar(scope: scope, pubkey: first.pubkey)?.data == bytes) + let replacementRequest = try #require( + try cache.beginAvatarRequest( + profileID: replacement.id, pubkey: first.pubkey, scope: scope)) + let removal = try signedEvent(privateKey: key, createdAt: 3, kind: 0, content: #"{"name":"A"}"#) + try cache.save(event: removal, scope: scope) + try cache.saveAvatar( + bytes, source: "https://image.example/b.png", + profileID: replacement.id, requestID: replacementRequest, pubkey: first.pubkey, scope: scope) + #expect(try cache.avatar(scope: scope, pubkey: first.pubkey) == nil) + try cache.save(event: first, scope: scope) + #expect(try cache.metadata(scope: scope, kind: 0, author: first.pubkey)?.event.id == removal.id) + } + + @Test("Two cache objects preserve each other's records and data survives reopening") + func multipleInstances() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try scope() + let first = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + let second = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + let profile = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#) + let channel = try signedEvent( + privateKey: key, kind: 39000, tags: [["d", "room"], ["name", "General"]]) + try first.save(event: profile, scope: scope) + try second.save(event: channel, scope: scope) + #expect( + try first.metadata(scope: scope, kind: 39000, author: channel.pubkey, identifier: "room")? + .event == channel) + #expect(try second.metadata(scope: scope, kind: 0, author: profile.pubkey)?.event == profile) + } + + @Test("Concurrent notification writers preserve every independent record") + func concurrentWriters() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try scope() + let events = try (0..<24).map { index in + try signedEvent( + privateKey: key, kind: 39000, + tags: [["d", "room-\(index)"], ["name", "Room \(index)"]]) + } + let errorLock = NSLock() + var failures: [String] = [] + DispatchQueue.concurrentPerform(iterations: events.count) { index in + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + do { + try cache.save(event: events[index], scope: scope) + let record = try cache.metadata( + scope: scope, kind: 39000, + author: events[index].pubkey, identifier: "room-\(index)") + if record?.event != events[index] { + errorLock.lock() + failures.append("Missing record \(index)") + errorLock.unlock() + } + } catch { + errorLock.lock() + failures.append(String(describing: error)) + errorLock.unlock() + } + } + #expect(failures.isEmpty) + let reopened = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + for (index, event) in events.enumerated() { + #expect( + try reopened.metadata( + scope: scope, kind: 39000, + author: event.pubkey, identifier: "room-\(index)")?.event == event) + } + } + + @Test("A failed batch cannot publish only its channel metadata") + func atomicBatchFailure() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try scope() + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + let channel = try signedEvent( + privateKey: key, createdAt: 1, kind: 39000, tags: [["d", "room"]]) + let members = try signedEvent( + privateKey: key, createdAt: 1, kind: 39002, tags: [["d", "room"]]) + try cache.save(events: [channel, members], scope: scope) + let newer = try signedEvent(privateKey: key, createdAt: 2, kind: 39000, tags: [["d", "room"]]) + let invalid = try signedEvent(privateKey: key, createdAt: 2, kind: 9) + #expect(throws: BuzzNotificationCacheError.self) { + try cache.save(events: [newer, invalid], scope: scope) + } + let snapshot = try cache.presentation( + scope: scope, sender: channel.pubkey, + channelID: "room", authority: channel.pubkey) + #expect(snapshot.channel?.event == channel) + #expect(snapshot.membership?.event == members) + } + + @Test("Partial channel refreshes cannot reuse an old counterpart") + func partialChannelRefresh() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try scope() + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + for refreshedKind in [39000, 39002] { + let old = try [39000, 39002].map { + try signedEvent(privateKey: key, createdAt: 1, kind: $0, tags: [["d", "room-\(refreshedKind)"]]) + } + try cache.saveChannel(channel: old[0], membership: old[1], scope: scope) + let newer = try signedEvent( + privateKey: key, createdAt: 2, kind: refreshedKind, + tags: [["d", "room-\(refreshedKind)"]]) + try cache.saveChannel( + channel: refreshedKind == 39000 ? newer : nil, + membership: refreshedKind == 39002 ? newer : nil, scope: scope) + let snapshot = try cache.presentation( + scope: scope, sender: newer.pubkey, + channelID: "room-\(refreshedKind)", authority: newer.pubkey) + #expect(snapshot.channel?.event == (refreshedKind == 39000 ? newer : nil)) + #expect(snapshot.membership?.event == (refreshedKind == 39002 ? newer : nil)) + } + } + + @Test("Older channel responses cannot invalidate or partly replace a newer pair") + func olderChannelRefresh() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try scope() + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + let current = try [39000, 39002].map { + try signedEvent(privateKey: key, createdAt: 2, kind: $0, tags: [["d", "room"]]) + } + try cache.saveChannel(channel: current[0], membership: current[1], scope: scope) + let older = try signedEvent(privateKey: key, createdAt: 1, kind: 39000, tags: [["d", "room"]]) + let newer = try signedEvent(privateKey: key, createdAt: 3, kind: 39002, tags: [["d", "room"]]) + try cache.saveChannel(channel: older, membership: nil, scope: scope) + try cache.saveChannel(channel: older, membership: newer, scope: scope) + try cache.saveChannel(channel: nil, membership: nil, scope: scope) + let snapshot = try cache.presentation( + scope: scope, sender: current[0].pubkey, channelID: "room", authority: current[0].pubkey) + #expect(snapshot.channel?.event == current[0]) + #expect(snapshot.membership?.event == current[1]) + } + + @Test("Concurrent channel readers see one complete refresh") + func atomicBatchReaders() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try scope() + let pairs = try (1...20).map { version in + try [39000, 39002].map { kind in + try signedEvent(privateKey: key, createdAt: version, kind: kind, tags: [["d", "room"]]) + } + } + let errorsLock = NSLock() + var errors: [String] = [] + DispatchQueue.concurrentPerform(iterations: pairs.count) { index in + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + do { + try cache.save(events: pairs[index], scope: scope) + let snapshot = try cache.presentation( + scope: scope, sender: pairs[index][0].pubkey, + channelID: "room", authority: pairs[index][0].pubkey) + if snapshot.channel?.event.createdAt != snapshot.membership?.event.createdAt { + errorsLock.lock() + errors.append("Mixed channel refresh") + errorsLock.unlock() + } + } catch { + errorsLock.lock() + errors.append(String(describing: error)) + errorsLock.unlock() + } + } + #expect(errors.isEmpty) + } + + @Test("Reads do not extend freshness; a successful recheck does") + func freshness() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try scope() + let event = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#) + let first = BuzzNotificationCache( + directory: root, + now: { Date(timeIntervalSince1970: 100) }, isCurrent: { $0 == scope }) + try first.save(event: event, scope: scope) + let cached = try #require(try first.metadata(scope: scope, kind: 0, author: event.pubkey)) + #expect(cached.isFresh(at: Date(timeIntervalSince1970: 3699))) + #expect(!cached.isFresh(at: Date(timeIntervalSince1970: 3700))) + let next = BuzzNotificationCache( + directory: root, + now: { Date(timeIntervalSince1970: 4000) }, isCurrent: { $0 == scope }) + #expect( + try next.metadata(scope: scope, kind: 0, author: event.pubkey)?.checkedAt == cached.checkedAt) + try next.save(event: event, scope: scope) + #expect( + try next.metadata(scope: scope, kind: 0, author: event.pubkey)?.checkedAt + == Date(timeIntervalSince1970: 4000)) + } + + @Test("Removed account is inaccessible and a new sign-in cannot reuse its cache") + func removedAccount() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let old = try scope() + let new = try scope("login-two") + let event = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#) + try BuzzNotificationCache(directory: root, isCurrent: { $0 == old }).save( + event: event, scope: old) + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == new }) + #expect(throws: BuzzNotificationCacheError.self) { + try cache.metadata(scope: old, kind: 0, author: event.pubkey) + } + #expect(throws: BuzzNotificationCacheError.self) { try cache.save(event: event, scope: old) } + #expect(try cache.metadata(scope: new, kind: 0, author: event.pubkey) == nil) + try cache.removeRevokedScopes(keeping: [new]) + #expect( + !FileManager.default.fileExists(atPath: root.appendingPathComponent(old.directoryName).path)) + } + + @Test("Incomplete cache file is reported, removed, and can be fetched again") + func interruptedWriteRecovery() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try scope() + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + let event = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#) + try cache.save(event: event, scope: scope) + let file = root.appendingPathComponent(scope.directoryName).appendingPathComponent( + "metadata.json") + try Data("{broken".utf8).write(to: file) + #expect(throws: BuzzNotificationCacheError.self) { + try cache.metadata(scope: scope, kind: 0, author: event.pubkey) + } + try cache.save(event: event, scope: scope) + #expect(try cache.metadata(scope: scope, kind: 0, author: event.pubkey)?.event == event) + } + + @Test("Bootstrap keeps a session across updates but renews it after removal") + func bootstrapSession() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let bootstrap = BuzzPushPresentationCacheStore(containerURL: root) + let community = PushLeaseCommunity( + id: "community", name: "Test", relayUrl: "wss://relay.example", + pubkey: "account", policies: []) + func session() throws -> String? { + let data = try Data( + contentsOf: root.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)) + return BuzzPushPresentationCacheSnapshot.decode(data).communities.first?.notificationSessionID + } + try bootstrap.replaceCommunities([community]) + let first = try #require(try session()) + try bootstrap.replaceCommunities([community]) + #expect(try session() == first) + try bootstrap.replaceCommunities([]) + try bootstrap.replaceCommunities([community]) + #expect(try session() != first) + } + + private func signedEvent( + privateKey: String, + createdAt: Int = 1_700_000_000, + kind: Int, + tags: [[String]] = [], + content: String = "" + ) throws -> VerifiedNostrEvent { + let privateKeyBytes = try #require(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyBytes) + let pubkey = VerifiedNostrEvent.hex(key.xonly.bytes) + let serialization = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content + ) + let digest = Array(SHA256.hash(data: serialization)) + var message = digest + var randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32) + let signature = try key.signature(message: &message, auxiliaryRand: &randomness) + return VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationDownloadTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationDownloadTests.swift new file mode 100644 index 0000000000..063b532467 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationDownloadTests.swift @@ -0,0 +1,66 @@ +import Foundation +import XCTest + +@testable import BuzzPushKit + +final class BuzzNotificationDownloadTests: XCTestCase { + override func tearDown() { + DownloadProtocol.response = nil + super.tearDown() + } + + func testRejectsOversizedDeclaredResponse() { + check(headers: ["Content-Length": "1000"], body: Data([1]), limit: 8, succeeds: false) + } + + func testRejectsOversizedStreamWithoutLengthHeader() { + check(headers: [:], body: Data(repeating: 1, count: 9), limit: 8, succeeds: false) + } + + func testAcceptsBoundedResponse() { + check(headers: [:], body: Data([1, 2, 3]), limit: 8, succeeds: true) + } + + private func check(headers: [String: String], body: Data, limit: Int, succeeds: Bool) { + let done = expectation(description: "Bounded transfer completes once") + done.assertForOverFulfill = true + DownloadProtocol.response = (headers, body) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [DownloadProtocol.self] + BuzzNotificationDownload.start( + request: URLRequest(url: URL(string: "https://images.example/test")!), + configuration: configuration, maximumBytes: limit + ) { result in + switch result { + case .success(let data): + XCTAssertTrue(succeeds) + XCTAssertEqual(data, body) + case .failure(let error): + XCTAssertFalse(succeeds) + guard case BuzzNotificationDownload.Failure.tooLarge = error else { + XCTFail("Expected size limit error, got \(error)") + done.fulfill() + return + } + } + done.fulfill() + } + wait(for: [done], timeout: 2) + } +} + +private final class DownloadProtocol: URLProtocol { + static var response: ([String: String], Data)? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + guard let (headers, data) = Self.response, let url = request.url, + let response = HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + else { return } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationEnrichmentOperationTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationEnrichmentOperationTests.swift new file mode 100644 index 0000000000..c76b85147e --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationEnrichmentOperationTests.swift @@ -0,0 +1,268 @@ +import CryptoKit +import Foundation +import P256K +import XCTest + +@testable import BuzzPushKit + +final class BuzzNotificationEnrichmentOperationTests: XCTestCase { + func testLateProfileStartsAvatarFetchButDoesNotDeliverAgain() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let message = try signedEvent(kind: 9, content: "Hello") + let profile = try signedEvent( + kind: 0, + content: #"{"name":"Alice","picture":"https://images.example/avatar.png"}"#) + let community = PushLeaseCommunity( + id: "community", name: "Test", + relayUrl: "wss://relay.example", pubkey: message.pubkey, policies: [], + notificationSessionID: "session") + let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community)) + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + let delivered = expectation(description: "Delivered at deadline") + delivered.assertForOverFulfill = true + let warmed = expectation(description: "Late image saved") + let image = Data([1, 2, 3]) + var deliveryCount = 0 + let operation = BuzzNotificationEnrichmentOperation( + event: message, community: community, scope: scope, cache: cache, avatarLoader: nil, + now: Date.init, isCurrent: { true }, + loadAvatar: { source, receivedScope, completion in + XCTAssertEqual(source, "https://images.example/avatar.png") + XCTAssertEqual(receivedScope, scope) + XCTAssertEqual(deliveryCount, 1) + completion(.success(image)) + }, + scheduleDeadline: { queue, deadline in + queue.async(execute: DispatchWorkItem(block: deadline)) + }, + refresh: { needsProfile, _, completion in + XCTAssertTrue(needsProfile) + // The deadline is already queued. This callback queues processing after it. + completion(.profile(profile)) + }, + completion: { resolution in + deliveryCount += 1 + XCTAssertEqual(resolution?.title, "") + delivered.fulfill() + }) + operation.start() + wait(for: [delivered], timeout: 2) + // Observe persistent state, rather than requiring the process to stay alive + // for a specific delay after the content handler has run. + DispatchQueue.global().async { + for _ in 0..<200 { + if (try? cache.avatar(scope: scope, pubkey: message.pubkey))?.data == image { + warmed.fulfill() + return + } + Thread.sleep(forTimeInterval: 0.005) + } + } + wait(for: [warmed], timeout: 2) + XCTAssertEqual(deliveryCount, 1) + } + + func testRevocationBeforeDeadlineSuppressesPresentationAndLateWrites() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let message = try signedEvent(kind: 9, content: "Hello") + let profile = try signedEvent(kind: 0, content: #"{"name":"Alice"}"#) + let community = PushLeaseCommunity( + id: "community", name: "Test", + relayUrl: "wss://relay.example", pubkey: message.pubkey, policies: [], + notificationSessionID: "session") + let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community)) + let accessLock = NSLock() + var current = true + func isCurrent() -> Bool { + accessLock.lock() + defer { accessLock.unlock() } + return current + } + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope && isCurrent() }) + let delivered = expectation(description: "Revoked account has no presentation") + delivered.assertForOverFulfill = true + let finished = expectation(description: "Late callback processed") + var operationQueue: DispatchQueue? + var deadlineAction: (() -> Void)? + let operation = BuzzNotificationEnrichmentOperation( + event: message, community: community, scope: scope, cache: cache, avatarLoader: nil, + now: Date.init, isCurrent: isCurrent, + scheduleDeadline: { queue, deadline in + operationQueue = queue + deadlineAction = deadline + }, + refresh: { _, _, completion in + accessLock.lock() + current = false + accessLock.unlock() + if let deadlineAction { + operationQueue?.async(execute: DispatchWorkItem(block: deadlineAction)) + } + completion(.profile(profile)) + operationQueue?.async { finished.fulfill() } + }, + completion: { resolution in + XCTAssertNil(resolution) + delivered.fulfill() + }) + operation.start() + wait(for: [delivered, finished], timeout: 2) + // Inspect the old scope's disk contents with an independent reader. Revocation + // must have prevented the late result from being persisted at all. + let reader = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + XCTAssertNil(try reader.metadata(scope: scope, kind: 0, author: message.pubkey)) + } + + func testExpiredAvatarAtSameURLIsCheckedAndOldImageSurvivesFailure() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let message = try signedEvent(kind: 9, content: "Hello") + let source = "https://images.example/avatar.png" + let profile = try signedEvent( + kind: 0, + content: #"{"name":"Alice","picture":"https://images.example/avatar.png"}"#) + let community = PushLeaseCommunity( + id: "community", name: "Test", + relayUrl: "wss://relay.example", pubkey: message.pubkey, policies: [], + notificationSessionID: "session") + let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community)) + let oldCache = BuzzNotificationCache( + directory: root, + now: { Date(timeIntervalSince1970: 100) }, isCurrent: { $0 == scope }) + try oldCache.save(event: profile, scope: scope) + let oldRequest = try XCTUnwrap( + oldCache.beginAvatarRequest( + profileID: profile.id, pubkey: message.pubkey, scope: scope)) + let oldImage = Data([1, 2, 3]) + try oldCache.saveAvatar( + oldImage, source: source, profileID: profile.id, requestID: oldRequest, + pubkey: message.pubkey, scope: scope) + let now = Date(timeIntervalSince1970: 3700) + let cache = BuzzNotificationCache(directory: root, now: { now }, isCurrent: { $0 == scope }) + // The profile itself is fresh, but its unchanged URL must not make the old + // decoded image permanently fresh. + try cache.save(event: profile, scope: scope) + let checked = expectation(description: "Same URL checked again") + let delivered = expectation(description: "Old image retained") + let operation = BuzzNotificationEnrichmentOperation( + event: message, community: community, scope: scope, cache: cache, avatarLoader: nil, + now: { now }, isCurrent: { true }, + loadAvatar: { url, _, completion in + XCTAssertEqual(url, source) + checked.fulfill() + completion(.failure(URLError(.notConnectedToInternet))) + }, + refresh: { _, _, _ in XCTFail("Fresh profile must not trigger a metadata request") }, + completion: { result in + XCTAssertEqual(result?.senderAvatarPNG, oldImage) + delivered.fulfill() + }) + operation.start() + wait(for: [checked, delivered], timeout: 2) + XCTAssertEqual( + try cache.avatar(scope: scope, pubkey: message.pubkey)?.savedAt, + Date(timeIntervalSince1970: 100)) + } + + func testOlderOverlappingDownloadCannotReplaceNewerImage() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let message = try signedEvent(kind: 9, content: "Hello") + let profile = try signedEvent( + kind: 0, content: #"{"name":"Alice","picture":"https://images.example/avatar.png"}"#) + let community = PushLeaseCommunity( + id: "community", name: "Test", relayUrl: "wss://relay.example", + pubkey: message.pubkey, policies: [], notificationSessionID: "session") + let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community)) + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + try cache.save(event: profile, scope: scope) + let olderStarted = expectation(description: "Older download started") + let newerStarted = expectation(description: "Newer download started") + let olderFinished = expectation(description: "Older completion processed") + let newerFinished = expectation(description: "Newer completion processed") + var olderCallback: ((Result) -> Void)? + var newerCallback: ((Result) -> Void)? + func operation( + started: XCTestExpectation, finished: XCTestExpectation, + capture: @escaping (@escaping (Result) -> Void) -> Void + ) -> BuzzNotificationEnrichmentOperation { + BuzzNotificationEnrichmentOperation( + event: message, community: community, scope: scope, + cache: BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }), + avatarLoader: nil, now: Date.init, isCurrent: { true }, + loadAvatar: { _, _, callback in + capture(callback) + started.fulfill() + }, + scheduleDeadline: { _, _ in }, + refresh: { _, _, _ in XCTFail("Profile is fresh") }, + completion: { _ in finished.fulfill() }) + } + let older = operation(started: olderStarted, finished: olderFinished) { olderCallback = $0 } + older.start() + wait(for: [olderStarted], timeout: 2) + let newer = operation(started: newerStarted, finished: newerFinished) { newerCallback = $0 } + newer.start() + wait(for: [newerStarted], timeout: 2) + try XCTUnwrap(newerCallback)(.success(Data([2]))) + wait(for: [newerFinished], timeout: 2) + let saved = try XCTUnwrap(cache.avatar(scope: scope, pubkey: message.pubkey)) + try XCTUnwrap(olderCallback)(.success(Data([1]))) + wait(for: [olderFinished], timeout: 2) + XCTAssertEqual(try cache.avatar(scope: scope, pubkey: message.pubkey), saved) + } + + func testSlowChannelDoesNotBlockProfileAndAvatarAtDeadline() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let message = try signedEvent(kind: 9, content: "Hello", tags: [["h", "room"]]) + let profile = try signedEvent( + kind: 0, + content: #"{"name":"Alice","picture":"https://images.example/avatar.png"}"#) + let community = PushLeaseCommunity( + id: "community", name: "Test", + relayUrl: "wss://relay.example", pubkey: message.pubkey, policies: [], + notificationSessionID: "session") + let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community)) + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + let delivered = expectation(description: "Available profile and avatar at deadline") + let image = Data([1, 2, 3]) + let operation = BuzzNotificationEnrichmentOperation( + event: message, community: community, scope: scope, cache: cache, avatarLoader: nil, + now: Date.init, isCurrent: { true }, + loadAvatar: { _, _, completion in completion(.success(image)) }, + refresh: { profileNeeded, channelNeeded, completion in + XCTAssertTrue(profileNeeded) + XCTAssertTrue(channelNeeded) + completion(.profile(profile)) + // The channel response remains pending beyond the delivery deadline. + }, + completion: { resolution in + XCTAssertEqual(resolution?.title, "Alice") + XCTAssertEqual(resolution?.senderAvatarPNG, image) + delivered.fulfill() + }) + operation.start() + wait(for: [delivered], timeout: 3) + } + + private func signedEvent(kind: Int, content: String, tags: [[String]] = []) throws + -> VerifiedNostrEvent + { + let key = try P256K.Schnorr.PrivateKey( + dataRepresentation: Array(repeating: UInt8(1), count: 32)) + let pubkey = VerifiedNostrEvent.hex(key.xonly.bytes) + let serialization = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, createdAt: 1, kind: kind, tags: tags, content: content) + let digest = Array(SHA256.hash(data: serialization)) + var message = digest + var randomness = Array(repeating: UInt8(2), count: 32) + let signature = try key.signature(message: &message, auxiliaryRand: &randomness) + return VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), pubkey: pubkey, + createdAt: 1, kind: kind, tags: tags, content: content, + sig: VerifiedNostrEvent.hex(signature.dataRepresentation)) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationSVGTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationSVGTests.swift new file mode 100644 index 0000000000..12eb22218e --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationSVGTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import BuzzPushKit + +@Suite("Bounded native SVG avatars") +struct BuzzNotificationSVGTests { + @Test func rendersBasicSVG() throws { + let svg = + ##""## + let png = try BuzzNotificationAvatarLoader.decode(Data(svg.utf8)) + #expect(png.starts(with: [137, 80, 78, 71])) + } + + @Test func rejectsReferenceCyclesAndEntities() throws { + for svg in [ + ##""##, + ##"]>&x;"##, + ##""##, + ] { + #expect(throws: BuzzNotificationCacheError.self) { + try BuzzNotificationSVG.validate(Data(svg.utf8)) + } + } + } + + @Test func rejectsDeepDocuments() { + let svg = + "" + String(repeating: "", count: 33) + String(repeating: "", count: 33) + + "" + #expect(throws: BuzzNotificationCacheError.self) { + try BuzzNotificationSVG.validate(Data(svg.utf8)) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift index c79147dbea..9d0f817d41 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift @@ -99,7 +99,7 @@ extension BuzzPushNotificationResolverTests { XCTAssertEqual(result.subtitle, "Community") } - func testFreshEvictedMembershipDigestsTriggerOneBoundedRefresh() throws { + func testPartialMembershipRefreshDoesNotReuseCachedChannel() throws { let message = try Self.signedEvent( privateKey: Self.profilePrivateKey, createdAt: Self.now, @@ -142,7 +142,7 @@ extension BuzzPushNotificationResolverTests { if URLProtocolStub.requests.count == 1 { return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) } - XCTAssertEqual(request.timeoutInterval, 3) + XCTAssertEqual(request.timeoutInterval, 60) return Self.response( request, status: 200, @@ -160,9 +160,10 @@ extension BuzzPushNotificationResolverTests { ) ) - XCTAssertEqual(result.conversationDisplayName, "#General") - XCTAssertEqual(result.conversationRecipientCount, 1) - XCTAssertEqual(URLProtocolStub.requests.count, 2) + // Only membership was returned. Do not pair it with the cached channel. + XCTAssertNil(result.conversationDisplayName) + XCTAssertNil(result.conversationRecipientCount) + XCTAssertEqual(URLProtocolStub.requests.count, 4) } func testTwoPersonDMUsesDirectCommunicationPresentation() throws { diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 8d8de632be..5748537e1e 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -58,6 +58,77 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertTrue(URLProtocolStub.requests.isEmpty) } + func testInitialAccountFanoutReadsSnapshotOnce() throws { + let communities = (0..<64).map { community(id: "community-\($0)") } + let snapshot = try snapshotData(communities) + let lock = NSLock() + var reads = 0 + let started = expectation(description: "All community requests started") + started.expectedFulfillmentCount = communities.count + let finished = expectation(description: "Cancelled requests complete resolution") + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + let session = URLSession(configuration: configuration) + URLProtocolStub.handler = { request in + started.fulfill() + return Self.response(request, status: 200, data: Data("[]".utf8)) + } + URLProtocolStub.finishLoading = { _ in false } + let resolver = BuzzPushNotificationResolver(session: session, + loadCommunitiesData: { + lock.lock() + reads += 1 + lock.unlock() + return snapshot + }, loadPrivateKey: { _ in Self.privateKey }) + resolver.resolve { result in + XCTAssertNil(result) + finished.fulfill() + } + wait(for: [started], timeout: 3) + lock.lock() + let initialReads = reads + lock.unlock() + XCTAssertEqual(initialReads, 1) + session.invalidateAndCancel() + wait(for: [finished], timeout: 3) + } + + func testSnapshotAccountMustMatchKeychainBeforeAnyRequest() throws { + let configured = community(pubkey: try Self.pubkey(for: Self.profilePrivateKey)) + URLProtocolStub.handler = { request in + XCTFail("Mismatched account key must not issue a request") + return Self.response(request, status: 200, data: Data("[]".utf8)) + } + XCTAssertNil(resolve(makeResolver(communitiesData: try snapshotData([configured])))) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testKeyReplacementWhileRequestIsInFlightRejectsItsResult() throws { + let data = try snapshotData([community()]) + let lock = NSLock() + var key = Self.privateKey + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + let message = try Self.signedEvent(privateKey: Self.profilePrivateKey, + createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Old account result") + URLProtocolStub.handler = { request in + lock.lock() + key = Self.profilePrivateKey + lock.unlock() + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + let resolver = BuzzPushNotificationResolver( + session: URLSession(configuration: configuration), loadCommunitiesData: { data }, + loadPrivateKey: { _ in + lock.lock() + defer { lock.unlock() } + return key + }) + XCTAssertNil(resolve(resolver)) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + func testResolveReturnsNilForNon2xxRelayResponse() throws { URLProtocolStub.handler = { request in Self.response(request, status: 503, data: Data()) @@ -133,20 +204,13 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result?.0.body, "lower ID") } - func testDecodeResolutionSenderTitlesCanonicalizeKeysOrFallBackToNeutral() { - // The sender-identity presentation boundary: a verifiable key renders - // as the same compact npub whether it arrives as hex or as an npub, and - // an unverifiable key — including radix impostors like "+a"×32 that - // parse as hex pairs but are not literal hex keys — gets the neutral - // identity, never raw key material, while body, subtitle, and internal - // payload fields pass through untouched. Malformed-input classification - // itself is pinned at the codec (Bech32Tests); named senders winning - // over these labels is covered by the cached-profile resolve tests. + func testMissingSenderNameOmitsLabelWithoutExposingKey() { + // Keep identity in the routing payload, but do not use it as a display name. let senders: [(pubkey: String, title: String)] = [ - (Self.unnamedSenderHex, "npub14f8…9nsy"), - (Self.unnamedSenderNpub, "npub14f8…9nsy"), - ("author-pubkey", "Someone"), - (String(repeating: "+a", count: 32), "Someone"), + (Self.unnamedSenderHex, ""), + (Self.unnamedSenderNpub, ""), + ("author-pubkey", ""), + (String(repeating: "+a", count: 32), ""), ] for sender in senders { let result = BuzzPushNotificationResolver.decodeResolution( @@ -210,7 +274,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertNotEqual(result.title, Self.gatewayBody) XCTAssertNotEqual(result.body, Self.gatewayBody) - XCTAssertEqual(result.title, "npub1ccz…mnyd") + XCTAssertEqual(result.title, "") XCTAssertEqual(result.body, "Hello Buzz") XCTAssertEqual(result.subtitle, "Community") XCTAssertEqual( @@ -295,7 +359,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(URLProtocolStub.requests.count, 1) } - func testStaleVerifiedCacheIsUsedWhileOneBoundedRefreshFails() throws { + func testStaleVerifiedCacheIsUsedWhileBoundedRefreshesFail() throws { let message = try Self.signedEvent( privateKey: Self.profilePrivateKey, createdAt: Self.now, @@ -342,7 +406,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { if URLProtocolStub.requests.count == 1 { return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) } - XCTAssertEqual(request.timeoutInterval, 3) + XCTAssertEqual(request.timeoutInterval, 60) return Self.response(request, status: 503, data: Data()) } @@ -359,7 +423,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result.title, "Stale Alice") XCTAssertEqual(result.conversationDisplayName, "#Stale General") XCTAssertEqual(result.conversationRecipientCount, 1) - XCTAssertEqual(URLProtocolStub.requests.count, 2) + XCTAssertEqual(URLProtocolStub.requests.count, 4) } func testOlderVerifiedRefreshCannotReplaceNewerStaleCache() throws { @@ -519,7 +583,27 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result.conversationDisplayName, "#Stale General") } - func testMissingCacheRefreshesVerifiedProfileAndChannelTogether() throws { + func testLegacyAccountSnapshotEnrichesBeforeFirstAppLaunchAfterUpgrade() throws { + let message = try Self.signedEvent(privateKey: Self.profilePrivateKey, + createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Hello before app launch") + let profile = try Self.signedEvent(privateKey: Self.profilePrivateKey, + createdAt: Self.now, kind: 0, content: #"{"name":"Alice"}"#) + var legacy = community() + legacy.notificationSessionID = nil + URLProtocolStub.handler = { request in + Self.response(request, status: 200, + data: try JSONEncoder().encode(URLProtocolStub.requests.count == 1 ? [message] : [profile])) + } + let result = try XCTUnwrap(resolve(makeResolver(communitiesData: try snapshotData([legacy])))) + XCTAssertEqual(result.title, "Alice") + XCTAssertEqual(result.body, "Hello before app launch") + XCTAssertNotNil(result.notificationScope) + var newSignIn = legacy + newSignIn.notificationSessionID = UUID().uuidString + XCTAssertNotEqual(result.notificationScope, BuzzNotificationCacheScope(community: newSignIn)) + } + + func testMissingCacheRefreshesVerifiedProfileAndChannelIndependently() throws { let message = try Self.signedEvent( privateKey: Self.profilePrivateKey, createdAt: Self.now, @@ -575,7 +659,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result.conversationDisplayName, "#Fresh General") XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertNil(result.senderAvatarPNG) - XCTAssertEqual(URLProtocolStub.requests.count, 2) + XCTAssertEqual(URLProtocolStub.requests.count, 4) } func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws { @@ -623,13 +707,19 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) ) - XCTAssertEqual(result.title, "npub1ccz…mnyd") + XCTAssertEqual(result.title, "") XCTAssertNil(result.conversationDisplayName) XCTAssertEqual(result.subtitle, "Community") XCTAssertEqual(result.body, "Fallback content") } func testOversizedPresentationRefreshFallsBackWithoutBlockingMessage() throws { + let cancelled = expectation(description: "Oversized streaming responses are cancelled") + cancelled.expectedFulfillmentCount = 3 + URLProtocolStub.finishLoading = { _ in URLProtocolStub.requests.count == 1 } + URLProtocolStub.onStopLoading = { _ in + if URLProtocolStub.requests.count > 1 { cancelled.fulfill() } + } let message = try Self.signedEvent( privateKey: Self.profilePrivateKey, createdAt: Self.now, @@ -666,10 +756,60 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) ) - XCTAssertEqual(result.title, "npub1ccz…mnyd") + XCTAssertEqual(result.title, "") XCTAssertNil(result.conversationDisplayName) XCTAssertEqual(result.body, "Bounded fallback") - XCTAssertEqual(URLProtocolStub.requests.count, 2) + XCTAssertEqual(URLProtocolStub.requests.count, 4) + wait(for: [cancelled], timeout: 1) + } + + func testLargeRosterCannotDiscardSenderProfileOrChannelMetadata() throws { + let message = try Self.signedEvent(privateKey: Self.profilePrivateKey, + createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Large channel") + let profile = try Self.signedEvent(privateKey: Self.profilePrivateKey, + createdAt: Self.now, kind: 0, content: #"{"display_name":"Fizz"}"#) + let members = try Self.signedEvent(privateKey: Self.relayPrivateKey, + createdAt: Self.now, kind: 39002, + tags: [["d", Self.channelID]] + (0..<30000).map { ["p", String(format: "%064x", $0)] }) + let channel = try Self.signedEvent(privateKey: Self.relayPrivateKey, + createdAt: Self.now, kind: 39000, + tags: [["d", Self.channelID], ["name", "Large Channel"], ["t", "stream"]]) + let roster = try JSONEncoder().encode([profile, channel, members]) + XCTAssertGreaterThan(roster.count, BuzzPushNotificationResolver.maximumPresentationResponseBytes) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + var body = request.httpBody ?? Data() + if let stream = request.httpBodyStream { + stream.open() + defer { stream.close() } + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { break } + body.append(contentsOf: buffer.prefix(count)) + } + } + let filters = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [[String: Any]]) + let kinds = filters.flatMap { $0["kinds"] as? [Int] ?? [] } + return Self.response(request, status: 200, + data: kinds.contains(39002) ? roster : try JSONEncoder().encode(kinds.contains(0) ? [profile] : [channel])) + } + let configured = community(relayMetadataPubkey: members.pubkey) + let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: configured)) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope }) + let result = try XCTUnwrap(resolve(makeResolver( + communitiesData: try snapshotData([configured]), cacheOverride: cache, + now: Date(timeIntervalSince1970: TimeInterval(Self.now))))) + XCTAssertEqual(result.title, "Fizz") + XCTAssertEqual(result.body, "Large channel") + XCTAssertEqual(try cache.metadata(scope: scope, kind: 39000, + author: channel.pubkey, identifier: Self.channelID)?.event, channel) + // Group specialization still requires a verified complete recipient count. + XCTAssertNil(result.conversationRecipientCount) } func testBoundedInlineAvatarProfileRefreshStillResolvesDisplayName() throws { @@ -680,7 +820,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { tags: [["h", Self.channelID]], content: "Inline avatar profile" ) - let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000) + let picture = "data:image/png;base64," + String(repeating: "A", count: 262_050) let profileContent = try XCTUnwrap( String( data: JSONSerialization.data(withJSONObject: [ @@ -696,7 +836,19 @@ final class BuzzPushNotificationResolverTests: XCTestCase { kind: 0, content: profileContent ) - let presentationData = try JSONEncoder().encode([profile]) + let channel = try Self.signedEvent( + privateKey: Self.relayPrivateKey, createdAt: Self.now, kind: 39000, + tags: [["d", Self.channelID], ["name", "Large Channel"], ["t", "stream"]], + content: String(repeating: "\"", count: 256 * 1024) + ) + let membership = try Self.signedEvent( + privateKey: Self.relayPrivateKey, createdAt: Self.now, kind: 39002, + tags: [["d", Self.channelID], ["p", Self.ownPubkey]], + content: String(repeating: "\"", count: 256 * 1024) + ) + let presentationData = try JSONEncoder().encode([profile, channel, membership]) + XCTAssertLessThanOrEqual(profileContent.utf8.count, 256 * 1024) + XCTAssertGreaterThan(presentationData.count, 256 * 1024) XCTAssertLessThan( presentationData.count, BuzzPushNotificationResolver.maximumPresentationResponseBytes @@ -713,16 +865,18 @@ final class BuzzPushNotificationResolverTests: XCTestCase { let result = try XCTUnwrap( resolve( makeResolver( - communitiesData: try snapshotData([community()]), + communitiesData: try snapshotData([community(relayMetadataPubkey: channel.pubkey)]), now: Date(timeIntervalSince1970: TimeInterval(Self.now)) ) ) ) XCTAssertEqual(result.title, "Fizz") + XCTAssertEqual(result.conversationDisplayName, "#Large Channel") + XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertNil(result.senderAvatarPNG) XCTAssertEqual(result.body, "Inline avatar profile") - XCTAssertEqual(URLProtocolStub.requests.count, 2) + XCTAssertEqual(URLProtocolStub.requests.count, 4) } func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws { @@ -744,6 +898,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { communitiesData: Data?, privateKeys: [String: String] = ["community-id": privateKey], presentationCacheData: Data? = nil, + cacheOverride: BuzzNotificationCache? = nil, now: Date = Date() ) -> BuzzPushNotificationResolver { let configuration = URLSessionConfiguration.ephemeral @@ -752,11 +907,60 @@ final class BuzzPushNotificationResolverTests: XCTestCase { session: URLSession(configuration: configuration), loadCommunitiesData: { communitiesData }, loadPrivateKey: { privateKeys[$0] }, - loadPresentationCacheData: { presentationCacheData }, + notificationCache: cacheOverride ?? makeNotificationCache(communitiesData: communitiesData, fixture: presentationCacheData, now: now), now: { now } ) } + private func makeNotificationCache(communitiesData: Data?, fixture: Data?, now: Date) -> BuzzNotificationCache { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let communities = communitiesData.flatMap { try? JSONDecoder().decode(PushLeaseSnapshot.self, from: $0) }?.communities ?? [] + let scopes = communities.compactMap(BuzzNotificationCacheScope.init) + let cache = BuzzNotificationCache(directory: root, now: { now }, isCurrent: { scopes.contains($0) }) + let snapshot = BuzzPushPresentationCacheSnapshot.decode(fixture) + do { + for profile in snapshot.profiles { + let scope = try XCTUnwrap(scopes.first { $0.communityID == profile.communityID }) + var content = ["display_name": profile.displayName ?? ""] + if profile.avatarPNG != nil { content["picture"] = "https://images.example/avatar.png" } + let event = try Self.signedEvent(privateKey: Self.profilePrivateKey, + createdAt: profile.eventCreatedAt, kind: 0, + content: String(decoding: JSONEncoder().encode(content), as: UTF8.self)) + let writer = BuzzNotificationCache(directory: root, + now: { Date(timeIntervalSince1970: Double(profile.cachedAt)) }, isCurrent: { scopes.contains($0) }) + try writer.save(event: event, scope: scope) + if let image = profile.avatarPNG { + let requestID = try XCTUnwrap(writer.beginAvatarRequest( + profileID: event.id, pubkey: event.pubkey, scope: scope)) + try writer.saveAvatar(image, source: "https://images.example/avatar.png", + profileID: event.id, requestID: requestID, pubkey: event.pubkey, scope: scope) + } + } + for channel in snapshot.channels { + let scope = try XCTUnwrap(scopes.first { $0.communityID == channel.communityID }) + let event = try Self.signedEvent(privateKey: Self.relayPrivateKey, + createdAt: channel.eventCreatedAt, kind: 39000, + tags: [["d", channel.channelID], ["name", channel.displayName ?? ""], ["t", channel.channelType ?? "stream"]]) + let writer = BuzzNotificationCache(directory: root, + now: { Date(timeIntervalSince1970: Double(channel.cachedAt)) }, isCurrent: { scopes.contains($0) }) + try writer.save(event: event, scope: scope) + guard let digests = channel.memberDigests else { continue } + let candidates = [Self.ownPubkey, try Self.pubkey(for: Self.profilePrivateKey), try Self.pubkey(for: Self.relayPrivateKey)] + let members = candidates.filter { candidate in + Self.memberDigests([candidate]).contains { digests.contains($0) } + } + let membership = try Self.signedEvent(privateKey: Self.relayPrivateKey, + createdAt: channel.membershipEventCreatedAt ?? channel.eventCreatedAt, kind: 39002, + tags: [["d", channel.channelID]] + members.map { ["p", $0] }) + let membershipWriter = BuzzNotificationCache(directory: root, + now: { Date(timeIntervalSince1970: Double(channel.membershipCachedAt ?? channel.cachedAt)) }, isCurrent: { scopes.contains($0) }) + try membershipWriter.save(event: membership, scope: scope) + } + } catch { XCTFail("Cannot seed verified cache: \(error)") } + return cache + } + func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? { let completed = expectation(description: "resolver completed") var result: BuzzPushResolution? @@ -788,7 +992,8 @@ final class BuzzPushNotificationResolverTests: XCTestCase { hTags: [Self.channelID] ) ) - ] + ], + notificationSessionID: "test-session" ) } @@ -884,6 +1089,8 @@ final class BuzzPushNotificationResolverTests: XCTestCase { final class URLProtocolStub: URLProtocol, @unchecked Sendable { static let lock = NSLock() static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var finishLoading: ((URLRequest) -> Bool)? + static var onStopLoading: ((URLRequest) -> Void)? static var requests: [URLRequest] = [] override class func canInit(with request: URLRequest) -> Bool { true } @@ -898,17 +1105,21 @@ final class BuzzPushNotificationResolverTests: XCTestCase { let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }() client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) + if Self.finishLoading?(request) ?? true { + client?.urlProtocolDidFinishLoading(self) + } } catch { client?.urlProtocol(self, didFailWithError: error) } } - override func stopLoading() {} + override func stopLoading() { Self.onStopLoading?(request) } static func reset() { lock.lock() handler = nil + finishLoading = nil + onStopLoading = nil requests = [] lock.unlock() } diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift index 096e77382e..4093736338 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -675,8 +675,8 @@ struct BuzzPushPresentationCacheTests { #expect(snapshot.channels.count == 1) } - @Test("Community removal prunes profile and channel state") - func communityPruning() throws { + @Test("Account sync discards old presentation exports even for a retained account") + func discardLegacyExports() throws { let directory = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } let store = BuzzPushPresentationCacheStore(containerURL: directory) @@ -687,12 +687,12 @@ struct BuzzPushPresentationCacheTests { tags: [["d", "opaque"], ["name", "General"]] ) try store.updateProfiles( - communityID: "removed", + communityID: "retained", relayOrigin: "https://relay.example", updates: [BuzzPushProfileCacheUpdate(event: profile)] ) try store.updateChannels( - communityID: "removed", + communityID: "retained", relayOrigin: "https://relay.example", relayMetadataPubkey: try pubkey(for: relayKey), metadataEvents: [channel], diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift index 9cffaa5cd2..cceeade115 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift @@ -28,11 +28,16 @@ final class NostrHTTPAuthTests: XCTestCase { XCTAssertEqual(event.createdAt, 1_700_000_000) XCTAssertEqual(event.kind, 27235) XCTAssertEqual(event.content, "") - XCTAssertEqual(event.tags, [ + XCTAssertEqual(Array(event.tags.prefix(3)), [ ["u", "https://relay.example/query"], ["method", "POST"], ["payload", SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()], ]) + XCTAssertEqual(event.tags.count, 4) + let nonce = try XCTUnwrap(event.tags.last) + XCTAssertEqual(nonce.first, "nonce") + XCTAssertEqual(nonce.count, 2) + XCTAssertNotNil(UUID(uuidString: nonce[1])) XCTAssertTrue(event.hasValidIDAndSignature()) } @@ -44,6 +49,15 @@ final class NostrHTTPAuthTests: XCTestCase { XCTAssertFalse(copy(event, content: "tampered").hasValidIDAndSignature()) } + func testSeparateRequestsInTheSameSecondHaveDistinctEventIDs() throws { + let first = try makeEvent() + let second = try makeEvent() + XCTAssertTrue(first.hasValidIDAndSignature()) + XCTAssertTrue(second.hasValidIDAndSignature()) + XCTAssertNotEqual(first.id, second.id, + "The relay rejects a second request with the same authentication event ID as a replay") + } + private func makeEvent() throws -> VerifiedNostrEvent { let header = try NostrHTTPAuth.authorizationHeader( url: URL(string: "https://relay.example/query")!, diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index d2cf00a25e..0fffa19540 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -6,6 +6,7 @@ import UserNotifications final class NotificationService: UNNotificationServiceExtension { private var handoff: BuzzNotificationHandoff? + private var resolvedScope: BuzzNotificationCacheScope? private var bestAttemptContent: UNMutableNotificationContent? private var restrictedFallbackContent: UNMutableNotificationContent? private lazy var interactionCleanup = BuzzInteractionCleanupRetry( @@ -49,16 +50,56 @@ final class NotificationService: UNNotificationServiceExtension { keychainAccessGroup: keychainAccessGroup ) }, - loadPresentationCacheData: { [self] in - Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier) - } + notificationCache: notificationCache, + avatarLoader: notificationAvatarLoader ) }() + private var notificationCacheRoot: URL? { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent("notification-enrichment-v1", isDirectory: true) + } + + private lazy var notificationCache: BuzzNotificationCache? = { + guard let directory = notificationCacheRoot else { return nil } + let cache = BuzzNotificationCache(directory: directory, isCurrent: { [weak self] scope in + self?.isCurrent(scope) == true + }) + let scopes = Self.communities(appGroupIdentifier: appGroupIdentifier) + .compactMap(BuzzNotificationCacheScope.init(community:)) + do { try cache.removeRevokedScopes(keeping: scopes) } + catch { NSLog("Notification cache cleanup failed: %@", String(describing: error)) } + return cache + }() + + private lazy var notificationAvatarLoader: BuzzNotificationAvatarLoader? = { + guard let directory = notificationCacheRoot else { return nil } + let accessGroup = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + return BuzzNotificationAvatarLoader(directory: directory, + privateKey: { Self.loadPrivateKey(communityID: $0, keychainAccessGroup: accessGroup) }, + isCurrent: { [weak self] in self?.isCurrent($0) == true }) + }() + + private func isCurrent(_ scope: BuzzNotificationCacheScope) -> Bool { + let accessGroup = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + return Self.communities(appGroupIdentifier: appGroupIdentifier).contains { + BuzzNotificationCacheScope(community: $0) == scope + } && scope.matchesAccountKey( + Self.loadPrivateKey(communityID: scope.communityID, keychainAccessGroup: accessGroup)) + } + + private static func communities(appGroupIdentifier: String?) -> [PushLeaseCommunity] { + guard let data = loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier), + let snapshot = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data) + else { return [] } + return snapshot.communities + } + override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { + resolvedScope = nil handoff = BuzzNotificationHandoff(handler: contentHandler) restrictedFallbackContent = Self.restrictedFallback(from: request.content) guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { @@ -73,6 +114,11 @@ final class NotificationService: UNNotificationServiceExtension { resolver.resolve { [weak self] resolution in guard let self else { return } if let resolution { + guard let scope = resolution.notificationScope, self.isCurrent(scope) else { + self.finish(Self.restrictedFallback(from: content)) + return + } + self.resolvedScope = scope content.title = resolution.title content.body = resolution.body if let subtitle = resolution.subtitle { @@ -91,7 +137,8 @@ final class NotificationService: UNNotificationServiceExtension { ordinaryContent: content, resolution: resolution, isStillAllowed: { [weak self] in - !(self?.isAgeRestricted() ?? false) + guard let self else { return false } + return self.isCurrent(scope) && !self.isAgeRestricted() }, onDeletionFailure: { error in NSLog("Notification cleanup retry failed: %@", String(describing: error)) @@ -113,7 +160,8 @@ final class NotificationService: UNNotificationServiceExtension { private func finish(_ content: UNNotificationContent) { handoff?.finish( - content, + resolvedScope.map { isCurrent($0) } == false + ? Self.restrictedFallback(from: content) : content, restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content), handoffIfAllowed: { deliver in BuzzAgeRestrictionSession.handoffIfAllowed( diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 320fe8c569..af6b7429b6 100644 --- a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "08a49d61c7de953c8fb77e34cc8578189c85a707e518c346697669ad28235ec0", + "originHash" : "62fc6df0fb0a452720ac04b0bfacf7c5dbe509dd06046cc6c44487a812f0502e", "pins" : [ { "identity" : "swift-secp256k1", @@ -9,6 +9,15 @@ "revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2", "version" : "0.21.1" } + }, + { + "identity" : "swiftdraw", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swhitty/SwiftDraw.git", + "state" : { + "revision" : "82699f4bdf2f075793cfa0d392eb27c59b41a3fe", + "version" : "0.29.0" + } } ], "version" : 3 diff --git a/mobile/ios/Runner/PushSnapshotBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift index e6598ae3f0..721fde6d21 100644 --- a/mobile/ios/Runner/PushSnapshotBridge.swift +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -83,9 +83,6 @@ final class BuzzPushSnapshotBridge { requiresStore: strictAgeGateWrite, result: result ) - case "profiles": cacheProfiles(arguments, result: result) - case "channels": cacheChannels(arguments, result: result) - case "avatar": cacheAvatar(arguments, result: result) default: return false } return true @@ -233,150 +230,6 @@ final class BuzzPushSnapshotBridge { }?.relayMetadataPubkey } - private func cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) { - guard let arguments = rawArguments as? [String: Any], - let communityID = arguments["communityId"] as? String, - let rawEvents = arguments["events"] as? [[String: Any]], - rawEvents.count <= BuzzPushPresentationCacheStore.maximumProfiles - else { - result( - FlutterError( - code: "invalid_arguments", - message: "Expected communityId and profile events.", - details: nil - ) - ) - return - } - queue.async { [weak self] in - do { - guard let self, let community = community(id: communityID) else { - Self.complete(result, value: nil) - return - } - let events = try decodeEvents(rawEvents) - try store?.updateProfiles( - communityID: communityID, - relayOrigin: community.relayUrl, - updates: events.map { BuzzPushProfileCacheUpdate(event: $0) } - ) - Self.complete(result, value: nil) - } catch { - Self.complete( - result, - value: FlutterError( - code: "profile_cache_failed", - message: "Unable to cache push sender profiles.", - details: error.localizedDescription - ) - ) - } - } - } - - private func cacheChannels(_ rawArguments: Any?, result: @escaping FlutterResult) { - guard let arguments = rawArguments as? [String: Any], - let communityID = arguments["communityId"] as? String, - let rawMetadataEvents = arguments["metadataEvents"] as? [[String: Any]], - let rawMembershipEvents = arguments["membershipEvents"] as? [[String: Any]], - rawMetadataEvents.count <= BuzzPushPresentationCacheStore.maximumChannels, - rawMembershipEvents.count <= BuzzPushPresentationCacheStore.maximumChannels - else { - result( - FlutterError( - code: "invalid_arguments", - message: "Expected communityId, channel metadata, and membership events.", - details: nil - ) - ) - return - } - queue.async { [weak self] in - do { - guard let self, let community = community(id: communityID), - let relayMetadataPubkey = community.relayMetadataPubkey - else { - Self.complete(result, value: nil) - return - } - try store?.updateChannels( - communityID: communityID, - relayOrigin: community.relayUrl, - relayMetadataPubkey: relayMetadataPubkey, - metadataEvents: try decodeEvents(rawMetadataEvents), - membershipEvents: try decodeEvents(rawMembershipEvents) - ) - Self.complete(result, value: nil) - } catch { - Self.complete( - result, - value: FlutterError( - code: "channel_cache_failed", - message: "Unable to cache push channel metadata.", - details: error.localizedDescription - ) - ) - } - } - } - - private func cacheAvatar(_ rawArguments: Any?, result: @escaping FlutterResult) { - guard let arguments = rawArguments as? [String: Any], - let communityID = arguments["communityId"] as? String, - let sourceURL = arguments["sourceUrl"] as? String, - let avatar = arguments["png"] as? FlutterStandardTypedData - else { - result( - FlutterError( - code: "invalid_arguments", - message: "Expected an avatar source and PNG thumbnail.", - details: nil - ) - ) - return - } - let avatarData = avatar.data - queue.async { [weak self] in - do { - guard let self, let community = community(id: communityID) else { - Self.complete(result, value: false) - return - } - let updated = - try store?.updateAvatar( - communityID: communityID, - relayOrigin: community.relayUrl, - sourceURL: sourceURL, - avatarPNG: avatarData - ) ?? false - Self.complete(result, value: updated) - } catch { - Self.complete( - result, - value: FlutterError( - code: "avatar_cache_failed", - message: "Unable to cache a push sender avatar.", - details: error.localizedDescription - ) - ) - } - } - } - - private func community(id: String) -> PushLeaseCommunity? { - guard let container = containerURL(), - let data = try? Data( - contentsOf: container.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)), - let snapshot = try? JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data) - else { return nil } - return snapshot.communities.first { $0.id == id } - } - - private func decodeEvents(_ rawEvents: [[String: Any]]) throws -> [VerifiedNostrEvent] { - let data = try JSONSerialization.data(withJSONObject: rawEvents) - return try JSONDecoder().decode([VerifiedNostrEvent].self, from: data) - } - private static func complete(_ result: @escaping FlutterResult, value: Any?) { DispatchQueue.main.async { result(value) diff --git a/mobile/lib/features/channels/channel_push_cache.dart b/mobile/lib/features/channels/channel_push_cache.dart deleted file mode 100644 index 4576f1b3d9..0000000000 --- a/mobile/lib/features/channels/channel_push_cache.dart +++ /dev/null @@ -1,106 +0,0 @@ -part of 'channels_provider.dart'; - -extension _ChannelPushCache on ChannelsNotifier { - Future _exportPushCache( - String? communityID, - List metadata, - List membership, - ) async { - if (defaultTargetPlatform != TargetPlatform.iOS || communityID == null) { - return; - } - if (_pushCacheExporting) { - // A refresh can contain different channels. Do not replace its predecessor - // or retain another raw batch: refetch the complete scope after it drains. - _pushCacheDirty = true; - return; - } - _pushCacheExporting = true; - try { - await _pushExport.export( - () => cacheBuzzPushChannelEvents(communityID, metadata, membership), - ); - // A failed operation is terminal only for its own input. A refresh that - // arrived meanwhile still owns an independent dirty obligation. - while (_lifecycleRef.mounted && _pushCacheDirty) { - _pushCacheDirty = false; - final session = _lifecycleRef.read(relaySessionProvider.notifier); - final community = _lifecycleRef.read(activeCommunityProvider).value?.id; - final relay = _lifecycleRef.read(relayConfigProvider).baseUrl; - final pubkey = _lifecycleRef.read(myPubkeyProvider); - if (community == null || pubkey == null) return; - bool current() => - _lifecycleRef.mounted && - _lifecycleRef.read(activeCommunityProvider).value?.id == - community && - _lifecycleRef.read(relayConfigProvider).baseUrl == relay && - _lifecycleRef.read(myPubkeyProvider) == pubkey && - identical( - _lifecycleRef.read(relaySessionProvider.notifier), - session, - ); - - void ensureCurrent() { - if (!current()) throw const _StaleChannelRefresh(); - } - - // This fetch has no UI writes or channel-refresh generation changes. - // New ordinary refreshes can proceed and mark this snapshot dirty again. - // Start once. Admission retries await this same snapshot rather than - // repeating relay reads while the native export queue remains full. - final snapshot = () async { - try { - if (!current()) return null; - final members = await _fetchChannelMemberships( - session, - pubkey, - ensureCurrent: ensureCurrent, - ); - if (!current()) return null; - final ids = members - .map((event) => event.getTagValue('d')) - .whereType() - .toSet() - .toList(); - final memberMetadata = ids.isEmpty - ? const [] - : await session.fetchHistory(NostrFilters.channelMetadata(ids)); - if (!current()) return null; - final memberSnapshots = ids.isEmpty - ? const [] - : await session.fetchHistory( - NostrFilter( - kinds: const [39002], - tags: {'#d': ids}, - limit: ids.length, - ), - ); - if (!current()) return null; - return ( - metadata: memberMetadata, - membership: [...members, ...memberSnapshots], - ); - } catch (_) { - if (current()) rethrow; - // Retired fetches must neither write nor report an error in the - // replacement scope. Its ordinary refresh owns any new dirty work. - } - return null; - }(); - await _pushExport.export(() async { - final events = await snapshot; - if (events == null || !current()) return; - await cacheBuzzPushChannelEvents( - community, - events.metadata, - events.membership, - ); - }); - // Only new producer input can set dirty again; failure alone never - // schedules another attempt after recovery's bounded retries. - } - } finally { - _pushCacheExporting = false; - } - } -} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 0d72eba0b7..5daee4f50c 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -3,12 +3,8 @@ import 'dart:convert'; import 'dart:math'; import 'package:flutter/widgets.dart'; -import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/community/community_provider.dart'; -import '../../shared/push/push_presentation_cache.dart'; -import '../../shared/push/push_presentation_export_recovery.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme_provider.dart'; import '../../shared/utils/string_utils.dart'; @@ -24,7 +20,6 @@ import 'unread_badge/observed_unread_event.dart'; import 'unread_badge/should_notify_for_event.dart'; part 'channel_directory.dart'; -part 'channel_push_cache.dart'; part 'channel_member_snapshots.dart'; part 'channels_provider_lifecycle.dart'; @@ -44,10 +39,6 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// for any visible channel event kind. Chunks stay within the relay's explicit /// channel cap and incoming events bump `lastMessageAt` for their channel. class ChannelsNotifier extends AsyncNotifier> { - final _pushExport = PushPresentationExportRecovery(); - bool _pushCacheExporting = false; - bool _pushCacheDirty = false; - static const _backstopInterval = Duration(seconds: 60); final Map _liveSubscriptionsByChunk = {}; @@ -215,7 +206,6 @@ class ChannelsNotifier extends AsyncNotifier> { }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); - final communityID = ref.read(activeCommunityProvider).value?.id; _loadThreadInterestStores(myPk); final session = ref.read(relaySessionProvider.notifier); @@ -342,12 +332,6 @@ class ChannelsNotifier extends AsyncNotifier> { // Use the membership snapshots already fetched above for both Huddle // linkage validation and member-count hydration. if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); - unawaited( - _exportPushCache(communityID, dedupedMetas, [ - ...memberships, - ...memberEvents, - ]), - ); final memberCounts = _memberCountsByChannelId(memberEvents); for (var i = 0; i < channels.length; i++) { final count = memberCounts[channels[i].id]; diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index 4a034315d0..b548cc6b7e 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -2,9 +2,6 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../community/community_provider.dart'; -import '../push/push_presentation_cache.dart'; -import '../push/push_presentation_export_recovery.dart'; import '../relay/relay.dart'; import 'user_profile.dart'; import 'profile_event_parser.dart'; @@ -19,7 +16,6 @@ class UserCacheNotifier extends Notifier> { static const _maximumProfilesPerQuery = 1000; final Set _pending = {}; - final _pushExport = PushPresentationExportRecovery(); final Map _profileEventOrders = {}; int _generation = 0; bool _flushInFlight = false; @@ -131,7 +127,6 @@ class UserCacheNotifier extends Notifier> { final generation = _generation; var succeeded = false; try { - final communityID = ref.read(activeCommunityProvider).value?.id; final session = ref.read(relaySessionProvider.notifier); var remaining = pubkeys.length; for (final batch in _profileQueryBatches(pubkeys)) { @@ -143,17 +138,10 @@ class UserCacheNotifier extends Notifier> { if (!await _verifyAndMerge(events, generation)) return; remaining -= batch.length; if (remaining == 0) { - // All requested pages are ready. Native export does not gate readers. + // All requested pages are ready. succeeded = true; completer?.complete(true); } - if (communityID != null) { - // Drain each page before fetching another, retaining at most one raw - // response while later requests coalesce as pubkeys in _pending. - await _pushExport.export( - () => cacheBuzzPushProfileEvents(communityID, events), - ); - } } } catch (_) { // Silently fail — non-gating callers will just show pubkeys. diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart deleted file mode 100644 index 324752e56e..0000000000 --- a/mobile/lib/shared/push/push_presentation_cache.dart +++ /dev/null @@ -1,377 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:nostr/nostr.dart' as nostr; - -import '../relay/nostr_models.dart'; - -const _pushPresentationChannel = MethodChannel('buzz/push'); -const _maximumAvatarSourceBytes = 512 * 1024; -const _maximumAvatarPNGBytes = 64 * 1024; -Future _avatarEncodeTail = Future.value(); -Future _presentationExportTail = Future.value(); -const _maximumPresentationExports = 8; -// Match BuzzPushPresentationCacheStore's per-write admission limits. -const _maximumProfilesPerWrite = 256; -const _maximumChannelsPerWrite = 512; -int _outstandingPresentationExports = 0; - -/// Temporary admission failure; detached producers must retain and retry input. -class PushPresentationExportQueueFull extends StateError { - /// Creates a saturation error for the shared eight-export budget. - PushPresentationExportQueueFull() - : super('Push presentation export queue is full (8 outstanding exports)'); -} - -/// The latest best-effort App Group presentation-cache failure. -final pushPresentationCacheError = ValueNotifier(null); - -/// Waits out one export retry delay for the native writer and -/// [PushPresentationExportRecovery]. Tests replace it to skip real time. -Future Function(Duration delay) debugPushPresentationRetryWait = - Future.delayed; - -/// Revalidates a relay event before it crosses into the native cache writer. -bool isVerifiedPushPresentationEvent(NostrEvent event) { - try { - nostr.Event( - event.id, - event.pubkey, - event.createdAt, - event.kind, - event.tags, - event.content, - event.sig, - ); - return true; - } catch (_) { - return false; - } -} - -/// Exports raw verified kind-0 events. Native code verifies them again before storage. -/// Fails with [StateError] when eight exports are already outstanding. -/// Native persistence failures retry with bounded backoff, then propagate and -/// stop any remaining chunks. Other native errors propagate immediately. -Future cacheBuzzPushProfileEvents( - String communityID, - Iterable events, -) async { - if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { - return; - } - final batch = events.toList(growable: false); - if (batch.isEmpty) return; - await _serializePresentationExport(() async { - final verified = await compute( - _selectPushProfileEvents, - batch, - debugLabel: 'buzz-push-profile-cache', - ); - if (verified.isEmpty) return; - final retryBudget = _NativeWriteRetryBudget(); - for (final chunk in _boundedChunks(verified, _maximumProfilesPerWrite)) { - await _invokeVerifiedChunk({ - 'section': 'profiles', - 'communityId': communityID, - 'events': [for (final event in chunk) event.toJson()], - }, retryBudget); - } - }); -} - -/// Exports verified channel metadata and membership for native authority checks. -/// Fails with [StateError] when eight exports are already outstanding. -/// Native persistence failures retry with bounded backoff, then propagate and -/// stop any remaining chunks. Other native errors propagate immediately. -Future cacheBuzzPushChannelEvents( - String? communityID, - Iterable metadataEvents, - Iterable membershipEvents, -) async { - if (defaultTargetPlatform != TargetPlatform.iOS || - communityID == null || - communityID.isEmpty) { - return; - } - final batch = ( - metadata: metadataEvents.toList(growable: false), - membership: membershipEvents.toList(growable: false), - ); - if (batch.metadata.isEmpty && batch.membership.isEmpty) return; - await _serializePresentationExport(() async { - final verified = await compute( - _selectPushChannelBatch, - batch, - debugLabel: 'buzz-push-channel-cache', - ); - if (verified.metadata.isEmpty && verified.membership.isEmpty) return; - final metadata = { - for (final event in verified.metadata) event.getTagValue('d')!: event, - }; - final membership = { - for (final event in verified.membership) event.getTagValue('d')!: event, - }; - final channelIDs = {...metadata.keys, ...membership.keys}.toList(); - final retryBudget = _NativeWriteRetryBudget(); - // Keep each channel's metadata and membership in the same native write. - // All chunks retain this export's FIFO slot until handoff is complete. - for (final ids in _boundedChunks(channelIDs, _maximumChannelsPerWrite)) { - await _invokeVerifiedChunk({ - 'section': 'channels', - 'communityId': communityID, - 'metadataEvents': [ - for (final id in ids) - if (metadata[id] case final event?) event.toJson(), - ], - 'membershipEvents': [ - for (final id in ids) - if (membership[id] case final event?) event.toJson(), - ], - }, retryBudget); - } - }); -} - -Iterable> _boundedChunks(List values, int maximum) sync* { - for (var start = 0; start < values.length; start += maximum) { - final end = start + maximum; - yield values.sublist(start, end < values.length ? end : values.length); - } -} - -// Share one worker slot across profile/channel exports and retain FIFO native -// handoff, even across community changes. The two producers are coalesced profile -// fetches and channel refreshes. Eight outstanding batches allow a short burst -// while bounding retained batch count; individual batch sizes remain caller-owned. -// Saturation fails explicitly rather than acknowledging an export we cannot retain. -Future _serializePresentationExport( - Future Function() export, -) async { - if (_outstandingPresentationExports >= _maximumPresentationExports) { - throw PushPresentationExportQueueFull(); - } - _outstandingPresentationExports++; - final previous = _presentationExportTail; - final release = Completer(); - _presentationExportTail = release.future; - await previous; - try { - await export(); - } finally { - // Keep the queue usable while propagating a worker failure to its caller. - _outstandingPresentationExports--; - release.complete(); - } -} - -List _selectPushProfileEvents(List events) => - _newestVerifiedEvents( - events, - kind: 0, - scope: (event) => event.pubkey.toLowerCase(), - ).values.toList(); - -({List metadata, List membership}) -_selectPushChannelBatch( - ({List metadata, List membership}) batch, -) => selectPushChannelEvents(batch.metadata, batch.membership); - -/// Selects the newest paired verified channel metadata and membership events. -@visibleForTesting -({List metadata, List membership}) -selectPushChannelEvents( - Iterable metadataEvents, - Iterable membershipEvents, -) { - final verifiedMembershipByChannel = _newestVerifiedEvents( - membershipEvents, - kind: 39002, - scope: (event) => event.getTagValue('d'), - ); - final selectedChannelIDs = verifiedMembershipByChannel.keys.toSet(); - final verifiedMetadataByChannel = _newestVerifiedEvents( - metadataEvents, - kind: 39000, - scope: (event) => event.getTagValue('d'), - allowedScopes: selectedChannelIDs.isEmpty ? null : selectedChannelIDs, - ); - if (selectedChannelIDs.isEmpty) { - selectedChannelIDs.addAll(verifiedMetadataByChannel.keys); - } - final verifiedMetadata = [ - for (final entry in verifiedMetadataByChannel.entries) - if (selectedChannelIDs.contains(entry.key)) entry.value, - ]; - final verifiedMembership = [ - for (final entry in verifiedMembershipByChannel.entries) - if (selectedChannelIDs.contains(entry.key)) entry.value, - ]; - return (metadata: verifiedMetadata, membership: verifiedMembership); -} - -Map _newestVerifiedEvents( - Iterable events, { - required int kind, - required String? Function(NostrEvent event) scope, - Set? allowedScopes, -}) { - final selected = {}; - for (final event in events) { - if (event.kind != kind || !isVerifiedPushPresentationEvent(event)) continue; - final key = scope(event); - if (key == null || key.isEmpty) continue; - if (allowedScopes != null && !allowedScopes.contains(key)) continue; - final existing = selected[key]; - if (existing != null) { - if (_isNewerEvent(event, existing)) selected[key] = event; - continue; - } - selected[key] = event; - } - return selected; -} - -bool _isNewerEvent(NostrEvent candidate, NostrEvent existing) => - candidate.createdAt > existing.createdAt || - (candidate.createdAt == existing.createdAt && - candidate.id.compareTo(existing.id) < 0); - -/// Reuses bytes already fetched for a visible foreground avatar. -/// -/// This never starts network I/O. Oversized, malformed, or unsupported images -/// are ignored, and notification delivery remains independent of the cache. -Future cacheBuzzPushAvatarFromLoadedBytes( - String communityID, - String sourceURL, - Uint8List sourceBytes, -) async { - if (defaultTargetPlatform != TargetPlatform.iOS || - communityID.isEmpty || - sourceBytes.isEmpty || - sourceBytes.length > _maximumAvatarSourceBytes || - !isCacheablePushAvatarSource(sourceURL)) { - return; - } - final previous = _avatarEncodeTail; - final release = Completer(); - _avatarEncodeTail = release.future; - await previous; - try { - final png = await _boundedAvatarPNG(sourceBytes); - if (png == null) return; - await _invokeSnapshot({ - 'section': 'avatar', - 'communityId': communityID, - 'sourceUrl': sourceURL, - 'png': png, - }, bestEffort: true); - } finally { - release.complete(); - } -} - -// One bounded backoff budget for the entire export, not one per chunk. -class _NativeWriteRetryBudget { - static const _delays = [250, 500, 1000, 2000, 4000]; - int _used = 0; - - Duration? takeDelay() => - _used == _delays.length ? null : Duration(milliseconds: _delays[_used++]); -} - -// Retry only native persistence failures, retaining the verified payload and -// the surrounding export's FIFO slot. Never repeat relay reads or verification. -Future _invokeVerifiedChunk( - Map arguments, - _NativeWriteRetryBudget budget, -) async { - final retryableCode = arguments['section'] == 'profiles' - ? 'profile_cache_failed' - : 'channel_cache_failed'; - while (true) { - try { - await _invokeSnapshot(arguments); - return; - } on PlatformException catch (error) { - if (error.code != retryableCode) rethrow; - final delay = budget.takeDelay(); - if (delay == null) rethrow; - await debugPushPresentationRetryWait(delay); - } - } -} - -Future _invokeSnapshot( - Map arguments, { - bool bestEffort = false, -}) async { - try { - await _pushPresentationChannel.invokeMethod( - 'syncPushSnapshot', - arguments, - ); - pushPresentationCacheError.value = null; - } on MissingPluginException { - // Non-Runner embeddings do not provide the native snapshot bridge. - } catch (error, stackTrace) { - pushPresentationCacheError.value = error.toString(); - debugPrint('Push presentation cache update failed: $error'); - debugPrintStack(stackTrace: stackTrace); - // Event exports must stop at the failed chunk and report failure to their - // owner. Avatar updates preserve their existing detached best-effort policy. - if (!bestEffort) rethrow; - } -} - -@visibleForTesting -bool isCacheablePushAvatarSource(String value) { - final trimmed = value.trim(); - if (trimmed.startsWith('data:image/')) { - try { - final data = UriData.parse(trimmed); - return data.mimeType.startsWith('image/') && - data.mimeType != 'image/svg+xml' && - data.contentAsBytes().isNotEmpty; - } on FormatException { - return false; - } - } - final uri = Uri.tryParse(value.trim()); - return uri != null && - (uri.scheme == 'http' || uri.scheme == 'https') && - uri.host.isNotEmpty && - uri.userInfo.isEmpty; -} - -Future _boundedAvatarPNG(Uint8List sourceBytes) async { - for (final size in const [128, 96, 64, 48]) { - ui.Codec? codec; - ui.Image? image; - try { - codec = await ui.instantiateImageCodec( - sourceBytes, - targetWidth: size, - targetHeight: size, - allowUpscaling: false, - ); - final frame = await codec.getNextFrame(); - image = frame.image; - final data = await image.toByteData(format: ui.ImageByteFormat.png); - if (data == null) continue; - final png = data.buffer.asUint8List( - data.offsetInBytes, - data.lengthInBytes, - ); - if (png.isNotEmpty && png.length <= _maximumAvatarPNGBytes) return png; - } catch (_) { - return null; - } finally { - image?.dispose(); - codec?.dispose(); - } - } - return null; -} diff --git a/mobile/lib/shared/push/push_presentation_export_recovery.dart b/mobile/lib/shared/push/push_presentation_export_recovery.dart deleted file mode 100644 index 29d742099c..0000000000 --- a/mobile/lib/shared/push/push_presentation_export_recovery.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import 'push_presentation_cache.dart'; - -/// Latest terminal export failure, retained across unrelated successful writes. -/// -/// Unlike [pushPresentationCacheError], this records an export that could not -/// reach native storage. Retries are bounded and do not guarantee delivery. -final pushPresentationExportError = ValueNotifier(null); - -/// Retries one export while its producer applies backpressure to new input. -/// -/// Queue saturation retries the exact operation five times over 7.75 seconds. -/// Exhausted retries or a worker failure return false and record a terminal -/// error. Producers must serialize calls and retain dirty input upstream; this -/// helper does not queue, replace, or merge unverified event batches. -class PushPresentationExportRecovery { - /// Exports once, recovering transient saturation without detached errors. - Future export(Future Function() operation) async { - try { - await operation(); - return true; - } on PushPresentationExportQueueFull { - // The producer retains this operation until recovery completes. - } catch (error, stack) { - return _failed(error, stack); - } - - for (final milliseconds in [250, 500, 1000, 2000, 4000]) { - await debugPushPresentationRetryWait( - Duration(milliseconds: milliseconds), - ); - try { - await operation(); - return true; - } on PushPresentationExportQueueFull catch (error, stack) { - if (milliseconds == 4000) return _failed(error, stack); - } catch (error, stack) { - return _failed(error, stack); - } - } - return false; - } - - bool _failed(Object error, StackTrace stack) { - pushPresentationExportError.value = error.toString(); - debugPrint('Push presentation export could not be delivered: $error'); - debugPrintStack(stackTrace: stack); - return false; - } -} diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 8008f87f73..7c47ee879c 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -8,10 +8,8 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../animated_avatar.dart'; -import '../community/community_provider.dart'; import '../emoji/emoji_avatar.dart'; import '../emoji/native_emoji_glyph.dart'; -import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; part 'avatar_image_native.dart'; @@ -83,7 +81,6 @@ class AvatarImageContent extends ConsumerStatefulWidget { class _AvatarImageContentState extends ConsumerState { late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl); - String? _scheduledPushAvatar; @override void didUpdateWidget(AvatarImageContent oldWidget) { @@ -96,7 +93,6 @@ class _AvatarImageContentState extends ConsumerState { @override Widget build(BuildContext context) { final centeredFallback = Center(child: widget.fallback); - final communityID = ref.watch(activeCommunityProvider).value?.id; return switch (_source) { _EmojiAvatarSource(:final emoji, :final color) => ColoredBox( @@ -121,43 +117,19 @@ class _AvatarImageContentState extends ConsumerState { errorBuilder: (_, _, _) => centeredFallback, ), _RasterDataAvatarSource(:final bytes) => _rasterImage( - communityID: communityID, - sourceURL: widget.imageUrl, bytes: bytes, fallback: centeredFallback, ), _NetworkAvatarSource(:final url) => MediaImage( url: url, fit: widget.fit, - onBytesLoaded: communityID == null - ? null - : (bytes) => unawaited( - cacheBuzzPushAvatarFromLoadedBytes(communityID, url, bytes), - ), errorBuilder: (_, _, _) => centeredFallback, ), null => centeredFallback, }; } - Widget _rasterImage({ - required String? communityID, - required String? sourceURL, - required Uint8List bytes, - required Widget fallback, - }) { - if (communityID != null && sourceURL != null) { - final identity = '$communityID\u0000$sourceURL'; - if (_scheduledPushAvatar != identity) { - _scheduledPushAvatar = identity; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _scheduledPushAvatar != identity) return; - unawaited( - cacheBuzzPushAvatarFromLoadedBytes(communityID, sourceURL, bytes), - ); - }); - } - } + Widget _rasterImage({required Uint8List bytes, required Widget fallback}) { return Image.memory( bytes, fit: widget.fit, diff --git a/mobile/test/shared/profile/user_cache_batch_limit_test.dart b/mobile/test/shared/profile/user_cache_batch_limit_test.dart index 452a8df656..ef8c314ca4 100644 --- a/mobile/test/shared/profile/user_cache_batch_limit_test.dart +++ b/mobile/test/shared/profile/user_cache_batch_limit_test.dart @@ -5,35 +5,18 @@ import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - const bridge = MethodChannel('buzz/push'); - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; test( - 'pending profiles drain in bounded queries after delayed export', + 'pending profiles drain in bounded queries after a delayed relay query', () async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; final entered = Completer(); final release = Completer(); - messenger.setMockMethodCallHandler(bridge, (_) async { - if (!entered.isCompleted) { - entered.complete(); - await release.future; - } - return null; - }); - addTearDown(() { - messenger.setMockMethodCallHandler(bridge, null); - debugDefaultTargetPlatformOverride = null; - }); final signed = NostrEvent.fromJson( nostr.Event.from( kind: 0, @@ -43,7 +26,15 @@ void main() { secretKey: '1'.padLeft(64, '0'), ).toMap(), ); - final session = _ClampingSession(first: signed); + final session = _ClampingSession( + first: signed, + beforeFetch: (index) async { + if (index == 0) { + entered.complete(); + await release.future; + } + }, + ); final container = ProviderContainer( overrides: [ relaySessionProvider.overrideWith(() => session), @@ -60,7 +51,7 @@ void main() { addTearDown(container.dispose); await container.read(activeCommunityProvider.future); final cache = container.read(userCacheProvider.notifier); - expect(await cache.preload([signed.pubkey]), isTrue); + final first = cache.preload([signed.pubkey]); await entered.future.timeout(const Duration(seconds: 5)); final keys = List.generate(1001, (i) => 'profile-$i'); var completed = false; @@ -76,6 +67,7 @@ void main() { } finally { release.complete(); } + expect(await first, isTrue); expect(await pending, isTrue); expect(await duplicate, isTrue); expect(container.read(userCacheProvider).keys, containsAll(keys)); diff --git a/mobile/test/shared/push/push_presentation_cache_test.dart b/mobile/test/shared/push/push_presentation_cache_test.dart deleted file mode 100644 index fff3ef7257..0000000000 --- a/mobile/test/shared/push/push_presentation_cache_test.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:buzz/shared/push/push_presentation_cache.dart'; -import 'package:buzz/shared/relay/nostr_models.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:nostr/nostr.dart' as nostr; - -void main() { - const secretKey = - '0000000000000000000000000000000000000000000000000000000000000001'; - - test('accepts a valid signed profile event', () { - final signed = nostr.Event.from( - kind: 0, - content: '{"display_name":"Alice"}', - secretKey: secretKey, - createdAt: 1700000000, - ); - - expect( - isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), - isTrue, - ); - }); - - test('accepts opaque channel IDs in a valid signed metadata event', () { - final signed = nostr.Event.from( - kind: 39000, - content: '', - tags: const [ - ['d', 'channel/general:v5'], - ['name', 'General'], - ], - secretKey: secretKey, - createdAt: 1700000000, - ); - - expect( - isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), - isTrue, - ); - }); - - test('accepts a valid signed channel membership snapshot', () { - final signed = nostr.Event.from( - kind: 39002, - content: '', - tags: const [ - ['d', 'channel/general:v5'], - [ - 'p', - '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', - 'member', - ], - ], - secretKey: secretKey, - createdAt: 1700000000, - ); - - expect( - isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), - isTrue, - ); - }); - - test('channel selection keeps newest metadata paired with rosters', () { - NostrEvent signedChannelEvent(int kind, String channelID, int createdAt) { - final signed = nostr.Event.from( - kind: kind, - content: '', - tags: [ - ['d', channelID], - if (kind == 39002) - [ - 'p', - '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', - ], - ], - secretKey: secretKey, - createdAt: createdAt, - ); - return NostrEvent.fromJson(signed.toMap()); - } - - final batch = selectPushChannelEvents( - [ - signedChannelEvent(39000, 'channel-0', 100), - signedChannelEvent(39000, 'channel-1', 300), - signedChannelEvent(39000, 'channel-1', 200), - signedChannelEvent(39000, 'metadata-only', 400), - ], - [ - signedChannelEvent(39002, 'channel-0', 300), - signedChannelEvent(39002, 'channel-1', 200), - ], - ); - - expect(batch.metadata.map((event) => event.getTagValue('d')).toSet(), { - 'channel-0', - 'channel-1', - }); - expect(batch.membership.map((event) => event.getTagValue('d')).toSet(), { - 'channel-0', - 'channel-1', - }); - }); - - test('rejects changed content and malformed signatures', () { - final signed = nostr.Event.from( - kind: 0, - content: '{"name":"Alice"}', - secretKey: secretKey, - createdAt: 1700000000, - ); - final event = NostrEvent.fromJson(signed.toMap()); - - expect( - isVerifiedPushPresentationEvent( - NostrEvent( - id: event.id, - pubkey: event.pubkey, - createdAt: event.createdAt, - kind: event.kind, - tags: event.tags, - content: '{"name":"Mallory"}', - sig: event.sig, - ), - ), - isFalse, - ); - expect( - isVerifiedPushPresentationEvent( - NostrEvent( - id: event.id, - pubkey: event.pubkey, - createdAt: event.createdAt, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: '00', - ), - ), - isFalse, - ); - }); -} diff --git a/mobile/test/shared/push/push_presentation_export_test.dart b/mobile/test/shared/push/push_presentation_export_test.dart deleted file mode 100644 index b0edf4d08d..0000000000 --- a/mobile/test/shared/push/push_presentation_export_test.dart +++ /dev/null @@ -1,628 +0,0 @@ -import 'dart:async'; -import 'dart:isolate'; - -import 'package:buzz/shared/push/push_presentation_cache.dart'; -import 'package:buzz/shared/push/push_presentation_export_recovery.dart'; -import 'package:buzz/shared/relay/nostr_models.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:nostr/nostr.dart' as nostr; - -const _channel = MethodChannel('buzz/push'); -const _secret = - '0000000000000000000000000000000000000000000000000000000000000001'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final calls = []; - final retryWaits = []; - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - - setUp(() { - calls.clear(); - retryWaits.clear(); - debugPushPresentationRetryWait = (delay) async => retryWaits.add(delay); - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - pushPresentationCacheError.value = null; - pushPresentationExportError.value = null; - messenger.setMockMethodCallHandler(_channel, (call) async { - calls.add(call); - return null; - }); - }); - tearDown(() { - debugPushPresentationRetryWait = Future.delayed; - messenger.setMockMethodCallHandler(_channel, null); - debugDefaultTargetPlatformOverride = null; - pushPresentationCacheError.value = null; - pushPresentationExportError.value = null; - }); - - for (final profiles in [true, false]) { - for (final failedChunk in [0, 1]) { - test( - 'native chunk transient failure retries ${profiles ? "profiles" : "channels"} chunk $failedChunk in FIFO slot', - () async { - final limit = profiles ? 256 : 512; - final observations = ReceivePort(); - addTearDown(observations.close); - var workerReads = 0; - final caller = Isolate.current.debugName; - observations.listen((name) { - if (name != caller) workerReads++; - }); - final events = [ - for (var i = 0; i <= limit; i++) - _signed( - profiles ? 0 : 39000, - 100, - channelID: 'channel-$i', - secretKey: profiles - ? (i + 1).toRadixString(16).padLeft(64, '0') - : _secret, - ), - ]; - events[0] = _ObservedVerificationEvent( - events[0], - observations.sendPort, - ); - final memberships = profiles - ? [] - : [ - for (var i = 0; i <= limit; i++) - _signed(39002, 100, channelID: 'channel-$i'), - ]; - var failed = false; - final successfulChunks = []; - messenger.setMockMethodCallHandler(_channel, (call) async { - calls.add(call); - final args = call.arguments as Map; - if (args['communityId'] == 'following-community') return null; - final chunk = - (args[profiles ? 'events' : 'metadataEvents'] as List).length == - limit - ? 0 - : 1; - if (chunk == failedChunk && !failed) { - failed = true; - throw PlatformException( - code: profiles - ? 'profile_cache_failed' - : 'channel_cache_failed', - ); - } - successfulChunks.add(args); - return null; - }); - final exported = PushPresentationExportRecovery().export( - () => profiles - ? cacheBuzzPushProfileEvents('original-community', events) - : cacheBuzzPushChannelEvents( - 'original-community', - events, - memberships, - ), - ); - final following = cacheBuzzPushProfileEvents('following-community', [ - _signed(0, 200), - ]); - expect(await exported, isTrue); - await following; - expect(calls.map((call) => call.arguments['communityId']), [ - 'original-community', - 'original-community', - 'original-community', - 'following-community', - ]); - expect( - calls[failedChunk + 1].arguments, - equals(calls[failedChunk].arguments), - reason: 'retry must reuse the exact failed verified chunk', - ); - expect(successfulChunks, hasLength(2)); - expect([ - for (final chunk in successfulChunks) - ...chunk[profiles ? 'events' : 'metadataEvents'] as List, - ], unorderedEquals(events.map((event) => event.toJson()))); - if (!profiles) { - expect([ - for (final chunk in successfulChunks) - ...chunk['membershipEvents'] as List, - ], unorderedEquals(memberships.map((event) => event.toJson()))); - } - await Future.delayed(Duration.zero); - expect( - workerReads, - 1, - reason: 'native retries must not repeat signature verification', - ); - expect(pushPresentationExportError.value, isNull); - }, - ); - } - } - - for (final profiles in [true, false]) { - test( - 'permanent first ${profiles ? "profile" : "channel"} chunk failure exhausts retries and stops export', - () async { - final limit = profiles ? 256 : 512; - final events = [ - for (var i = 0; i <= limit; i++) - _signed( - profiles ? 0 : 39000, - 100, - channelID: 'channel-$i', - secretKey: profiles - ? (i + 1).toRadixString(16).padLeft(64, '0') - : _secret, - ), - ]; - final memberships = profiles - ? [] - : [ - for (var i = 0; i <= limit; i++) - _signed(39002, 100, channelID: 'channel-$i'), - ]; - messenger.setMockMethodCallHandler(_channel, (call) async { - calls.add(call); - if (call.arguments['communityId'] == 'failed-community') { - throw PlatformException( - code: profiles ? 'profile_cache_failed' : 'channel_cache_failed', - ); - } - return null; - }); - final recovery = PushPresentationExportRecovery(); - final succeeded = await recovery.export( - () => profiles - ? cacheBuzzPushProfileEvents('failed-community', events) - : cacheBuzzPushChannelEvents( - 'failed-community', - events, - memberships, - ), - ); - expect(succeeded, isFalse); - expect( - calls, - hasLength(6), - reason: 'initial attempt plus five retries, with no later chunk', - ); - expect(retryWaits, _retryBackoff); - expect( - pushPresentationCacheError.value, - contains(profiles ? 'profile_cache_failed' : 'channel_cache_failed'), - ); - final terminal = pushPresentationExportError.value; - expect( - terminal, - contains(profiles ? 'profile_cache_failed' : 'channel_cache_failed'), - ); - await cacheBuzzPushProfileEvents('following-community', [ - _signed(0, 200), - ]); - expect(calls, hasLength(7)); - for (final retry in calls.take(6)) { - expect(retry.arguments, equals(calls.first.arguments)); - } - expect(calls.last.arguments['communityId'], 'following-community'); - expect(pushPresentationExportError.value, terminal); - }, - ); - } - - test('native retry budget is shared across all chunks', () async { - final events = [ - for (var i = 1; i <= 257; i++) - _signed(0, 100, secretKey: i.toRadixString(16).padLeft(64, '0')), - ]; - messenger.setMockMethodCallHandler(_channel, (call) async { - calls.add(call); - final rows = call.arguments['events'] as List; - if (calls.length == 1 || rows.length == 1) { - throw PlatformException(code: 'profile_cache_failed'); - } - return null; - }); - expect( - await PushPresentationExportRecovery().export( - () => cacheBuzzPushProfileEvents('community', events), - ), - isFalse, - ); - expect(calls, hasLength(7)); - expect(retryWaits, _retryBackoff); - expect(calls.map((call) => (call.arguments['events'] as List).length), [ - 256, - 256, - 1, - 1, - 1, - 1, - 1, - ]); - expect(pushPresentationExportError.value, contains('profile_cache_failed')); - }); - - test( - 'native argument errors are terminal without persistence retries', - () async { - messenger.setMockMethodCallHandler(_channel, (call) async { - calls.add(call); - throw PlatformException(code: 'invalid_arguments'); - }); - expect( - await PushPresentationExportRecovery().export( - () => cacheBuzzPushProfileEvents('community', [_signed(0, 100)]), - ), - isFalse, - ); - expect(calls, hasLength(1)); - expect(pushPresentationExportError.value, contains('invalid_arguments')); - }, - ); - - test('unavailable native bridge remains an intentional no-op', () async { - messenger.setMockMethodCallHandler(_channel, (call) async { - calls.add(call); - throw MissingPluginException('synthetic non-Runner embedding'); - }); - expect( - await PushPresentationExportRecovery().export( - () => cacheBuzzPushProfileEvents('community', [_signed(0, 100)]), - ), - isTrue, - ); - expect(calls, hasLength(1)); - expect(pushPresentationCacheError.value, isNull); - expect(pushPresentationExportError.value, isNull); - }); - - for (final profiles in [true, false]) { - test( - 'large ${profiles ? "profile" : "channel"} exports fit native bounds', - () async { - final limit = profiles ? 256 : 512; - final events = [ - for (var i = 0; i <= limit; i++) - _signed( - profiles ? 0 : 39000, - 100, - channelID: 'channel-$i', - secretKey: profiles - ? (i + 1).toRadixString(16).padLeft(64, '0') - : _secret, - ), - ]; - // Deliberately reverse roster order: each chunk must pair by channel ID. - final memberships = profiles - ? [] - : [ - for (var i = limit; i >= 0; i--) - _signed(39002, 100, channelID: 'channel-$i'), - ]; - final received = []; - final receivedMemberships = []; - messenger.setMockMethodCallHandler(_channel, (call) async { - final args = call.arguments as Map; - if (args['communityId'] == 'following-community') { - calls.add(call); - return null; - } - final metadata = args[profiles ? 'events' : 'metadataEvents'] as List; - final rosters = profiles - ? [] - : args['membershipEvents'] as List; - if (metadata.length > limit || rosters.length > limit) { - throw PlatformException(code: 'invalid_arguments'); - } - expect(args['communityId'], 'bounded-community'); - if (!profiles) { - String id(dynamic event) => - (event['tags'] as List).firstWhere((tag) => tag[0] == 'd')[1] - as String; - expect(rosters.map(id).toSet(), metadata.map(id).toSet()); - } - received.addAll(metadata); - receivedMemberships.addAll(rosters); - calls.add(call); - return null; - }); - final largeExport = profiles - ? cacheBuzzPushProfileEvents('bounded-community', events) - : cacheBuzzPushChannelEvents( - 'bounded-community', - events, - memberships, - ); - final followingExport = cacheBuzzPushProfileEvents( - 'following-community', - [_signed(0, 200)], - ); - await Future.wait([largeExport, followingExport]); - expect(pushPresentationCacheError.value, isNull); - expect(calls.map((call) => call.arguments['communityId']), [ - 'bounded-community', - 'bounded-community', - 'following-community', - ]); - expect( - received, - unorderedEquals(events.map((event) => event.toJson())), - ); - expect( - receivedMemberships, - unorderedEquals(memberships.map((event) => event.toJson())), - ); - }, - ); - } - - for (final kind in [0, 39000]) { - test( - 'kind $kind verifies off the calling isolate before native export', - () async { - final verification = Completer(); - final observations = ReceivePort(); - addTearDown(observations.close); - final callerIsolateName = Isolate.current.debugName; - observations.listen((name) { - if (name != callerIsolateName && !verification.isCompleted) { - verification.complete(); - } - }); - final event = _ObservedVerificationEvent( - _signed(kind, 100), - observations.sendPort, - ); - var timerRan = false; - Timer.run(() => timerRan = true); - if (kind == 0) { - await cacheBuzzPushProfileEvents('community', [event]); - } else { - await cacheBuzzPushChannelEvents('community', [event], []); - } - // The signal originates from reading the real signature in the worker. - // Restoring synchronous verification cannot produce it. This is a - // liveness timeout, not a device-dependent elapsed-time performance gate. - await verification.future.timeout(const Duration(seconds: 5)); - expect(timerRan, isTrue); - expect(calls.single.method, 'syncPushSnapshot'); - expect(calls.single.arguments['communityId'], 'community'); - final key = kind == 0 ? 'events' : 'metadataEvents'; - expect(calls.single.arguments[key], [event.toJson()]); - }, - ); - } - - test( - 'profile export retains older valid candidates and signed payloads', - () async { - final older = _signed(0, 100); - final newestValid = _signed(0, 200); - await cacheBuzzPushProfileEvents('profiles-community', [ - older, - _tampered(_signed(0, 300)), - newestValid, - _signed(39000, 400), - ]); - expect(calls.single.arguments, { - 'section': 'profiles', - 'communityId': 'profiles-community', - 'events': [newestValid.toJson()], - }); - }, - ); - - test( - 'channel export preserves valid fallback and membership scope pairing', - () async { - final metadata = _signed(39000, 200); - final membership = _signed(39002, 200); - await cacheBuzzPushChannelEvents( - 'channels-community', - [ - _signed(39000, 100), - _tampered(_signed(39000, 300)), - metadata, - _signed(39000, 400, channelID: 'unpaired'), - ], - [_tampered(_signed(39002, 300)), membership, _signed(39002, 100)], - ); - expect(calls.single.arguments, { - 'section': 'channels', - 'communityId': 'channels-community', - 'metadataEvents': [metadata.toJson()], - 'membershipEvents': [membership.toJson()], - }); - }, - ); - - test( - 'native handoff and completion retain FIFO across sections and communities', - () async { - final firstEntered = Completer(); - final releaseFirst = Completer(); - var firstReleased = false; - var overlappingHandoff = false; - messenger.setMockMethodCallHandler(_channel, (call) async { - calls.add(call); - if (calls.length == 1) { - firstEntered.complete(); - await releaseFirst.future; - firstReleased = true; - } else { - overlappingHandoff |= !firstReleased; - } - return null; - }); - final completed = []; - final first = cacheBuzzPushProfileEvents('first', [ - _signed(0, 100), - ]).then((_) => completed.add('first')); - await firstEntered.future; - final second = cacheBuzzPushChannelEvents('second', [ - _signed(39000, 100), - ], []).then((_) => completed.add('second')); - final third = cacheBuzzPushProfileEvents('third', [ - _signed(0, 200), - ]).then((_) => completed.add('third')); - try { - await Future.delayed(Duration.zero); - expect(completed, isEmpty); - expect(calls.length, 1); - } finally { - releaseFirst.complete(); - await Future.wait([first, second, third]); - } - expect(overlappingHandoff, isFalse); - expect(completed, ['first', 'second', 'third']); - expect(calls.map((call) => call.arguments['communityId']), [ - 'first', - 'second', - 'third', - ]); - }, - ); - - test( - 'saturation rejects explicitly and admitted exports drain before recovery', - () async { - final entered = Completer(); - final release = Completer(); - messenger.setMockMethodCallHandler(_channel, (call) async { - calls.add(call); - if (calls.length == 1) { - entered.complete(); - await release.future; - } - return null; - }); - final event = _signed(0, 100); - final admitted = [ - cacheBuzzPushProfileEvents('0', [event]), - ]; - await entered.future; - try { - for (var i = 1; i < 8; i++) { - admitted.add(cacheBuzzPushProfileEvents('$i', [event])); - } - await expectLater( - cacheBuzzPushProfileEvents('rejected', [ - event, - ]).timeout(const Duration(seconds: 5)), - throwsStateError, - ); - expect(calls.length, 1); - } finally { - release.complete(); - await Future.wait(admitted); - } - expect(calls.map((call) => call.arguments['communityId']), [ - for (var i = 0; i < 8; i++) '$i', - ]); - await cacheBuzzPushProfileEvents('recovered', [event]); - expect(calls.last.arguments['communityId'], 'recovered'); - expect(calls.length, 9); - }, - ); - - test( - 'worker submission failure propagates and releases the next export', - () async { - final unsendable = ReceivePort(); - addTearDown(unsendable.close); - final failed = cacheBuzzPushProfileEvents('failed', [ - _UnsendableEvent(_signed(0, 100), unsendable), - ]); - final failure = expectLater(failed, throwsArgumentError); - final next = cacheBuzzPushProfileEvents('next', [_signed(0, 200)]); - await failure; - await next.timeout(const Duration(seconds: 5)); - expect(calls.length, 1); - expect(calls.single.arguments['communityId'], 'next'); - }, - ); -} - -const _retryBackoff = [ - Duration(milliseconds: 250), - Duration(milliseconds: 500), - Duration(seconds: 1), - Duration(seconds: 2), - Duration(seconds: 4), -]; - -// Signing dominates fixture cost, and tests reuse the same events. -final _signedEvents = <(int, int, String, String), NostrEvent>{}; - -NostrEvent _signed( - int kind, - int createdAt, { - String channelID = 'channel', - String secretKey = _secret, -}) => _signedEvents.putIfAbsent( - (kind, createdAt, channelID, secretKey), - () => NostrEvent.fromJson( - nostr.Event.from( - kind: kind, - createdAt: createdAt, - content: kind == 0 ? '{"name":"Synthetic profile"}' : '', - tags: kind == 0 - ? [] - : [ - ['d', channelID], - ], - secretKey: secretKey, - ).toMap(), - ), -); - -NostrEvent _tampered(NostrEvent event) => NostrEvent( - id: event.id, - pubkey: event.pubkey, - createdAt: event.createdAt, - kind: event.kind, - tags: event.tags, - content: 'Tampered', - sig: event.sig, -); - -class _ObservedVerificationEvent extends NostrEvent { - final SendPort observations; - - _ObservedVerificationEvent(NostrEvent event, this.observations) - : super( - id: event.id, - pubkey: event.pubkey, - createdAt: event.createdAt, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: event.sig, - ); - - @override - String get sig { - observations.send(Isolate.current.debugName); - return super.sig; - } -} - -class _UnsendableEvent extends NostrEvent { - final ReceivePort port; - - _UnsendableEvent(NostrEvent event, this.port) - : super( - id: event.id, - pubkey: event.pubkey, - createdAt: event.createdAt, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: event.sig, - ); -} diff --git a/mobile/test/shared/push/push_presentation_recovery_test.dart b/mobile/test/shared/push/push_presentation_recovery_test.dart deleted file mode 100644 index 8158756403..0000000000 --- a/mobile/test/shared/push/push_presentation_recovery_test.dart +++ /dev/null @@ -1,716 +0,0 @@ -import 'dart:async'; -import 'dart:isolate'; - -import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/shared/community/community.dart'; -import 'package:buzz/shared/community/community_provider.dart'; -import 'package:buzz/shared/profile/user_cache_provider.dart'; -import 'package:buzz/shared/profile/profile_event_parser.dart'; -import 'package:buzz/shared/push/push_presentation_cache.dart'; -import 'package:buzz/shared/push/push_presentation_export_recovery.dart'; -import 'package:buzz/shared/relay/relay.dart'; -import 'package:fake_async/fake_async.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nostr/nostr.dart' as nostr; - -const _bridge = MethodChannel('buzz/push'); - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - setUp(() { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - pushPresentationExportError.value = null; - }); - tearDown(() { - debugDefaultTargetPlatformOverride = null; - pushPresentationExportError.value = null; - messenger.setMockMethodCallHandler(_bridge, null); - }); - - for (final profiles in [true, false]) { - final section = profiles ? 'profiles' : 'channels'; - test( - '$section producer recovers its saturated detached snapshot', - () async { - final blocked = Completer(); - final entered = Completer(); - final delivered = Completer>(); - messenger.setMockMethodCallHandler(_bridge, (call) async { - final args = call.arguments as Map; - if (args['communityId'] == 'blocker' && !entered.isCompleted) { - entered.complete(); - await blocked.future; - } - if (args['communityId'] == 'original' && !delivered.isCompleted) { - delivered.complete(args); - } - return null; - }); - final filler = _signed(0); - final queued = [ - cacheBuzzPushProfileEvents('blocker', [filler]), - ]; - await entered.future; - for (var i = 1; i < 8; i++) { - queued.add(cacheBuzzPushProfileEvents('queued-$i', [filler])); - } - final event = _signed(profiles ? 0 : 39000); - final membership = _signed(39002); - var communityID = 'original'; - final container = _container( - _Session(event, membership), - communityID: () => communityID, - ); - addTearDown(container.dispose); - Future? preload; - try { - await container.read(activeCommunityProvider.future); - if (profiles) { - final ready = Completer(); - final subscription = container.listen(userCacheProvider, (_, next) { - if (next.containsKey(event.pubkey) && !ready.isCompleted) { - ready.complete(); - } - }); - addTearDown(subscription.close); - preload = container.read(userCacheProvider.notifier).preload([ - event.pubkey, - ]); - await ready.future.timeout(const Duration(seconds: 5)); - } else { - await container.read(channelsProvider.future); - } - expect(delivered.isCompleted, isFalse); - communityID = 'replacement'; - container.invalidate(activeCommunityProvider); - await container.read(activeCommunityProvider.future); - } finally { - blocked.complete(); - await Future.wait(queued); - if (preload != null) expect(await preload, isTrue); - } - final payload = await delivered.future.timeout( - const Duration(seconds: 5), - ); - expect(payload['section'], section); - expect(payload['communityId'], 'original'); - expect(payload[profiles ? 'events' : 'metadataEvents'], [ - event.toJson(), - ]); - if (!profiles) { - expect(payload['membershipEvents'], [membership.toJson()]); - } - expect(pushPresentationExportError.value, isNull); - }, - ); - - test( - '$section producer preserves distinct input beyond eight arrivals', - () async { - final entered = Completer(); - final release = Completer(); - final payloads = >[]; - final delivered = Completer(); - final expected = {}; - messenger.setMockMethodCallHandler(_bridge, (call) async { - final args = call.arguments as Map; - if (args['communityId'] == 'blocker' && !entered.isCompleted) { - entered.complete(); - await release.future; - } - if (args['communityId'] == 'original') { - payloads.add(args); - final ids = { - for (final payload in payloads) - for (final event - in payload[profiles ? 'events' : 'metadataEvents'] as List) - event['id'] as String, - }; - if (expected.length == 13 && - ids.containsAll(expected) && - !delivered.isCompleted) { - delivered.complete(); - } - } - return null; - }); - final queued = [ - cacheBuzzPushProfileEvents('blocker', [_signed(0)]), - ]; - await entered.future; - for (var i = 1; i < 8; i++) { - queued.add(cacheBuzzPushProfileEvents('queued-$i', [_signed(0)])); - } - final first = _signed(profiles ? 0 : 39000); - expected.add(first.id); - final session = _Session(first, _signed(39002)); - final container = _container(session); - addTearDown(container.dispose); - final loads = >[]; - List? visibleBefore; - var subscriptionsBefore = 0; - try { - await container.read(activeCommunityProvider.future); - if (profiles) { - final ready = Completer(); - final subscription = container.listen(userCacheProvider, (_, next) { - if (next.containsKey(first.pubkey) && !ready.isCompleted) { - ready.complete(); - } - }); - addTearDown(subscription.close); - loads.add( - container.read(userCacheProvider.notifier).preload([ - first.pubkey, - ]), - ); - await ready.future.timeout(const Duration(seconds: 5)); - expect(await loads.single, isTrue); - } else { - await container.read(channelsProvider.future); - } - for (var i = 2; i <= 13; i++) { - final event = _signed( - profiles ? 0 : 39000, - key: i, - channel: 'channel-$i', - ); - session.extra.add(event); - expected.add(event.id); - if (profiles) { - loads.add( - container.read(userCacheProvider.notifier).preload([ - event.pubkey, - ]), - ); - } else { - session.extra.add(_signed(39002, channel: 'channel-$i')); - await container.read(channelsProvider.notifier).refresh(); - } - } - // Include a newer valid event and an invalid newest candidate for an - // entity in a pending partial batch. Selection must keep the valid one. - final newer = _signed( - profiles ? 0 : 39000, - key: 2, - channel: 'channel-2', - createdAt: 200, - ); - expected.remove( - _signed(profiles ? 0 : 39000, key: 2, channel: 'channel-2').id, - ); - expected.add(newer.id); - session.extra.addAll([ - newer, - NostrEvent( - id: 'invalid-newest', - pubkey: newer.pubkey, - createdAt: 300, - kind: newer.kind, - tags: newer.tags, - content: 'tampered', - sig: newer.sig, - ), - ]); - if (profiles) { - // Let the 50 ms fetch-coalescing timer fire if backpressure is - // removed. This is scheduling coverage, not a latency threshold. - await Future.delayed(const Duration(milliseconds: 60)); - expect( - session.profileFetches, - 1, - reason: 'pending keys must remain upstream while export waits', - ); - } else { - for (var turn = 0; turn < 20; turn++) { - await Future.delayed(Duration.zero); - } - visibleBefore = container - .read(channelsProvider) - .requireValue - .map((channel) => channel.id) - .toList(); - subscriptionsBefore = session.subscriptions; - } - } finally { - release.complete(); - await Future.wait(queued); - } - await Future.wait(loads); - await delivered.future.timeout(const Duration(seconds: 5)); - expect(pushPresentationExportError.value, isNull); - if (!profiles) { - expect( - container - .read(channelsProvider) - .requireValue - .map((channel) => channel.id), - visibleBefore, - ); - expect(session.subscriptions, subscriptionsBefore); - expect( - session.directoryLoads, - 0, - reason: 'notification recovery must not fetch the open directory', - ); - expect( - payloads.last['membershipEvents'], - unorderedEquals([ - session.membership.toJson(), - for (final event in session.extra) - if (event.kind == 39002) event.toJson(), - ]), - ); - } - }, - ); - - test( - '$section producer handles detached worker submission failure', - () async { - final port = ReceivePort(); - addTearDown(port.close); - final event = _Unsendable(_signed(profiles ? 0 : 39000), port); - final failure = Completer(); - void onError() { - if (pushPresentationExportError.value != null && - !failure.isCompleted) { - failure.complete(); - } - } - - pushPresentationExportError.addListener(onError); - addTearDown(() => pushPresentationExportError.removeListener(onError)); - var nativeCalls = 0; - messenger.setMockMethodCallHandler(_bridge, (_) async { - nativeCalls++; - return null; - }); - final container = _container( - _Session(event, _signed(39002)), - bypassProfileWorker: true, - ); - addTearDown(container.dispose); - await container.read(activeCommunityProvider.future); - if (profiles) { - await container.read(userCacheProvider.notifier).preload([ - event.pubkey, - ]); - } else { - await container.read(channelsProvider.future); - } - await failure.future.timeout(const Duration(seconds: 5)); - expect(nativeCalls, 0); - final terminalError = pushPresentationExportError.value; - await cacheBuzzPushProfileEvents('unrelated-success', [_signed(0)]); - expect(nativeCalls, 1); - expect(pushPresentationExportError.value, terminalError); - }, - ); - } - - for (final mode in ['reentrant', 'retired', 'failure']) { - test('channel dirty refetch handles $mode work', () async { - final nativeEntered = Completer(); - final releaseNative = Completer(); - final refetchEntered = Completer(); - final releaseRefetch = Completer(); - final complete = Completer(); - final payloads = >[]; - messenger.setMockMethodCallHandler(_bridge, (call) async { - final args = call.arguments as Map; - payloads.add(args); - if (payloads.length == 1) { - nativeEntered.complete(); - await releaseNative.future; - } else if ((mode == 'reentrant' && payloads.length == 3) || - (mode == 'retired' && args['communityId'] == 'replacement')) { - if (!complete.isCompleted) complete.complete(); - } - return null; - }); - void errorChanged() { - if (mode == 'failure' && - pushPresentationExportError.value != null && - !complete.isCompleted) { - complete.complete(); - } - } - - pushPresentationExportError.addListener(errorChanged); - addTearDown( - () => pushPresentationExportError.removeListener(errorChanged), - ); - final session = _Session(_signed(39000), _signed(39002)); - var community = 'original'; - final container = _container(session, communityID: () => community); - addTearDown(container.dispose); - await container.read(activeCommunityProvider.future); - await container.read(channelsProvider.future); - await nativeEntered.future; - await container.read(channelsProvider.notifier).refresh(); - session.beforeMetadata = () async { - refetchEntered.complete(); - await releaseRefetch.future; - if (mode == 'failure') throw StateError('synthetic refetch failure'); - }; - releaseNative.complete(); - await refetchEntered.future.timeout(const Duration(seconds: 5)); - try { - if (mode == 'reentrant') { - session.extra.addAll([ - _signed(39000, channel: 'later'), - _signed(39002, channel: 'later'), - ]); - await container.read(channelsProvider.notifier).refresh(); - } else if (mode == 'retired') { - community = 'replacement'; - session.event = _signed(39000, channel: 'replacement-channel'); - session.membership = _signed(39002, channel: 'replacement-channel'); - container.invalidate(activeCommunityProvider); - await container.read(activeCommunityProvider.future); - container - .read(relayConfigProvider.notifier) - .update(baseUrl: 'https://replacement.invalid'); - await container.read(channelsProvider.future); - await container.read(channelsProvider.notifier).refresh(); - } - } finally { - releaseRefetch.complete(); - } - await complete.future.timeout(const Duration(seconds: 5)); - for (var turn = 0; turn < 20; turn++) { - await Future.delayed(Duration.zero); - } - if (mode == 'failure') { - expect(session.metadataLoads, 3); - expect(session.directoryLoads, 0); - expect(payloads, hasLength(1)); - expect( - pushPresentationExportError.value, - contains('synthetic refetch failure'), - ); - } else if (mode == 'retired') { - expect( - payloads.where((payload) => payload['communityId'] == 'original'), - hasLength(1), - ); - expect(payloads.last['metadataEvents'], [session.event.toJson()]); - expect(payloads.last['membershipEvents'], [ - session.membership.toJson(), - ]); - expect(pushPresentationExportError.value, isNull); - } else { - expect(session.metadataLoads, 5); - expect(session.directoryLoads, 0); - expect(payloads.last['metadataEvents'], hasLength(2)); - expect(payloads.last['membershipEvents'], hasLength(2)); - expect(pushPresentationExportError.value, isNull); - } - }); - } - - for (final failedOperation in ['initial export', 'dirty refetch']) { - test( - 'pending channel input drains after exhausted $failedOperation', - () async { - final retryWaits = []; - debugPushPresentationRetryWait = (delay) async => retryWaits.add(delay); - addTearDown( - () => debugPushPresentationRetryWait = Future.delayed, - ); - final blockerEntered = Completer(); - final releaseBlocker = Completer(); - final initialEntered = Completer(); - final releaseInitial = Completer(); - final refetchEntered = Completer(); - final releaseRefetch = Completer(); - final terminal = Completer(); - late _Session session; - List? readsBeforeAdmissionRetries; - List? readsAtTerminal; - final latestDelivered = Completer>(); - final latest = _signed(39000, channel: 'latest'); - final latestMembership = _signed(39002, channel: 'latest'); - messenger.setMockMethodCallHandler(_bridge, (call) async { - final args = call.arguments as Map; - if (args['communityId'] == 'blocker' && !blockerEntered.isCompleted) { - blockerEntered.complete(); - await releaseBlocker.future; - } - if (args['communityId'] == 'original') { - if (failedOperation == 'dirty refetch' && - !initialEntered.isCompleted) { - initialEntered.complete(); - await releaseInitial.future; - } - if ((args['metadataEvents'] as List).any( - (event) => event['id'] == latest.id, - ) && - !latestDelivered.isCompleted) { - latestDelivered.complete(args); - } - } - return null; - }); - void onFailure() { - if (pushPresentationExportError.value != null && - !terminal.isCompleted) { - readsAtTerminal = session.snapshotReadCounts; - terminal.complete(); - } - } - - pushPresentationExportError.addListener(onFailure); - addTearDown( - () => pushPresentationExportError.removeListener(onFailure), - ); - final queued = >[]; - Future saturate() async { - queued.add(cacheBuzzPushProfileEvents('blocker', [_signed(0)])); - await blockerEntered.future; - for (var i = 1; i < 8; i++) { - queued.add(cacheBuzzPushProfileEvents('queued-$i', [_signed(0)])); - } - } - - session = _Session(_signed(39000), _signed(39002)); - final container = _container(session); - addTearDown(container.dispose); - try { - if (failedOperation == 'initial export') await saturate(); - await container.read(activeCommunityProvider.future); - await container.read(channelsProvider.future); - if (failedOperation == 'dirty refetch') { - await initialEntered.future; - await container.read(channelsProvider.notifier).refresh(); - session.beforeMetadata = () async { - refetchEntered.complete(); - await releaseRefetch.future; - }; - releaseInitial.complete(); - await refetchEntered.future.timeout(const Duration(seconds: 5)); - await saturate(); - } - session.extra.addAll([latest, latestMembership]); - await container.read(channelsProvider.notifier).refresh(); - readsBeforeAdmissionRetries = session.snapshotReadCounts; - if (!releaseRefetch.isCompleted) releaseRefetch.complete(); - await terminal.future.timeout(const Duration(seconds: 5)); - expect(pushPresentationExportError.value, contains('queue is full')); - expect(retryWaits, [ - for (final milliseconds in [250, 500, 1000, 2000, 4000]) - Duration(milliseconds: milliseconds), - ]); - if (failedOperation == 'dirty refetch') { - // Membership pages and metadata were fetched before the paused - // metadata response. Only the remaining member snapshot is read; - // all five admission retries must reuse those same raw events. - expect( - [ - for (var i = 0; i < 3; i++) - readsAtTerminal![i] - readsBeforeAdmissionRetries[i], - ], - [0, 0, 1], - ); - } - // Capacity returns only after the predecessor has exhausted every retry. - releaseBlocker.complete(); - await Future.wait(queued); - final payload = await latestDelivered.future.timeout( - const Duration(seconds: 5), - ); - expect(payload['communityId'], 'original'); - expect(payload['metadataEvents'], contains(equals(latest.toJson()))); - expect( - payload['membershipEvents'], - contains(equals(latestMembership.toJson())), - ); - expect(session.directoryLoads, 0); - expect(pushPresentationExportError.value, contains('queue is full')); - } finally { - if (!releaseBlocker.isCompleted) releaseBlocker.complete(); - if (!releaseInitial.isCompleted) releaseInitial.complete(); - if (!releaseRefetch.isCompleted) releaseRefetch.complete(); - await Future.wait(queued); - } - }, - ); - } - - test('bounded retries terminate and a later operation can succeed', () { - fakeAsync((clock) { - final recovery = PushPresentationExportRecovery(); - var attempts = 0; - bool? result; - recovery - .export(() async { - attempts++; - throw PushPresentationExportQueueFull(); - }) - .then((value) => result = value); - clock.flushMicrotasks(); - clock.elapse(const Duration(seconds: 8)); - expect(result, isFalse); - expect(attempts, 6); - final terminal = pushPresentationExportError.value; - expect(terminal, isNotNull); - clock.elapse(const Duration(days: 1)); - expect(attempts, 6); - bool? recovered; - recovery.export(() async {}).then((value) => recovered = value); - clock.flushMicrotasks(); - expect(recovered, isTrue); - expect(pushPresentationExportError.value, terminal); - }); - }); -} - -ProviderContainer _container( - _Session session, { - String Function()? communityID, - bool bypassProfileWorker = false, -}) => ProviderContainer( - retry: (_, _) => null, - overrides: [ - if (bypassProfileWorker) - profileEventBatchParserProvider.overrideWithValue( - (events) async => events.map(parseProfileEvent).toList(), - ), - relaySessionProvider.overrideWith(() => session), - appLifecycleProvider.overrideWith(_Lifecycle.new), - myPubkeyProvider.overrideWith((ref) => 'me'), - activeCommunityProvider.overrideWith( - (ref) async => Community( - id: communityID?.call() ?? 'original', - name: 'Synthetic', - relayUrl: 'https://example.invalid', - addedAt: DateTime(2026), - ), - ), - ], -); - -class _Session extends RelaySessionNotifier { - _Session(this.event, this.membership); - NostrEvent event; - NostrEvent membership; - final extra = []; - int profileFetches = 0; - int subscriptions = 0; - int directoryLoads = 0; - int metadataLoads = 0; - int membershipPages = 0; - int memberSnapshotLoads = 0; - List get snapshotReadCounts => [ - membershipPages, - metadataLoads, - memberSnapshotLoads, - ]; - Future Function()? beforeMetadata; - @override - SessionState build() => const SessionState(status: SessionStatus.connected); - @override - Future> fetchHistory( - NostrFilter filter, { - Duration timeout = const Duration(seconds: 8), - }) async { - if (filter.kinds.contains(0)) profileFetches++; - if (filter.kinds.contains(39002) && filter.tags['#d'] != null) { - memberSnapshotLoads++; - } - if (filter.kinds.contains(39000)) { - metadataLoads++; - final hook = beforeMetadata; - beforeMetadata = null; - if (hook != null) await hook(); - } - return [ - for (final candidate in [event, membership, ...extra]) - if (filter.kinds.contains(candidate.kind) && - (filter.authors == null || - filter.authors!.contains(candidate.pubkey))) - candidate, - ]; - } - - @override - Future> queryRelay( - List filters, { - Duration timeout = const Duration(seconds: 8), - }) async { - if (filters.any( - (filter) => filter.kinds.contains(39002) && filter.tags['#p'] != null, - )) { - membershipPages++; - } - if (filters.any( - (filter) => - filter.kinds.contains(39000) && - filter.tags['#d'] == null && - filter.extensions['before_id'] == null, - )) { - directoryLoads++; - } - return [ - for (final filter in filters) - ...await fetchHistory(filter, timeout: timeout), - ]; - } - - @override - Future subscribe( - NostrFilter filter, - void Function(NostrEvent) onEvent, { - void Function(String)? onClosed, - }) async { - subscriptions++; - return () {}; - } -} - -class _Lifecycle extends AppLifecycleNotifier { - @override - AppLifecycleState build() => AppLifecycleState.resumed; -} - -NostrEvent _signed( - int kind, { - int key = 1, - String channel = 'channel', - int createdAt = 100, -}) => NostrEvent.fromJson( - nostr.Event.from( - kind: kind, - createdAt: createdAt, - content: kind == 0 ? '{"name":"Synthetic"}' : '', - tags: kind == 0 - ? [] - : [ - ['d', channel], - ['name', 'Synthetic'], - if (kind == 39002) ['p', 'me'], - ], - secretKey: key.toRadixString(16).padLeft(64, '0'), - ).toMap(), -); - -class _Unsendable extends NostrEvent { - final ReceivePort port; - _Unsendable(NostrEvent event, this.port) - : super( - id: event.id, - pubkey: event.pubkey, - createdAt: event.createdAt, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: event.sig, - ); -} diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 34cf86c144..1b911136ff 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; -import 'package:buzz/shared/push/push_presentation_cache.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -29,15 +28,6 @@ void main() { ), ); - test('accepts bounded raster data avatars for the push cache', () { - expect(isCacheablePushAvatarSource('data:image/png;base64,AA=='), isTrue); - expect( - isCacheablePushAvatarSource('data:image/svg+xml;base64,AA=='), - isFalse, - ); - expect(isCacheablePushAvatarSource('data:image/png;base64,%%%'), isFalse); - }); - testWidgets('clips agents to a 30 percent squircle', (tester) async { await tester.pumpWidget(subject(null, isAgent: true));