fix(ios): fetch profiles and avatars in notification extension (#8369)

A notification can arrive from someone whose profile the main app has
never loaded. The iOS notification extension now fetches and caches
sender profiles, channel details, and avatars itself. The main app
supplies account configuration and Keychain credentials only.

After resolving the message, the extension allows 1.5 seconds for sender
and channel details. It uses available stale data when a refresh is slow
or fails, delivers once, and lets late results update its private cache.
Profiles, channel details, and avatar refresh checks share a one-hour
freshness threshold. A verified avatar removal clears the previous
image. Account removal prevents further use of that sign-in's cached
data.

Profile, channel metadata, and membership use separate bounded
downloads, so an oversized roster cannot discard the smaller records.
Sender enrichment can finish while channel requests are pending. A
successful partial channel refresh atomically removes its old
counterpart, preventing a mixed group identity or recipient count.
Group-specific notification presentation still requires a verified
recipient count.

The
[specification](https://github.com/block/buzz/blob/native-notification-enrichment/docs/native-notification-enrichment.md)
describes the display, caching, and account-removal behavior. General
persistent image caching in Flutter remains separate.

### Validation

[Hosted CI](https://github.com/block/buzz/actions/runs/38780108487)
passed on the final commit, including all Flutter shards, Android, the
full iOS release and simulator builds, and native
controls/push-navigation tests. The [current-commit security
review](https://github.com/block/buzz/pull/8369#issuecomment-6197508385)
found no issues.

The native package passed 159 tests, including new coverage for cache
freshness, avatar replacement and removal, sign-in isolation, concurrent
storage, late results, single delivery, bounded downloads, and SVG
validation. A regression test exposed identical HTTP authentication
event IDs for requests in the same second; each request now includes a
unique nonce, matching the Dart and CLI clients.

Full local CI passed before that small authentication fix. The complete
native suite and signed device build passed again on the latest commit,
including response-size limits, overlapping avatar downloads, canonical
relay-origin matching, independent profile/channel refreshes,
account-key changes before and during requests, and a single initial
snapshot read across 64 accounts, signed IPv6 avatar authorities, and
partial channel refreshes. Focused Flutter regression tests passed 198
tests with three configuration-specific skips; seven avatar widget tests
also passed. Flutter analysis is clean, and the notification extension
builds for the simulator.

On an iPhone SE, the notification displayed the sender name, avatar,
channel, and message before the updated main app was opened. Repeat
notifications used a byte-identical private cache. Three notifications
delivered together displayed the avatar after the authentication fix.
The next notification also displayed correctly after terminating the
extension process. The existing generic-push design can select the same
newest unread message for several pushes; this change does not alter
message selection.

![Notification before first app launch after
upgrade](https://raw.githubusercontent.com/block/buzz/37d450855e026178836e2a9e05b0081ebbc3b479/pr-8369--before-first-app-launch.png)

Authenticated HTTP avatar fetching and persistent HTTP cache reuse also
passed on the SE. The first notification downloaded a relay thumbnail.
After terminating the extension and expiring its separate avatar entry,
the next notification displayed the avatar and refreshed that entry
without another media request in the relay logs. The main app remained
unopened throughout. The original app build and test profile were
restored afterward.

Automated tests cover slow and failed requests, stale images, removal,
and account revocation; those cases have not all been exercised on the
phone.

### Before merge

- [x] Verify authenticated HTTP avatar fetching and cache reuse on iOS
against a working relay media service.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-15 11:16:04 -07:00
committed by GitHub
parent d61fe2071d
commit fea02d174d
35 changed files with 2417 additions and 2571 deletions

View File

@@ -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.

View File

@@ -8,12 +8,13 @@ let package = Package(
.library(name: "BuzzPushKit", targets: ["BuzzPushKit"]) .library(name: "BuzzPushKit", targets: ["BuzzPushKit"])
], ],
dependencies: [ 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: [ targets: [
.target( .target(
name: "BuzzPushKit", name: "BuzzPushKit",
dependencies: [.product(name: "P256K", package: "swift-secp256k1")] dependencies: [.product(name: "P256K", package: "swift-secp256k1"), .product(name: "SwiftDraw", package: "SwiftDraw")]
), ),
.testTarget( .testTarget(
name: "BuzzPushKitTests", name: "BuzzPushKitTests",

View File

@@ -2,7 +2,7 @@ import Foundation
/// Verified local values used to specialize an ordinary notification as communication. /// Verified local values used to specialize an ordinary notification as communication.
public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
public let senderDisplayName: String public let senderDisplayName: String?
public let senderIdentifier: String public let senderIdentifier: String
public let senderAvatarPNG: Data? public let senderAvatarPNG: Data?
public let messageBody: String public let messageBody: String
@@ -12,7 +12,7 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
public let recipientCount: Int public let recipientCount: Int
public init( public init(
senderDisplayName: String, senderDisplayName: String?,
senderIdentifier: String, senderIdentifier: String,
senderAvatarPNG: Data?, senderAvatarPNG: Data?,
messageBody: String, messageBody: String,
@@ -39,7 +39,7 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
recipientCount > 0 recipientCount > 0
else { return nil } else { return nil }
self.init( self.init(
senderDisplayName: resolution.title, senderDisplayName: resolution.title.isEmpty ? nil : resolution.title,
senderIdentifier: BuzzPushPresentationIdentity.sender( senderIdentifier: BuzzPushPresentationIdentity.sender(
communityID: target.communityID, communityID: target.communityID,
pubkey: senderPubkey pubkey: senderPubkey

View File

@@ -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<Data, Error>) -> 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<Data, Error>) -> 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[..<comma]))
{
return trimmed
}
return BuzzPushPresentationCacheStore.normalizedAvatarURL(trimmed)
}
/// Animated avatars use the sender-selected poster, never the animation URL.
static func posterURL(_ source: String) -> 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[..<comma]
let payload = String(source[source.index(after: comma)...])
let bytes: Data?
if header.hasSuffix(";base64") {
bytes = Data(base64Encoded: payload)
} else {
bytes = payload.removingPercentEncoding?.data(using: .utf8)
}
guard let bytes, bytes.count <= maximumDownloadBytes else {
throw BuzzNotificationCacheError.invalidRecord
}
return bytes
}
static func decode(_ data: Data) throws -> 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()
}
}

View File

@@ -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<T>(
_ 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<T>(_ 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()
}
}

View File

@@ -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<Data, Error>) -> Void
private var received = Data()
private var failure: Error?
private var session: URLSession?
private init(maximumBytes: Int, completion: @escaping (Result<Data, Error>) -> Void) {
self.maximumBytes = maximumBytes
self.completion = completion
}
static func start(
request: URLRequest, configuration: URLSessionConfiguration,
maximumBytes: Int, completion: @escaping (Result<Data, Error>) -> 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)
}
}

View File

@@ -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<Data, Error>) -> 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))
}
}

View File

@@ -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("<!doctype"), !text.lowercased().contains("<!entity"),
!text.lowercased().contains("@font-face"), !text.lowercased().contains("@import")
else { throw BuzzNotificationCacheError.invalidRecord }
let validator = Validator()
let parser = XMLParser(data: data)
parser.shouldResolveExternalEntities = false
parser.delegate = validator
guard parser.parse(), validator.valid, validator.stack.isEmpty,
validator.nodes.first?.name == "svg"
else { throw BuzzNotificationCacheError.invalidRecord }
try validator.checkReferences()
}
private final class Validator: NSObject, XMLParserDelegate {
struct Node {
let name: String
var children: [Int] = []
var references: [String] = []
}
var nodes: [Node] = []
var stack: [Int] = []
var identifiers: [String: Int] = [:]
var valid = true
private var textBytes = 0
private var styleText = ""
func parser(
_ parser: XMLParser, didStartElement name: String, namespaceURI: String?,
qualifiedName: String?, attributes: [String: String]
) {
guard nodes.count < 512, stack.count < 32,
!["font", "font-face", "foreignObject", "script"].contains(name),
attributes.values.allSatisfy({ $0.utf8.count <= 32768 })
else {
reject(parser)
return
}
let index = nodes.count
nodes.append(Node(name: name))
if let parent = stack.last { nodes[parent].children.append(index) }
stack.append(index)
if let id = attributes["id"] {
guard identifiers[id] == nil else {
reject(parser)
return
}
identifiers[id] = index
}
for (key, value) in attributes {
if key == "href" || key == "xlink:href" {
if value.hasPrefix("#") {
nodes[index].references.append(String(value.dropFirst()))
} else if name == "image", value.hasPrefix("data:image/") {
// Validate compressed raster bounds before SwiftDraw expands it.
guard let comma = value.firstIndex(of: ","),
["data:image/png;base64", "data:image/jpeg;base64", "data:image/webp;base64"]
.contains(String(value[..<comma])),
let bytes = Data(base64Encoded: String(value[value.index(after: comma)...])),
(try? BuzzNotificationAvatarLoader.decode(bytes)) != nil
else {
reject(parser)
return
}
} else {
reject(parser)
return
}
}
if value.contains("url(") {
guard
let expression = try? NSRegularExpression(
pattern: #"url\(\s*['"]?#([^\s)'" ]+)['"]?\s*\)"#)
else {
reject(parser)
return
}
let range = NSRange(value.startIndex..., in: value)
let matches = expression.matches(in: value, range: range)
guard matches.count == value.components(separatedBy: "url(").count - 1 else {
reject(parser)
return
}
for match in matches {
guard let range = Range(match.range(at: 1), in: value) else {
reject(parser)
return
}
nodes[index].references.append(String(value[range]))
}
}
}
}
func parser(
_ parser: XMLParser, didEndElement: String, namespaceURI: String?, qualifiedName: String?
) {
if didEndElement == "style" {
let css = styleText.lowercased()
// CSS escapes can hide resource references. Reject them before parsing.
if css.contains("url(") || css.contains("@") || css.contains("\\") { reject(parser) }
styleText = ""
}
if !stack.isEmpty { stack.removeLast() }
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
textBytes += string.utf8.count
if stack.last.map({ nodes[$0].name == "style" }) == true { styleText += string }
if textBytes > 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<Int>) 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: [])
}
}
}

View File

@@ -6,6 +6,8 @@ import Foundation
/// Content resolved from unread Buzz events for a mutable push notification. /// Content resolved from unread Buzz events for a mutable push notification.
public struct BuzzPushResolution: Decodable, Equatable, Sendable { 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 title: String
public let body: String public let body: String
public let subtitle: String? public let subtitle: String?
@@ -28,8 +30,10 @@ public struct BuzzPushResolution: Decodable, Equatable, Sendable {
senderAvatarPNG: Data? = nil, senderAvatarPNG: Data? = nil,
conversationIdentifier: String? = nil, conversationIdentifier: String? = nil,
conversationDisplayName: String? = nil, conversationDisplayName: String? = nil,
conversationRecipientCount: Int? = nil conversationRecipientCount: Int? = nil,
notificationScope: BuzzNotificationCacheScope? = nil
) { ) {
self.notificationScope = notificationScope
self.title = title self.title = title
self.body = body self.body = body
self.subtitle = subtitle self.subtitle = subtitle
@@ -50,39 +54,39 @@ public protocol BuzzPushNotificationResolving {
/// Reads configured Buzz communities and resolves their newest unread event. /// Reads configured Buzz communities and resolves their newest unread event.
public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
// Verified profiles may carry bounded inline raster avatars. Keep the refresh // Each independent refresh has a hard transfer limit, allowing bounded
// response capped, but large enough to recover the sender name from those // content plus JSON escaping, tags, and envelopes.
// otherwise valid kind-0 events when the app cache has not been populated. // Each record still passes the cache's separate content and signature checks.
static let maximumPresentationResponseBytes = 256 * 1_024 static let maximumPresentationResponseBytes = 2 * 1_024 * 1_024
private let session: URLSession private let session: URLSession
private let loadCommunitiesData: () -> Data? private let loadCommunitiesData: () -> Data?
private let loadPresentationCacheData: () -> Data?
private let loadPrivateKey: (String) -> String? private let loadPrivateKey: (String) -> String?
private let now: () -> Date 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. /// Creates a resolver around the notification extension's App Group and Keychain I/O.
public init( public init(
session: URLSession, session: URLSession,
loadCommunitiesData: @escaping () -> Data?, loadCommunitiesData: @escaping () -> Data?,
loadPrivateKey: @escaping (String) -> String?, loadPrivateKey: @escaping (String) -> String?,
loadPresentationCacheData: @escaping () -> Data? = { nil }, notificationCache: BuzzNotificationCache? = nil,
now: @escaping () -> Date = Date.init, avatarLoader: BuzzNotificationAvatarLoader? = nil,
presentationCacheLifetime: TimeInterval = BuzzPushPresentationCacheStore.freshnessLifetime now: @escaping () -> Date = Date.init
) { ) {
self.notificationCache = notificationCache
self.avatarLoader = avatarLoader
self.session = session self.session = session
self.loadCommunitiesData = loadCommunitiesData self.loadCommunitiesData = loadCommunitiesData
self.loadPresentationCacheData = loadPresentationCacheData
self.loadPrivateKey = loadPrivateKey self.loadPrivateKey = loadPrivateKey
self.now = now self.now = now
self.presentationCacheLifetime = presentationCacheLifetime
} }
public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
let communities = loadCommunities().filter { let communities = loadCommunities().filter {
$0.pubkey?.isEmpty == false $0.pubkey?.isEmpty == false
&& loadPrivateKey($0.id) != nil && accountPrivateKey(for: $0) != nil
&& !$0.policies.isEmpty && !$0.policies.isEmpty
} }
guard !communities.isEmpty else { 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( private func query(
_ community: PushLeaseCommunity, _ community: PushLeaseCommunity,
completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void 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) completion(nil)
return return
} }
@@ -149,7 +169,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
} }
request.setValue(auth, forHTTPHeaderField: "Authorization") request.setValue(auth, forHTTPHeaderField: "Authorization")
session.dataTask(with: request) { data, response, _ in 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) let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else { else {
completion(nil) completion(nil)
@@ -173,241 +194,116 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
community: PushLeaseCommunity, community: PushLeaseCommunity,
completion: @escaping (BuzzPushResolution?) -> Void completion: @escaping (BuzzPushResolution?) -> Void
) { ) {
let snapshot = BuzzPushPresentationCacheSnapshot.decode(loadPresentationCacheData()) guard matchingPrivateKey(for: community) != nil else {
let relayOrigin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(community.relayUrl) completion(nil)
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)
return return
} }
if let notificationCache {
refreshPresentation( guard let scope = BuzzNotificationCacheScope(community: community) else {
event: event, completion(nil)
community: community, return
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
} }
let newerMembershipEvent: VerifiedNostrEvent? = refreshedMembershipEvent.flatMap { event in let operation = BuzzNotificationEnrichmentOperation(
guard event: event, community: community, scope: scope, cache: notificationCache,
BuzzPushPresentationCacheStore.shouldReplace( avatarLoader: avatarLoader, now: now,
existingCreatedAt: cachedChannel?.membershipEventCreatedAt, isCurrent: { [self] in
existingID: cachedChannel?.membershipEventID, matchingPrivateKey(for: community) != nil
candidateCreatedAt: event.createdAt, },
candidateID: event.id refresh: { [self] profile, channel, done in
) refreshPresentation(event: event, community: community,
else { return nil } refreshProfile: profile, refreshChannel: channel, completion: done)
return event }, completion: completion)
} operation.start()
let channel = return
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
)
} }
// Without extension storage, keep the verified message but omit enrichment.
completion(Self.makeResolution(event: event, community: community, profile: nil, channel: nil))
} }
private func refreshPresentation( private func refreshPresentation(
event: VerifiedNostrEvent, event: VerifiedNostrEvent, community: PushLeaseCommunity,
community: PushLeaseCommunity, refreshProfile: Bool, refreshChannel: Bool,
refreshProfile: Bool, completion: @escaping (BuzzNotificationEnrichmentOperation.RefreshResult) -> Void
refreshChannel: Bool,
completion:
@escaping (
VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent?
) -> Void
) { ) {
guard let privateKey = loadPrivateKey(community.id), // A full channel roster can be much larger than a profile. Keep these
let relayURL = community.relayURL, // transfers independent so its size or latency cannot hide the sender.
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]] = []
if refreshProfile { 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 { if refreshChannel {
filters.append([ guard let channelID = Self.tagValue("h", in: event),
"kinds": [39_000], let authority = community.relayMetadataPubkey?.lowercased()
"authors": [relayMetadataPubkey], else {
"#d": [channelID], completion(.channel(nil, nil))
"limit": 1, return
]) }
filters.append([ let group = DispatchGroup()
"kinds": [39_002], let lock = NSLock()
"authors": [relayMetadataPubkey], var records: [Int: VerifiedNostrEvent] = [:]
"#d": [channelID], for kind in [39000, 39002] {
"limit": 1, 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) let body = try? JSONSerialization.data(withJSONObject: filters)
else { else {
completion(nil, nil, nil) completion([])
return return
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.httpBody = body 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") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
guard guard let auth = try? NostrHTTPAuth.authorizationHeader(
let auth = try? NostrHTTPAuth.authorizationHeader( url: url, method: "POST", body: body, privateKeyHex: privateKey)
url: url,
method: "POST",
body: body,
privateKeyHex: privateKey
)
else { else {
completion(nil, nil, nil) completion([])
return return
} }
request.setValue(auth, forHTTPHeaderField: "Authorization") request.setValue(auth, forHTTPHeaderField: "Authorization")
session.downloadTask(with: request) { fileURL, response, _ in BuzzNotificationDownload.start(
guard let response = response as? HTTPURLResponse, request: request, configuration: session.configuration,
(200..<300).contains(response.statusCode), maximumBytes: Self.maximumPresentationResponseBytes
let fileURL, ) { result in
let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), guard self.matchingPrivateKey(for: community) != nil,
let fileSize = values.fileSize, case .success(let data) = result,
fileSize <= Self.maximumPresentationResponseBytes,
let data = try? Data(contentsOf: fileURL),
let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else { else {
completion(nil, nil, nil) completion([])
return return
} }
let verified = events.filter { $0.hasValidIDAndSignature() } completion(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()
} }
static func decodeResolution( static func decodeResolution(
@@ -438,7 +334,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
}.first }.first
} }
private static func makeResolution( static func makeResolution(
event: VerifiedNostrEvent, event: VerifiedNostrEvent,
community: PushLeaseCommunity, community: PushLeaseCommunity,
profile: BuzzPushCachedProfile?, profile: BuzzPushCachedProfile?,
@@ -454,7 +350,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
communicationConversation(event: event, community: community, channel: $0) communicationConversation(event: event, community: community, channel: $0)
} }
return BuzzPushResolution( return BuzzPushResolution(
title: profile?.displayName ?? shortPubkey(event.pubkey), title: profile?.displayName ?? "",
body: body, body: body,
subtitle: community.name, subtitle: community.name,
threadIdentifier: conversationIdentifier ?? community.id, threadIdentifier: conversationIdentifier ?? community.id,
@@ -470,32 +366,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
senderAvatarPNG: profile?.avatarPNG, senderAvatarPNG: profile?.avatarPNG,
conversationIdentifier: conversationIdentifier, conversationIdentifier: conversationIdentifier,
conversationDisplayName: conversation?.displayName, conversationDisplayName: conversation?.displayName,
conversationRecipientCount: conversation?.recipientCount conversationRecipientCount: conversation?.recipientCount,
notificationScope: BuzzNotificationCacheScope(community: community)
) )
} }
private static func ephemeralProfile( static func ephemeralChannel(
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(
metadataEvent: VerifiedNostrEvent?, metadataEvent: VerifiedNostrEvent?,
membershipEvent: VerifiedNostrEvent?, membershipEvent: VerifiedNostrEvent?,
cached: BuzzPushCachedChannel?, cached: BuzzPushCachedChannel?,
@@ -585,19 +461,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
}.first }.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] 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 { static func previewBody(_ content: String) -> String {
var result = content.replacingOccurrences( var result = content.replacingOccurrences(
of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression) of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression)

View File

@@ -185,10 +185,19 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
var snapshot = loadLocked() var snapshot = loadLocked()
snapshot.communities = communities snapshot.communities = communities.map { community in
let retained = Set(communities.map(\.id)) var community = community
snapshot.profiles.removeAll { !retained.contains($0.communityID) } let previous = snapshot.communities.first {
snapshot.channels.removeAll { !retained.contains($0.communityID) } $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) try writeLocked(snapshot)
} }

View File

@@ -104,6 +104,8 @@ public enum NostrHTTPAuth {
["u", url.absoluteString], ["u", url.absoluteString],
["method", method.uppercased()], ["method", method.uppercased()],
["payload", payload], ["payload", payload],
// Separate requests in the same second must not reuse an event ID.
["nonce", UUID().uuidString.lowercased()],
] ]
let serialized = try VerifiedNostrEvent.canonicalSerialization( let serialized = try VerifiedNostrEvent.canonicalSerialization(
pubkey: pubkey, createdAt: createdAt, kind: 27235, pubkey: pubkey, createdAt: createdAt, kind: 27235,

View File

@@ -15,6 +15,8 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable {
/// Relay NIP-11 `self` key used to verify NIP-29 channel metadata. /// Relay NIP-11 `self` key used to verify NIP-29 channel metadata.
public let relayMetadataPubkey: String? public let relayMetadataPubkey: String?
public let pubkey: String? public let pubkey: String?
/// Identity of this local sign-in, renewed after account removal.
public var notificationSessionID: String?
public let policies: [PushResolutionPolicy] public let policies: [PushResolutionPolicy]
public init( public init(
@@ -23,7 +25,8 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable {
relayUrl: String, relayUrl: String,
relayMetadataPubkey: String? = nil, relayMetadataPubkey: String? = nil,
pubkey: String?, pubkey: String?,
policies: [PushResolutionPolicy] policies: [PushResolutionPolicy],
notificationSessionID: String? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@@ -31,6 +34,7 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable {
self.relayMetadataPubkey = relayMetadataPubkey self.relayMetadataPubkey = relayMetadataPubkey
self.pubkey = pubkey self.pubkey = pubkey
self.policies = policies self.policies = policies
self.notificationSessionID = notificationSessionID
} }
} }

View File

@@ -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))
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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() {}
}

View File

@@ -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<Data, Error>) -> Void)?
var newerCallback: ((Result<Data, Error>) -> Void)?
func operation(
started: XCTestExpectation, finished: XCTestExpectation,
capture: @escaping (@escaping (Result<Data, Error>) -> 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))
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
import Testing
@testable import BuzzPushKit
@Suite("Bounded native SVG avatars")
struct BuzzNotificationSVGTests {
@Test func rendersBasicSVG() throws {
let svg =
##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="#ff0000"/></svg>"##
let png = try BuzzNotificationAvatarLoader.decode(Data(svg.utf8))
#expect(png.starts(with: [137, 80, 78, 71]))
}
@Test func rejectsReferenceCyclesAndEntities() throws {
for svg in [
##"<svg width="100" height="100"><g id="a"><use href="#a"/></g></svg>"##,
##"<!DOCTYPE svg [<!ENTITY x "expanded">]><svg width="1" height="1">&x;</svg>"##,
##"<svg width="1" height="1"><style>@font-face { src: url(data:font/woff;base64,AAAA); }</style></svg>"##,
] {
#expect(throws: BuzzNotificationCacheError.self) {
try BuzzNotificationSVG.validate(Data(svg.utf8))
}
}
}
@Test func rejectsDeepDocuments() {
let svg =
"<svg>" + String(repeating: "<g>", count: 33) + String(repeating: "</g>", count: 33)
+ "</svg>"
#expect(throws: BuzzNotificationCacheError.self) {
try BuzzNotificationSVG.validate(Data(svg.utf8))
}
}
}

View File

@@ -99,7 +99,7 @@ extension BuzzPushNotificationResolverTests {
XCTAssertEqual(result.subtitle, "Community") XCTAssertEqual(result.subtitle, "Community")
} }
func testFreshEvictedMembershipDigestsTriggerOneBoundedRefresh() throws { func testPartialMembershipRefreshDoesNotReuseCachedChannel() throws {
let message = try Self.signedEvent( let message = try Self.signedEvent(
privateKey: Self.profilePrivateKey, privateKey: Self.profilePrivateKey,
createdAt: Self.now, createdAt: Self.now,
@@ -142,7 +142,7 @@ extension BuzzPushNotificationResolverTests {
if URLProtocolStub.requests.count == 1 { if URLProtocolStub.requests.count == 1 {
return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
} }
XCTAssertEqual(request.timeoutInterval, 3) XCTAssertEqual(request.timeoutInterval, 60)
return Self.response( return Self.response(
request, request,
status: 200, status: 200,
@@ -160,9 +160,10 @@ extension BuzzPushNotificationResolverTests {
) )
) )
XCTAssertEqual(result.conversationDisplayName, "#General") // Only membership was returned. Do not pair it with the cached channel.
XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertNil(result.conversationDisplayName)
XCTAssertEqual(URLProtocolStub.requests.count, 2) XCTAssertNil(result.conversationRecipientCount)
XCTAssertEqual(URLProtocolStub.requests.count, 4)
} }
func testTwoPersonDMUsesDirectCommunicationPresentation() throws { func testTwoPersonDMUsesDirectCommunicationPresentation() throws {

View File

@@ -58,6 +58,77 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertTrue(URLProtocolStub.requests.isEmpty) 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 { func testResolveReturnsNilForNon2xxRelayResponse() throws {
URLProtocolStub.handler = { request in URLProtocolStub.handler = { request in
Self.response(request, status: 503, data: Data()) Self.response(request, status: 503, data: Data())
@@ -133,20 +204,13 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertEqual(result?.0.body, "lower ID") XCTAssertEqual(result?.0.body, "lower ID")
} }
func testDecodeResolutionSenderTitlesCanonicalizeKeysOrFallBackToNeutral() { func testMissingSenderNameOmitsLabelWithoutExposingKey() {
// The sender-identity presentation boundary: a verifiable key renders // Keep identity in the routing payload, but do not use it as a display name.
// 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.
let senders: [(pubkey: String, title: String)] = [ let senders: [(pubkey: String, title: String)] = [
(Self.unnamedSenderHex, "npub14f8…9nsy"), (Self.unnamedSenderHex, ""),
(Self.unnamedSenderNpub, "npub14f8…9nsy"), (Self.unnamedSenderNpub, ""),
("author-pubkey", "Someone"), ("author-pubkey", ""),
(String(repeating: "+a", count: 32), "Someone"), (String(repeating: "+a", count: 32), ""),
] ]
for sender in senders { for sender in senders {
let result = BuzzPushNotificationResolver.decodeResolution( let result = BuzzPushNotificationResolver.decodeResolution(
@@ -210,7 +274,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertNotEqual(result.title, Self.gatewayBody) XCTAssertNotEqual(result.title, Self.gatewayBody)
XCTAssertNotEqual(result.body, Self.gatewayBody) XCTAssertNotEqual(result.body, Self.gatewayBody)
XCTAssertEqual(result.title, "npub1ccz…mnyd") XCTAssertEqual(result.title, "")
XCTAssertEqual(result.body, "Hello Buzz") XCTAssertEqual(result.body, "Hello Buzz")
XCTAssertEqual(result.subtitle, "Community") XCTAssertEqual(result.subtitle, "Community")
XCTAssertEqual( XCTAssertEqual(
@@ -295,7 +359,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertEqual(URLProtocolStub.requests.count, 1) XCTAssertEqual(URLProtocolStub.requests.count, 1)
} }
func testStaleVerifiedCacheIsUsedWhileOneBoundedRefreshFails() throws { func testStaleVerifiedCacheIsUsedWhileBoundedRefreshesFail() throws {
let message = try Self.signedEvent( let message = try Self.signedEvent(
privateKey: Self.profilePrivateKey, privateKey: Self.profilePrivateKey,
createdAt: Self.now, createdAt: Self.now,
@@ -342,7 +406,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
if URLProtocolStub.requests.count == 1 { if URLProtocolStub.requests.count == 1 {
return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) 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()) return Self.response(request, status: 503, data: Data())
} }
@@ -359,7 +423,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertEqual(result.title, "Stale Alice") XCTAssertEqual(result.title, "Stale Alice")
XCTAssertEqual(result.conversationDisplayName, "#Stale General") XCTAssertEqual(result.conversationDisplayName, "#Stale General")
XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertEqual(result.conversationRecipientCount, 1)
XCTAssertEqual(URLProtocolStub.requests.count, 2) XCTAssertEqual(URLProtocolStub.requests.count, 4)
} }
func testOlderVerifiedRefreshCannotReplaceNewerStaleCache() throws { func testOlderVerifiedRefreshCannotReplaceNewerStaleCache() throws {
@@ -519,7 +583,27 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertEqual(result.conversationDisplayName, "#Stale General") 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( let message = try Self.signedEvent(
privateKey: Self.profilePrivateKey, privateKey: Self.profilePrivateKey,
createdAt: Self.now, createdAt: Self.now,
@@ -575,7 +659,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
XCTAssertEqual(result.conversationDisplayName, "#Fresh General") XCTAssertEqual(result.conversationDisplayName, "#Fresh General")
XCTAssertEqual(result.conversationRecipientCount, 1) XCTAssertEqual(result.conversationRecipientCount, 1)
XCTAssertNil(result.senderAvatarPNG) XCTAssertNil(result.senderAvatarPNG)
XCTAssertEqual(URLProtocolStub.requests.count, 2) XCTAssertEqual(URLProtocolStub.requests.count, 4)
} }
func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws { func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws {
@@ -623,13 +707,19 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
) )
) )
XCTAssertEqual(result.title, "npub1ccz…mnyd") XCTAssertEqual(result.title, "")
XCTAssertNil(result.conversationDisplayName) XCTAssertNil(result.conversationDisplayName)
XCTAssertEqual(result.subtitle, "Community") XCTAssertEqual(result.subtitle, "Community")
XCTAssertEqual(result.body, "Fallback content") XCTAssertEqual(result.body, "Fallback content")
} }
func testOversizedPresentationRefreshFallsBackWithoutBlockingMessage() throws { 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( let message = try Self.signedEvent(
privateKey: Self.profilePrivateKey, privateKey: Self.profilePrivateKey,
createdAt: Self.now, createdAt: Self.now,
@@ -666,10 +756,60 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
) )
) )
XCTAssertEqual(result.title, "npub1ccz…mnyd") XCTAssertEqual(result.title, "")
XCTAssertNil(result.conversationDisplayName) XCTAssertNil(result.conversationDisplayName)
XCTAssertEqual(result.body, "Bounded fallback") 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 { func testBoundedInlineAvatarProfileRefreshStillResolvesDisplayName() throws {
@@ -680,7 +820,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
tags: [["h", Self.channelID]], tags: [["h", Self.channelID]],
content: "Inline avatar profile" 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( let profileContent = try XCTUnwrap(
String( String(
data: JSONSerialization.data(withJSONObject: [ data: JSONSerialization.data(withJSONObject: [
@@ -696,7 +836,19 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
kind: 0, kind: 0,
content: profileContent 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( XCTAssertLessThan(
presentationData.count, presentationData.count,
BuzzPushNotificationResolver.maximumPresentationResponseBytes BuzzPushNotificationResolver.maximumPresentationResponseBytes
@@ -713,16 +865,18 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
let result = try XCTUnwrap( let result = try XCTUnwrap(
resolve( resolve(
makeResolver( makeResolver(
communitiesData: try snapshotData([community()]), communitiesData: try snapshotData([community(relayMetadataPubkey: channel.pubkey)]),
now: Date(timeIntervalSince1970: TimeInterval(Self.now)) now: Date(timeIntervalSince1970: TimeInterval(Self.now))
) )
) )
) )
XCTAssertEqual(result.title, "Fizz") XCTAssertEqual(result.title, "Fizz")
XCTAssertEqual(result.conversationDisplayName, "#Large Channel")
XCTAssertEqual(result.conversationRecipientCount, 1)
XCTAssertNil(result.senderAvatarPNG) XCTAssertNil(result.senderAvatarPNG)
XCTAssertEqual(result.body, "Inline avatar profile") XCTAssertEqual(result.body, "Inline avatar profile")
XCTAssertEqual(URLProtocolStub.requests.count, 2) XCTAssertEqual(URLProtocolStub.requests.count, 4)
} }
func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws { func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws {
@@ -744,6 +898,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
communitiesData: Data?, communitiesData: Data?,
privateKeys: [String: String] = ["community-id": privateKey], privateKeys: [String: String] = ["community-id": privateKey],
presentationCacheData: Data? = nil, presentationCacheData: Data? = nil,
cacheOverride: BuzzNotificationCache? = nil,
now: Date = Date() now: Date = Date()
) -> BuzzPushNotificationResolver { ) -> BuzzPushNotificationResolver {
let configuration = URLSessionConfiguration.ephemeral let configuration = URLSessionConfiguration.ephemeral
@@ -752,11 +907,60 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
session: URLSession(configuration: configuration), session: URLSession(configuration: configuration),
loadCommunitiesData: { communitiesData }, loadCommunitiesData: { communitiesData },
loadPrivateKey: { privateKeys[$0] }, loadPrivateKey: { privateKeys[$0] },
loadPresentationCacheData: { presentationCacheData }, notificationCache: cacheOverride ?? makeNotificationCache(communitiesData: communitiesData, fixture: presentationCacheData, now: now),
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? { func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? {
let completed = expectation(description: "resolver completed") let completed = expectation(description: "resolver completed")
var result: BuzzPushResolution? var result: BuzzPushResolution?
@@ -788,7 +992,8 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
hTags: [Self.channelID] hTags: [Self.channelID]
) )
) )
] ],
notificationSessionID: "test-session"
) )
} }
@@ -884,6 +1089,8 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
final class URLProtocolStub: URLProtocol, @unchecked Sendable { final class URLProtocolStub: URLProtocol, @unchecked Sendable {
static let lock = NSLock() static let lock = NSLock()
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
static var finishLoading: ((URLRequest) -> Bool)?
static var onStopLoading: ((URLRequest) -> Void)?
static var requests: [URLRequest] = [] static var requests: [URLRequest] = []
override class func canInit(with request: URLRequest) -> Bool { true } 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) }() let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }()
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data) client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self) if Self.finishLoading?(request) ?? true {
client?.urlProtocolDidFinishLoading(self)
}
} catch { } catch {
client?.urlProtocol(self, didFailWithError: error) client?.urlProtocol(self, didFailWithError: error)
} }
} }
override func stopLoading() {} override func stopLoading() { Self.onStopLoading?(request) }
static func reset() { static func reset() {
lock.lock() lock.lock()
handler = nil handler = nil
finishLoading = nil
onStopLoading = nil
requests = [] requests = []
lock.unlock() lock.unlock()
} }

View File

@@ -675,8 +675,8 @@ struct BuzzPushPresentationCacheTests {
#expect(snapshot.channels.count == 1) #expect(snapshot.channels.count == 1)
} }
@Test("Community removal prunes profile and channel state") @Test("Account sync discards old presentation exports even for a retained account")
func communityPruning() throws { func discardLegacyExports() throws {
let directory = try temporaryDirectory() let directory = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) } defer { try? FileManager.default.removeItem(at: directory) }
let store = BuzzPushPresentationCacheStore(containerURL: directory) let store = BuzzPushPresentationCacheStore(containerURL: directory)
@@ -687,12 +687,12 @@ struct BuzzPushPresentationCacheTests {
tags: [["d", "opaque"], ["name", "General"]] tags: [["d", "opaque"], ["name", "General"]]
) )
try store.updateProfiles( try store.updateProfiles(
communityID: "removed", communityID: "retained",
relayOrigin: "https://relay.example", relayOrigin: "https://relay.example",
updates: [BuzzPushProfileCacheUpdate(event: profile)] updates: [BuzzPushProfileCacheUpdate(event: profile)]
) )
try store.updateChannels( try store.updateChannels(
communityID: "removed", communityID: "retained",
relayOrigin: "https://relay.example", relayOrigin: "https://relay.example",
relayMetadataPubkey: try pubkey(for: relayKey), relayMetadataPubkey: try pubkey(for: relayKey),
metadataEvents: [channel], metadataEvents: [channel],

View File

@@ -28,11 +28,16 @@ final class NostrHTTPAuthTests: XCTestCase {
XCTAssertEqual(event.createdAt, 1_700_000_000) XCTAssertEqual(event.createdAt, 1_700_000_000)
XCTAssertEqual(event.kind, 27235) XCTAssertEqual(event.kind, 27235)
XCTAssertEqual(event.content, "") XCTAssertEqual(event.content, "")
XCTAssertEqual(event.tags, [ XCTAssertEqual(Array(event.tags.prefix(3)), [
["u", "https://relay.example/query"], ["u", "https://relay.example/query"],
["method", "POST"], ["method", "POST"],
["payload", SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()], ["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()) XCTAssertTrue(event.hasValidIDAndSignature())
} }
@@ -44,6 +49,15 @@ final class NostrHTTPAuthTests: XCTestCase {
XCTAssertFalse(copy(event, content: "tampered").hasValidIDAndSignature()) 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 { private func makeEvent() throws -> VerifiedNostrEvent {
let header = try NostrHTTPAuth.authorizationHeader( let header = try NostrHTTPAuth.authorizationHeader(
url: URL(string: "https://relay.example/query")!, url: URL(string: "https://relay.example/query")!,

View File

@@ -6,6 +6,7 @@ import UserNotifications
final class NotificationService: UNNotificationServiceExtension { final class NotificationService: UNNotificationServiceExtension {
private var handoff: BuzzNotificationHandoff<UNNotificationContent>? private var handoff: BuzzNotificationHandoff<UNNotificationContent>?
private var resolvedScope: BuzzNotificationCacheScope?
private var bestAttemptContent: UNMutableNotificationContent? private var bestAttemptContent: UNMutableNotificationContent?
private var restrictedFallbackContent: UNMutableNotificationContent? private var restrictedFallbackContent: UNMutableNotificationContent?
private lazy var interactionCleanup = BuzzInteractionCleanupRetry( private lazy var interactionCleanup = BuzzInteractionCleanupRetry(
@@ -49,16 +50,56 @@ final class NotificationService: UNNotificationServiceExtension {
keychainAccessGroup: keychainAccessGroup keychainAccessGroup: keychainAccessGroup
) )
}, },
loadPresentationCacheData: { [self] in notificationCache: notificationCache,
Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier) 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( override func didReceive(
_ request: UNNotificationRequest, _ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) { ) {
resolvedScope = nil
handoff = BuzzNotificationHandoff(handler: contentHandler) handoff = BuzzNotificationHandoff(handler: contentHandler)
restrictedFallbackContent = Self.restrictedFallback(from: request.content) restrictedFallbackContent = Self.restrictedFallback(from: request.content)
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
@@ -73,6 +114,11 @@ final class NotificationService: UNNotificationServiceExtension {
resolver.resolve { [weak self] resolution in resolver.resolve { [weak self] resolution in
guard let self else { return } guard let self else { return }
if let resolution { 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.title = resolution.title
content.body = resolution.body content.body = resolution.body
if let subtitle = resolution.subtitle { if let subtitle = resolution.subtitle {
@@ -91,7 +137,8 @@ final class NotificationService: UNNotificationServiceExtension {
ordinaryContent: content, ordinaryContent: content,
resolution: resolution, resolution: resolution,
isStillAllowed: { [weak self] in isStillAllowed: { [weak self] in
!(self?.isAgeRestricted() ?? false) guard let self else { return false }
return self.isCurrent(scope) && !self.isAgeRestricted()
}, },
onDeletionFailure: { error in onDeletionFailure: { error in
NSLog("Notification cleanup retry failed: %@", String(describing: error)) NSLog("Notification cleanup retry failed: %@", String(describing: error))
@@ -113,7 +160,8 @@ final class NotificationService: UNNotificationServiceExtension {
private func finish(_ content: UNNotificationContent) { private func finish(_ content: UNNotificationContent) {
handoff?.finish( handoff?.finish(
content, resolvedScope.map { isCurrent($0) } == false
? Self.restrictedFallback(from: content) : content,
restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content), restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content),
handoffIfAllowed: { deliver in handoffIfAllowed: { deliver in
BuzzAgeRestrictionSession.handoffIfAllowed( BuzzAgeRestrictionSession.handoffIfAllowed(

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "08a49d61c7de953c8fb77e34cc8578189c85a707e518c346697669ad28235ec0", "originHash" : "62fc6df0fb0a452720ac04b0bfacf7c5dbe509dd06046cc6c44487a812f0502e",
"pins" : [ "pins" : [
{ {
"identity" : "swift-secp256k1", "identity" : "swift-secp256k1",
@@ -9,6 +9,15 @@
"revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2", "revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2",
"version" : "0.21.1" "version" : "0.21.1"
} }
},
{
"identity" : "swiftdraw",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swhitty/SwiftDraw.git",
"state" : {
"revision" : "82699f4bdf2f075793cfa0d392eb27c59b41a3fe",
"version" : "0.29.0"
}
} }
], ],
"version" : 3 "version" : 3

View File

@@ -83,9 +83,6 @@ final class BuzzPushSnapshotBridge {
requiresStore: strictAgeGateWrite, requiresStore: strictAgeGateWrite,
result: result result: result
) )
case "profiles": cacheProfiles(arguments, result: result)
case "channels": cacheChannels(arguments, result: result)
case "avatar": cacheAvatar(arguments, result: result)
default: return false default: return false
} }
return true return true
@@ -233,150 +230,6 @@ final class BuzzPushSnapshotBridge {
}?.relayMetadataPubkey }?.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?) { private static func complete(_ result: @escaping FlutterResult, value: Any?) {
DispatchQueue.main.async { DispatchQueue.main.async {
result(value) result(value)

View File

@@ -1,106 +0,0 @@
part of 'channels_provider.dart';
extension _ChannelPushCache on ChannelsNotifier {
Future<void> _exportPushCache(
String? communityID,
List<NostrEvent> metadata,
List<NostrEvent> 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<String>()
.toSet()
.toList();
final memberMetadata = ids.isEmpty
? const <NostrEvent>[]
: await session.fetchHistory(NostrFilters.channelMetadata(ids));
if (!current()) return null;
final memberSnapshots = ids.isEmpty
? const <NostrEvent>[]
: 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;
}
}
}

View File

@@ -3,12 +3,8 @@ import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/relay/relay.dart';
import '../../shared/theme/theme_provider.dart'; import '../../shared/theme/theme_provider.dart';
import '../../shared/utils/string_utils.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'; import 'unread_badge/should_notify_for_event.dart';
part 'channel_directory.dart'; part 'channel_directory.dart';
part 'channel_push_cache.dart';
part 'channel_member_snapshots.dart'; part 'channel_member_snapshots.dart';
part 'channels_provider_lifecycle.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 /// for any visible channel event kind. Chunks stay within the relay's explicit
/// channel cap and incoming events bump `lastMessageAt` for their channel. /// channel cap and incoming events bump `lastMessageAt` for their channel.
class ChannelsNotifier extends AsyncNotifier<List<Channel>> { class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final _pushExport = PushPresentationExportRecovery();
bool _pushCacheExporting = false;
bool _pushCacheDirty = false;
static const _backstopInterval = Duration(seconds: 60); static const _backstopInterval = Duration(seconds: 60);
final Map<String, _LiveChunkSubscription> _liveSubscriptionsByChunk = {}; final Map<String, _LiveChunkSubscription> _liveSubscriptionsByChunk = {};
@@ -215,7 +206,6 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
}) async { }) async {
final myPk = ref.read(myPubkeyProvider); final myPk = ref.read(myPubkeyProvider);
if (myPk == null) throw StateError('No signing identity available'); if (myPk == null) throw StateError('No signing identity available');
final communityID = ref.read(activeCommunityProvider).value?.id;
_loadThreadInterestStores(myPk); _loadThreadInterestStores(myPk);
final session = ref.read(relaySessionProvider.notifier); final session = ref.read(relaySessionProvider.notifier);
@@ -342,12 +332,6 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
// Use the membership snapshots already fetched above for both Huddle // Use the membership snapshots already fetched above for both Huddle
// linkage validation and member-count hydration. // linkage validation and member-count hydration.
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents);
unawaited(
_exportPushCache(communityID, dedupedMetas, [
...memberships,
...memberEvents,
]),
);
final memberCounts = _memberCountsByChannelId(memberEvents); final memberCounts = _memberCountsByChannelId(memberEvents);
for (var i = 0; i < channels.length; i++) { for (var i = 0; i < channels.length; i++) {
final count = memberCounts[channels[i].id]; final count = memberCounts[channels[i].id];

View File

@@ -2,9 +2,6 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; 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 '../relay/relay.dart';
import 'user_profile.dart'; import 'user_profile.dart';
import 'profile_event_parser.dart'; import 'profile_event_parser.dart';
@@ -19,7 +16,6 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
static const _maximumProfilesPerQuery = 1000; static const _maximumProfilesPerQuery = 1000;
final Set<String> _pending = {}; final Set<String> _pending = {};
final _pushExport = PushPresentationExportRecovery();
final Map<String, ({int createdAt, String eventId})> _profileEventOrders = {}; final Map<String, ({int createdAt, String eventId})> _profileEventOrders = {};
int _generation = 0; int _generation = 0;
bool _flushInFlight = false; bool _flushInFlight = false;
@@ -131,7 +127,6 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
final generation = _generation; final generation = _generation;
var succeeded = false; var succeeded = false;
try { try {
final communityID = ref.read(activeCommunityProvider).value?.id;
final session = ref.read(relaySessionProvider.notifier); final session = ref.read(relaySessionProvider.notifier);
var remaining = pubkeys.length; var remaining = pubkeys.length;
for (final batch in _profileQueryBatches(pubkeys)) { for (final batch in _profileQueryBatches(pubkeys)) {
@@ -143,17 +138,10 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
if (!await _verifyAndMerge(events, generation)) return; if (!await _verifyAndMerge(events, generation)) return;
remaining -= batch.length; remaining -= batch.length;
if (remaining == 0) { if (remaining == 0) {
// All requested pages are ready. Native export does not gate readers. // All requested pages are ready.
succeeded = true; succeeded = true;
completer?.complete(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 (_) { } catch (_) {
// Silently fail — non-gating callers will just show pubkeys. // Silently fail — non-gating callers will just show pubkeys.

View File

@@ -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<void> _avatarEncodeTail = Future.value();
Future<void> _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<String?>(null);
/// Waits out one export retry delay for the native writer and
/// [PushPresentationExportRecovery]. Tests replace it to skip real time.
Future<void> Function(Duration delay) debugPushPresentationRetryWait =
Future<void>.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<void> cacheBuzzPushProfileEvents(
String communityID,
Iterable<NostrEvent> 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<void> cacheBuzzPushChannelEvents(
String? communityID,
Iterable<NostrEvent> metadataEvents,
Iterable<NostrEvent> 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<List<T>> _boundedChunks<T>(List<T> 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<void> _serializePresentationExport(
Future<void> Function() export,
) async {
if (_outstandingPresentationExports >= _maximumPresentationExports) {
throw PushPresentationExportQueueFull();
}
_outstandingPresentationExports++;
final previous = _presentationExportTail;
final release = Completer<void>();
_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<NostrEvent> _selectPushProfileEvents(List<NostrEvent> events) =>
_newestVerifiedEvents(
events,
kind: 0,
scope: (event) => event.pubkey.toLowerCase(),
).values.toList();
({List<NostrEvent> metadata, List<NostrEvent> membership})
_selectPushChannelBatch(
({List<NostrEvent> metadata, List<NostrEvent> membership}) batch,
) => selectPushChannelEvents(batch.metadata, batch.membership);
/// Selects the newest paired verified channel metadata and membership events.
@visibleForTesting
({List<NostrEvent> metadata, List<NostrEvent> membership})
selectPushChannelEvents(
Iterable<NostrEvent> metadataEvents,
Iterable<NostrEvent> 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<String, NostrEvent> _newestVerifiedEvents(
Iterable<NostrEvent> events, {
required int kind,
required String? Function(NostrEvent event) scope,
Set<String>? allowedScopes,
}) {
final selected = <String, NostrEvent>{};
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<void> 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<void>();
_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<void> _invokeVerifiedChunk(
Map<String, Object> 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<void> _invokeSnapshot(
Map<String, Object> arguments, {
bool bestEffort = false,
}) async {
try {
await _pushPresentationChannel.invokeMethod<void>(
'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<Uint8List?> _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;
}

View File

@@ -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<String?>(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<bool> export(Future<void> 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;
}
}

View File

@@ -8,10 +8,8 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../animated_avatar.dart'; import '../animated_avatar.dart';
import '../community/community_provider.dart';
import '../emoji/emoji_avatar.dart'; import '../emoji/emoji_avatar.dart';
import '../emoji/native_emoji_glyph.dart'; import '../emoji/native_emoji_glyph.dart';
import '../push/push_presentation_cache.dart';
import '../relay/relay.dart'; import '../relay/relay.dart';
part 'avatar_image_native.dart'; part 'avatar_image_native.dart';
@@ -83,7 +81,6 @@ class AvatarImageContent extends ConsumerStatefulWidget {
class _AvatarImageContentState extends ConsumerState<AvatarImageContent> { class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl); late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl);
String? _scheduledPushAvatar;
@override @override
void didUpdateWidget(AvatarImageContent oldWidget) { void didUpdateWidget(AvatarImageContent oldWidget) {
@@ -96,7 +93,6 @@ class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final centeredFallback = Center(child: widget.fallback); final centeredFallback = Center(child: widget.fallback);
final communityID = ref.watch(activeCommunityProvider).value?.id;
return switch (_source) { return switch (_source) {
_EmojiAvatarSource(:final emoji, :final color) => ColoredBox( _EmojiAvatarSource(:final emoji, :final color) => ColoredBox(
@@ -121,43 +117,19 @@ class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
errorBuilder: (_, _, _) => centeredFallback, errorBuilder: (_, _, _) => centeredFallback,
), ),
_RasterDataAvatarSource(:final bytes) => _rasterImage( _RasterDataAvatarSource(:final bytes) => _rasterImage(
communityID: communityID,
sourceURL: widget.imageUrl,
bytes: bytes, bytes: bytes,
fallback: centeredFallback, fallback: centeredFallback,
), ),
_NetworkAvatarSource(:final url) => MediaImage( _NetworkAvatarSource(:final url) => MediaImage(
url: url, url: url,
fit: widget.fit, fit: widget.fit,
onBytesLoaded: communityID == null
? null
: (bytes) => unawaited(
cacheBuzzPushAvatarFromLoadedBytes(communityID, url, bytes),
),
errorBuilder: (_, _, _) => centeredFallback, errorBuilder: (_, _, _) => centeredFallback,
), ),
null => centeredFallback, null => centeredFallback,
}; };
} }
Widget _rasterImage({ Widget _rasterImage({required Uint8List bytes, required Widget fallback}) {
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),
);
});
}
}
return Image.memory( return Image.memory(
bytes, bytes,
fit: widget.fit, fit: widget.fit,

View File

@@ -5,35 +5,18 @@ import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/relay/relay.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:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr; import 'package:nostr/nostr.dart' as nostr;
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
const bridge = MethodChannel('buzz/push');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
test( test(
'pending profiles drain in bounded queries after delayed export', 'pending profiles drain in bounded queries after a delayed relay query',
() async { () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final entered = Completer<void>(); final entered = Completer<void>();
final release = Completer<void>(); final release = Completer<void>();
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( final signed = NostrEvent.fromJson(
nostr.Event.from( nostr.Event.from(
kind: 0, kind: 0,
@@ -43,7 +26,15 @@ void main() {
secretKey: '1'.padLeft(64, '0'), secretKey: '1'.padLeft(64, '0'),
).toMap(), ).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( final container = ProviderContainer(
overrides: [ overrides: [
relaySessionProvider.overrideWith(() => session), relaySessionProvider.overrideWith(() => session),
@@ -60,7 +51,7 @@ void main() {
addTearDown(container.dispose); addTearDown(container.dispose);
await container.read(activeCommunityProvider.future); await container.read(activeCommunityProvider.future);
final cache = container.read(userCacheProvider.notifier); 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)); await entered.future.timeout(const Duration(seconds: 5));
final keys = List.generate(1001, (i) => 'profile-$i'); final keys = List.generate(1001, (i) => 'profile-$i');
var completed = false; var completed = false;
@@ -76,6 +67,7 @@ void main() {
} finally { } finally {
release.complete(); release.complete();
} }
expect(await first, isTrue);
expect(await pending, isTrue); expect(await pending, isTrue);
expect(await duplicate, isTrue); expect(await duplicate, isTrue);
expect(container.read(userCacheProvider).keys, containsAll(keys)); expect(container.read(userCacheProvider).keys, containsAll(keys));

View File

@@ -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,
);
});
}

View File

@@ -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 = <MethodCall>[];
final retryWaits = <Duration>[];
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<void>.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
? <NostrEvent>[]
: [
for (var i = 0; i <= limit; i++)
_signed(39002, 100, channelID: 'channel-$i'),
];
var failed = false;
final successfulChunks = <Map>[];
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<void>.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
? <NostrEvent>[]
: [
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
? <NostrEvent>[]
: [
for (var i = limit; i >= 0; i--)
_signed(39002, 100, channelID: 'channel-$i'),
];
final received = <dynamic>[];
final receivedMemberships = <dynamic>[];
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
? <dynamic>[]
: 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<void>();
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<void>();
final releaseFirst = Completer<void>();
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 = <String>[];
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<void>.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<void>();
final release = Completer<void>();
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,
);
}

View File

@@ -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<void>();
final entered = Completer<void>();
final delivered = Completer<Map<dynamic, dynamic>>();
messenger.setMockMethodCallHandler(_bridge, (call) async {
final args = call.arguments as Map<dynamic, dynamic>;
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<bool>? preload;
try {
await container.read(activeCommunityProvider.future);
if (profiles) {
final ready = Completer<void>();
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<void>();
final release = Completer<void>();
final payloads = <Map<dynamic, dynamic>>[];
final delivered = Completer<void>();
final expected = <String>{};
messenger.setMockMethodCallHandler(_bridge, (call) async {
final args = call.arguments as Map<dynamic, dynamic>;
if (args['communityId'] == 'blocker' && !entered.isCompleted) {
entered.complete();
await release.future;
}
if (args['communityId'] == 'original') {
payloads.add(args);
final ids = <String>{
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 = <Future<bool>>[];
List<String>? visibleBefore;
var subscriptionsBefore = 0;
try {
await container.read(activeCommunityProvider.future);
if (profiles) {
final ready = Completer<void>();
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<void>.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<void>.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>();
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<void>();
final releaseNative = Completer<void>();
final refetchEntered = Completer<void>();
final releaseRefetch = Completer<void>();
final complete = Completer<void>();
final payloads = <Map<dynamic, dynamic>>[];
messenger.setMockMethodCallHandler(_bridge, (call) async {
final args = call.arguments as Map<dynamic, dynamic>;
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<void>.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 = <Duration>[];
debugPushPresentationRetryWait = (delay) async => retryWaits.add(delay);
addTearDown(
() => debugPushPresentationRetryWait = Future<void>.delayed,
);
final blockerEntered = Completer<void>();
final releaseBlocker = Completer<void>();
final initialEntered = Completer<void>();
final releaseInitial = Completer<void>();
final refetchEntered = Completer<void>();
final releaseRefetch = Completer<void>();
final terminal = Completer<void>();
late _Session session;
List<int>? readsBeforeAdmissionRetries;
List<int>? readsAtTerminal;
final latestDelivered = Completer<Map<dynamic, dynamic>>();
final latest = _signed(39000, channel: 'latest');
final latestMembership = _signed(39002, channel: 'latest');
messenger.setMockMethodCallHandler(_bridge, (call) async {
final args = call.arguments as Map<dynamic, dynamic>;
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<void>>[];
Future<void> 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 = <NostrEvent>[];
int profileFetches = 0;
int subscriptions = 0;
int directoryLoads = 0;
int metadataLoads = 0;
int membershipPages = 0;
int memberSnapshotLoads = 0;
List<int> get snapshotReadCounts => [
membershipPages,
metadataLoads,
memberSnapshotLoads,
];
Future<void> Function()? beforeMetadata;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> 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<List<NostrEvent>> queryRelay(
List<NostrFilter> 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<void Function()> 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,
);
}

View File

@@ -2,7 +2,6 @@ import 'dart:convert';
import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/emoji/native_emoji_glyph.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/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_test/flutter_test.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 { testWidgets('clips agents to a 30 percent squircle', (tester) async {
await tester.pumpWidget(subject(null, isAgent: true)); await tester.pumpWidget(subject(null, isAgent: true));