mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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.  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:
75
docs/native-notification-enrichment.md
Normal file
75
docs/native-notification-enrichment.md
Normal 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.
|
||||
@@ -8,12 +8,13 @@ let package = Package(
|
||||
.library(name: "BuzzPushKit", targets: ["BuzzPushKit"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.21.1")
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.21.1"),
|
||||
.package(url: "https://github.com/swhitty/SwiftDraw.git", exact: "0.29.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "BuzzPushKit",
|
||||
dependencies: [.product(name: "P256K", package: "swift-secp256k1")]
|
||||
dependencies: [.product(name: "P256K", package: "swift-secp256k1"), .product(name: "SwiftDraw", package: "SwiftDraw")]
|
||||
),
|
||||
.testTarget(
|
||||
name: "BuzzPushKitTests",
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
/// Verified local values used to specialize an ordinary notification as communication.
|
||||
public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
|
||||
public let senderDisplayName: String
|
||||
public let senderDisplayName: String?
|
||||
public let senderIdentifier: String
|
||||
public let senderAvatarPNG: Data?
|
||||
public let messageBody: String
|
||||
@@ -12,7 +12,7 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
|
||||
public let recipientCount: Int
|
||||
|
||||
public init(
|
||||
senderDisplayName: String,
|
||||
senderDisplayName: String?,
|
||||
senderIdentifier: String,
|
||||
senderAvatarPNG: Data?,
|
||||
messageBody: String,
|
||||
@@ -39,7 +39,7 @@ public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable {
|
||||
recipientCount > 0
|
||||
else { return nil }
|
||||
self.init(
|
||||
senderDisplayName: resolution.title,
|
||||
senderDisplayName: resolution.title.isEmpty ? nil : resolution.title,
|
||||
senderIdentifier: BuzzPushPresentationIdentity.sender(
|
||||
communityID: target.communityID,
|
||||
pubkey: senderPubkey
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import Foundation
|
||||
|
||||
/// Content resolved from unread Buzz events for a mutable push notification.
|
||||
public struct BuzzPushResolution: Decodable, Equatable, Sendable {
|
||||
/// Local sign-in that authorized this result; checked again before delivery.
|
||||
public let notificationScope: BuzzNotificationCacheScope?
|
||||
public let title: String
|
||||
public let body: String
|
||||
public let subtitle: String?
|
||||
@@ -28,8 +30,10 @@ public struct BuzzPushResolution: Decodable, Equatable, Sendable {
|
||||
senderAvatarPNG: Data? = nil,
|
||||
conversationIdentifier: String? = nil,
|
||||
conversationDisplayName: String? = nil,
|
||||
conversationRecipientCount: Int? = nil
|
||||
conversationRecipientCount: Int? = nil,
|
||||
notificationScope: BuzzNotificationCacheScope? = nil
|
||||
) {
|
||||
self.notificationScope = notificationScope
|
||||
self.title = title
|
||||
self.body = body
|
||||
self.subtitle = subtitle
|
||||
@@ -50,39 +54,39 @@ public protocol BuzzPushNotificationResolving {
|
||||
|
||||
/// Reads configured Buzz communities and resolves their newest unread event.
|
||||
public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
// Verified profiles may carry bounded inline raster avatars. Keep the refresh
|
||||
// response capped, but large enough to recover the sender name from those
|
||||
// otherwise valid kind-0 events when the app cache has not been populated.
|
||||
static let maximumPresentationResponseBytes = 256 * 1_024
|
||||
// Each independent refresh has a hard transfer limit, allowing bounded
|
||||
// content plus JSON escaping, tags, and envelopes.
|
||||
// Each record still passes the cache's separate content and signature checks.
|
||||
static let maximumPresentationResponseBytes = 2 * 1_024 * 1_024
|
||||
|
||||
private let session: URLSession
|
||||
private let loadCommunitiesData: () -> Data?
|
||||
private let loadPresentationCacheData: () -> Data?
|
||||
private let loadPrivateKey: (String) -> String?
|
||||
private let now: () -> Date
|
||||
private let presentationCacheLifetime: TimeInterval
|
||||
private let notificationCache: BuzzNotificationCache?
|
||||
private let avatarLoader: BuzzNotificationAvatarLoader?
|
||||
|
||||
/// Creates a resolver around the notification extension's App Group and Keychain I/O.
|
||||
public init(
|
||||
session: URLSession,
|
||||
loadCommunitiesData: @escaping () -> Data?,
|
||||
loadPrivateKey: @escaping (String) -> String?,
|
||||
loadPresentationCacheData: @escaping () -> Data? = { nil },
|
||||
now: @escaping () -> Date = Date.init,
|
||||
presentationCacheLifetime: TimeInterval = BuzzPushPresentationCacheStore.freshnessLifetime
|
||||
notificationCache: BuzzNotificationCache? = nil,
|
||||
avatarLoader: BuzzNotificationAvatarLoader? = nil,
|
||||
now: @escaping () -> Date = Date.init
|
||||
) {
|
||||
self.notificationCache = notificationCache
|
||||
self.avatarLoader = avatarLoader
|
||||
self.session = session
|
||||
self.loadCommunitiesData = loadCommunitiesData
|
||||
self.loadPresentationCacheData = loadPresentationCacheData
|
||||
self.loadPrivateKey = loadPrivateKey
|
||||
self.now = now
|
||||
self.presentationCacheLifetime = presentationCacheLifetime
|
||||
}
|
||||
|
||||
public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
|
||||
let communities = loadCommunities().filter {
|
||||
$0.pubkey?.isEmpty == false
|
||||
&& loadPrivateKey($0.id) != nil
|
||||
&& accountPrivateKey(for: $0) != nil
|
||||
&& !$0.policies.isEmpty
|
||||
}
|
||||
guard !communities.isEmpty else {
|
||||
@@ -115,11 +119,27 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
}
|
||||
}
|
||||
|
||||
private func matchingPrivateKey(for community: PushLeaseCommunity) -> String? {
|
||||
guard let scope = BuzzNotificationCacheScope(community: community),
|
||||
loadCommunities().contains(where: { BuzzNotificationCacheScope(community: $0) == scope })
|
||||
else { return nil }
|
||||
return accountPrivateKey(for: community)
|
||||
}
|
||||
|
||||
// Initial requests share the snapshot loaded by resolve. Asynchronous results
|
||||
// use matchingPrivateKey to check that the account and session are still current.
|
||||
private func accountPrivateKey(for community: PushLeaseCommunity) -> String? {
|
||||
guard let scope = BuzzNotificationCacheScope(community: community),
|
||||
let key = loadPrivateKey(community.id), scope.matchesAccountKey(key)
|
||||
else { return nil }
|
||||
return key
|
||||
}
|
||||
|
||||
private func query(
|
||||
_ community: PushLeaseCommunity,
|
||||
completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void
|
||||
) {
|
||||
guard let privateKey = loadPrivateKey(community.id), community.pubkey?.isEmpty == false else {
|
||||
guard let privateKey = accountPrivateKey(for: community), community.pubkey?.isEmpty == false else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
@@ -149,7 +169,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
}
|
||||
request.setValue(auth, forHTTPHeaderField: "Authorization")
|
||||
session.dataTask(with: request) { data, response, _ in
|
||||
guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
|
||||
guard self.matchingPrivateKey(for: community) != nil,
|
||||
let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
|
||||
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
|
||||
else {
|
||||
completion(nil)
|
||||
@@ -173,241 +194,116 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
community: PushLeaseCommunity,
|
||||
completion: @escaping (BuzzPushResolution?) -> Void
|
||||
) {
|
||||
let snapshot = BuzzPushPresentationCacheSnapshot.decode(loadPresentationCacheData())
|
||||
let relayOrigin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(community.relayUrl)
|
||||
let cachedProfile = relayOrigin.flatMap {
|
||||
snapshot.profile(
|
||||
communityID: community.id,
|
||||
relayOrigin: $0,
|
||||
pubkey: event.pubkey
|
||||
)
|
||||
}
|
||||
let channelID = Self.tagValue("h", in: event)
|
||||
let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased()
|
||||
let cachedChannel = channelID.flatMap { channelID in
|
||||
relayOrigin.flatMap {
|
||||
snapshot.channel(
|
||||
communityID: community.id,
|
||||
relayOrigin: $0,
|
||||
channelID: channelID
|
||||
)
|
||||
}
|
||||
}.flatMap { channel in
|
||||
channel.relayMetadataPubkey == relayMetadataPubkey ? channel : nil
|
||||
}
|
||||
let timestamp = Int(now().timeIntervalSince1970)
|
||||
let profileNeedsRefresh = Self.isStale(
|
||||
cachedAt: cachedProfile?.cachedAt,
|
||||
now: timestamp,
|
||||
lifetime: presentationCacheLifetime
|
||||
)
|
||||
let membershipNeedsRefresh: Bool = {
|
||||
guard let cachedChannel else { return true }
|
||||
if Self.isStale(
|
||||
cachedAt: cachedChannel.membershipCachedAt,
|
||||
now: timestamp,
|
||||
lifetime: presentationCacheLifetime
|
||||
) {
|
||||
return true
|
||||
}
|
||||
guard let memberCount = cachedChannel.memberCount else { return true }
|
||||
guard memberCount <= BuzzPushPresentationCacheStore.maximumMembersPerChannel
|
||||
else { return false }
|
||||
return cachedChannel.memberDigests?.count != memberCount
|
||||
}()
|
||||
let channelNeedsRefresh =
|
||||
channelID != nil
|
||||
&& (Self.isStale(
|
||||
cachedAt: cachedChannel?.cachedAt,
|
||||
now: timestamp,
|
||||
lifetime: presentationCacheLifetime
|
||||
) || membershipNeedsRefresh)
|
||||
let fallback = Self.makeResolution(
|
||||
event: event,
|
||||
community: community,
|
||||
profile: cachedProfile,
|
||||
channel: cachedChannel
|
||||
)
|
||||
guard profileNeedsRefresh || channelNeedsRefresh else {
|
||||
completion(fallback)
|
||||
guard matchingPrivateKey(for: community) != nil else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
refreshPresentation(
|
||||
event: event,
|
||||
community: community,
|
||||
refreshProfile: profileNeedsRefresh,
|
||||
refreshChannel: channelNeedsRefresh
|
||||
) { refreshedProfileEvent, refreshedChannelEvent, refreshedMembershipEvent in
|
||||
let profile =
|
||||
refreshedProfileEvent.flatMap {
|
||||
guard
|
||||
BuzzPushPresentationCacheStore.shouldReplace(
|
||||
existingCreatedAt: cachedProfile?.eventCreatedAt,
|
||||
existingID: cachedProfile?.eventID,
|
||||
candidateCreatedAt: $0.createdAt,
|
||||
candidateID: $0.id
|
||||
)
|
||||
else { return nil }
|
||||
return Self.ephemeralProfile(
|
||||
event: $0,
|
||||
communityID: community.id,
|
||||
relayOrigin: relayOrigin ?? community.relayUrl,
|
||||
cached: cachedProfile,
|
||||
cachedAt: timestamp
|
||||
)
|
||||
} ?? cachedProfile
|
||||
let newerChannelEvent: VerifiedNostrEvent? = refreshedChannelEvent.flatMap { event in
|
||||
guard
|
||||
BuzzPushPresentationCacheStore.shouldReplace(
|
||||
existingCreatedAt: cachedChannel?.eventCreatedAt,
|
||||
existingID: cachedChannel?.eventID,
|
||||
candidateCreatedAt: event.createdAt,
|
||||
candidateID: event.id
|
||||
)
|
||||
else { return nil }
|
||||
return event
|
||||
if let notificationCache {
|
||||
guard let scope = BuzzNotificationCacheScope(community: community) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
let newerMembershipEvent: VerifiedNostrEvent? = refreshedMembershipEvent.flatMap { event in
|
||||
guard
|
||||
BuzzPushPresentationCacheStore.shouldReplace(
|
||||
existingCreatedAt: cachedChannel?.membershipEventCreatedAt,
|
||||
existingID: cachedChannel?.membershipEventID,
|
||||
candidateCreatedAt: event.createdAt,
|
||||
candidateID: event.id
|
||||
)
|
||||
else { return nil }
|
||||
return event
|
||||
}
|
||||
let channel =
|
||||
relayMetadataPubkey.flatMap {
|
||||
Self.ephemeralChannel(
|
||||
metadataEvent: newerChannelEvent,
|
||||
membershipEvent: newerMembershipEvent,
|
||||
cached: cachedChannel,
|
||||
communityID: community.id,
|
||||
relayOrigin: relayOrigin ?? community.relayUrl,
|
||||
relayMetadataPubkey: $0,
|
||||
cachedAt: timestamp
|
||||
)
|
||||
} ?? cachedChannel
|
||||
completion(
|
||||
Self.makeResolution(
|
||||
event: event,
|
||||
community: community,
|
||||
profile: profile,
|
||||
channel: channel
|
||||
) ?? fallback
|
||||
)
|
||||
let operation = BuzzNotificationEnrichmentOperation(
|
||||
event: event, community: community, scope: scope, cache: notificationCache,
|
||||
avatarLoader: avatarLoader, now: now,
|
||||
isCurrent: { [self] in
|
||||
matchingPrivateKey(for: community) != nil
|
||||
},
|
||||
refresh: { [self] profile, channel, done in
|
||||
refreshPresentation(event: event, community: community,
|
||||
refreshProfile: profile, refreshChannel: channel, completion: done)
|
||||
}, completion: completion)
|
||||
operation.start()
|
||||
return
|
||||
}
|
||||
// Without extension storage, keep the verified message but omit enrichment.
|
||||
completion(Self.makeResolution(event: event, community: community, profile: nil, channel: nil))
|
||||
}
|
||||
|
||||
private func refreshPresentation(
|
||||
event: VerifiedNostrEvent,
|
||||
community: PushLeaseCommunity,
|
||||
refreshProfile: Bool,
|
||||
refreshChannel: Bool,
|
||||
completion:
|
||||
@escaping (
|
||||
VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent?
|
||||
) -> Void
|
||||
event: VerifiedNostrEvent, community: PushLeaseCommunity,
|
||||
refreshProfile: Bool, refreshChannel: Bool,
|
||||
completion: @escaping (BuzzNotificationEnrichmentOperation.RefreshResult) -> Void
|
||||
) {
|
||||
guard let privateKey = loadPrivateKey(community.id),
|
||||
let relayURL = community.relayURL,
|
||||
let url = URL(string: "/query", relativeTo: relayURL)
|
||||
else {
|
||||
completion(nil, nil, nil)
|
||||
return
|
||||
}
|
||||
let channelID = Self.tagValue("h", in: event)
|
||||
let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased()
|
||||
var filters: [[String: Any]] = []
|
||||
// A full channel roster can be much larger than a profile. Keep these
|
||||
// transfers independent so its size or latency cannot hide the sender.
|
||||
if refreshProfile {
|
||||
filters.append(["kinds": [0], "authors": [event.pubkey.lowercased()], "limit": 1])
|
||||
queryPresentation(
|
||||
filters: [["kinds": [0], "authors": [event.pubkey.lowercased()], "limit": 1]],
|
||||
community: community
|
||||
) { events in
|
||||
completion(.profile(Self.newest(events.filter {
|
||||
$0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased()
|
||||
})))
|
||||
}
|
||||
}
|
||||
if refreshChannel, let channelID, let relayMetadataPubkey {
|
||||
filters.append([
|
||||
"kinds": [39_000],
|
||||
"authors": [relayMetadataPubkey],
|
||||
"#d": [channelID],
|
||||
"limit": 1,
|
||||
])
|
||||
filters.append([
|
||||
"kinds": [39_002],
|
||||
"authors": [relayMetadataPubkey],
|
||||
"#d": [channelID],
|
||||
"limit": 1,
|
||||
])
|
||||
if refreshChannel {
|
||||
guard let channelID = Self.tagValue("h", in: event),
|
||||
let authority = community.relayMetadataPubkey?.lowercased()
|
||||
else {
|
||||
completion(.channel(nil, nil))
|
||||
return
|
||||
}
|
||||
let group = DispatchGroup()
|
||||
let lock = NSLock()
|
||||
var records: [Int: VerifiedNostrEvent] = [:]
|
||||
for kind in [39000, 39002] {
|
||||
group.enter()
|
||||
queryPresentation(filters: [[
|
||||
"kinds": [kind], "authors": [authority], "#d": [channelID], "limit": 1,
|
||||
]], community: community) { events in
|
||||
let record = Self.newest(events.filter {
|
||||
$0.kind == kind && $0.pubkey.lowercased() == authority
|
||||
&& Self.tagValue("d", in: $0) == channelID
|
||||
})
|
||||
lock.lock()
|
||||
records[kind] = record
|
||||
lock.unlock()
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
group.notify(queue: .global(qos: .utility)) {
|
||||
completion(.channel(records[39000], records[39002]))
|
||||
}
|
||||
}
|
||||
guard !filters.isEmpty,
|
||||
}
|
||||
|
||||
private func queryPresentation(
|
||||
filters: [[String: Any]], community: PushLeaseCommunity,
|
||||
completion: @escaping ([VerifiedNostrEvent]) -> Void
|
||||
) {
|
||||
guard let privateKey = matchingPrivateKey(for: community),
|
||||
let relayURL = community.relayURL,
|
||||
let url = URL(string: "/query", relativeTo: relayURL),
|
||||
let body = try? JSONSerialization.data(withJSONObject: filters)
|
||||
else {
|
||||
completion(nil, nil, nil)
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body
|
||||
// A verified kind-0 profile may include a bounded inline raster avatar.
|
||||
// Railway can take longer than one second to return that larger response,
|
||||
// while three seconds remains a small fraction of the NSE execution budget.
|
||||
request.timeoutInterval = 3
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
guard
|
||||
let auth = try? NostrHTTPAuth.authorizationHeader(
|
||||
url: url,
|
||||
method: "POST",
|
||||
body: body,
|
||||
privateKeyHex: privateKey
|
||||
)
|
||||
guard let auth = try? NostrHTTPAuth.authorizationHeader(
|
||||
url: url, method: "POST", body: body, privateKeyHex: privateKey)
|
||||
else {
|
||||
completion(nil, nil, nil)
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
request.setValue(auth, forHTTPHeaderField: "Authorization")
|
||||
session.downloadTask(with: request) { fileURL, response, _ in
|
||||
guard let response = response as? HTTPURLResponse,
|
||||
(200..<300).contains(response.statusCode),
|
||||
let fileURL,
|
||||
let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let fileSize = values.fileSize,
|
||||
fileSize <= Self.maximumPresentationResponseBytes,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
BuzzNotificationDownload.start(
|
||||
request: request, configuration: session.configuration,
|
||||
maximumBytes: Self.maximumPresentationResponseBytes
|
||||
) { result in
|
||||
guard self.matchingPrivateKey(for: community) != nil,
|
||||
case .success(let data) = result,
|
||||
let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
|
||||
else {
|
||||
completion(nil, nil, nil)
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
let verified = events.filter { $0.hasValidIDAndSignature() }
|
||||
let profile =
|
||||
refreshProfile
|
||||
? Self.newest(
|
||||
verified.filter {
|
||||
$0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased()
|
||||
}) : nil
|
||||
let channel =
|
||||
refreshChannel
|
||||
? channelID.flatMap { channelID in
|
||||
Self.newest(
|
||||
verified.filter {
|
||||
$0.kind == 39_000
|
||||
&& $0.pubkey.lowercased() == relayMetadataPubkey
|
||||
&& Self.tagValue("d", in: $0) == channelID
|
||||
})
|
||||
} : nil
|
||||
let membership =
|
||||
refreshChannel
|
||||
? channelID.flatMap { channelID in
|
||||
Self.newest(
|
||||
verified.filter {
|
||||
$0.kind == 39_002
|
||||
&& $0.pubkey.lowercased() == relayMetadataPubkey
|
||||
&& Self.tagValue("d", in: $0) == channelID
|
||||
})
|
||||
} : nil
|
||||
completion(profile, channel, membership)
|
||||
}.resume()
|
||||
completion(events.filter { $0.hasValidIDAndSignature() })
|
||||
}
|
||||
}
|
||||
|
||||
static func decodeResolution(
|
||||
@@ -438,7 +334,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
}.first
|
||||
}
|
||||
|
||||
private static func makeResolution(
|
||||
static func makeResolution(
|
||||
event: VerifiedNostrEvent,
|
||||
community: PushLeaseCommunity,
|
||||
profile: BuzzPushCachedProfile?,
|
||||
@@ -454,7 +350,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
communicationConversation(event: event, community: community, channel: $0)
|
||||
}
|
||||
return BuzzPushResolution(
|
||||
title: profile?.displayName ?? shortPubkey(event.pubkey),
|
||||
title: profile?.displayName ?? "",
|
||||
body: body,
|
||||
subtitle: community.name,
|
||||
threadIdentifier: conversationIdentifier ?? community.id,
|
||||
@@ -470,32 +366,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
senderAvatarPNG: profile?.avatarPNG,
|
||||
conversationIdentifier: conversationIdentifier,
|
||||
conversationDisplayName: conversation?.displayName,
|
||||
conversationRecipientCount: conversation?.recipientCount
|
||||
conversationRecipientCount: conversation?.recipientCount,
|
||||
notificationScope: BuzzNotificationCacheScope(community: community)
|
||||
)
|
||||
}
|
||||
|
||||
private static func ephemeralProfile(
|
||||
event: VerifiedNostrEvent,
|
||||
communityID: String,
|
||||
relayOrigin: String,
|
||||
cached: BuzzPushCachedProfile?,
|
||||
cachedAt: Int
|
||||
) -> BuzzPushCachedProfile {
|
||||
let metadata = BuzzPushPresentationCacheStore.profileMetadata(event)
|
||||
return BuzzPushCachedProfile(
|
||||
communityID: communityID,
|
||||
relayOrigin: relayOrigin,
|
||||
pubkey: event.pubkey.lowercased(),
|
||||
displayName: metadata.displayName,
|
||||
pictureHash: metadata.pictureHash,
|
||||
avatarPNG: cached?.pictureHash == metadata.pictureHash ? cached?.avatarPNG : nil,
|
||||
eventID: event.id,
|
||||
eventCreatedAt: event.createdAt,
|
||||
cachedAt: cachedAt
|
||||
)
|
||||
}
|
||||
|
||||
private static func ephemeralChannel(
|
||||
static func ephemeralChannel(
|
||||
metadataEvent: VerifiedNostrEvent?,
|
||||
membershipEvent: VerifiedNostrEvent?,
|
||||
cached: BuzzPushCachedChannel?,
|
||||
@@ -585,19 +461,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
|
||||
}.first
|
||||
}
|
||||
|
||||
private static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? {
|
||||
static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? {
|
||||
event.tags.first { $0.count >= 2 && $0[0] == name }?[1]
|
||||
}
|
||||
|
||||
private static func isStale(
|
||||
cachedAt: Int?,
|
||||
now: Int,
|
||||
lifetime: TimeInterval
|
||||
) -> Bool {
|
||||
guard let cachedAt else { return true }
|
||||
return TimeInterval(max(0, now - cachedAt)) > lifetime
|
||||
}
|
||||
|
||||
static func previewBody(_ content: String) -> String {
|
||||
var result = content.replacingOccurrences(
|
||||
of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression)
|
||||
|
||||
@@ -185,10 +185,19 @@ public final class BuzzPushPresentationCacheStore: @unchecked Sendable {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var snapshot = loadLocked()
|
||||
snapshot.communities = communities
|
||||
let retained = Set(communities.map(\.id))
|
||||
snapshot.profiles.removeAll { !retained.contains($0.communityID) }
|
||||
snapshot.channels.removeAll { !retained.contains($0.communityID) }
|
||||
snapshot.communities = communities.map { community in
|
||||
var community = community
|
||||
let previous = snapshot.communities.first {
|
||||
$0.id == community.id && $0.pubkey == community.pubkey
|
||||
&& Self.canonicalRelayOrigin($0.relayUrl) == Self.canonicalRelayOrigin(community.relayUrl)
|
||||
}
|
||||
community.notificationSessionID = previous?.notificationSessionID ?? UUID().uuidString
|
||||
return community
|
||||
}
|
||||
// Account configuration is the only app-owned notification data. Discard
|
||||
// presentation exports left by older app versions, including retained accounts.
|
||||
snapshot.profiles.removeAll()
|
||||
snapshot.channels.removeAll()
|
||||
try writeLocked(snapshot)
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ public enum NostrHTTPAuth {
|
||||
["u", url.absoluteString],
|
||||
["method", method.uppercased()],
|
||||
["payload", payload],
|
||||
// Separate requests in the same second must not reuse an event ID.
|
||||
["nonce", UUID().uuidString.lowercased()],
|
||||
]
|
||||
let serialized = try VerifiedNostrEvent.canonicalSerialization(
|
||||
pubkey: pubkey, createdAt: createdAt, kind: 27235,
|
||||
|
||||
@@ -15,6 +15,8 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable {
|
||||
/// Relay NIP-11 `self` key used to verify NIP-29 channel metadata.
|
||||
public let relayMetadataPubkey: String?
|
||||
public let pubkey: String?
|
||||
/// Identity of this local sign-in, renewed after account removal.
|
||||
public var notificationSessionID: String?
|
||||
public let policies: [PushResolutionPolicy]
|
||||
|
||||
public init(
|
||||
@@ -23,7 +25,8 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable {
|
||||
relayUrl: String,
|
||||
relayMetadataPubkey: String? = nil,
|
||||
pubkey: String?,
|
||||
policies: [PushResolutionPolicy]
|
||||
policies: [PushResolutionPolicy],
|
||||
notificationSessionID: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -31,6 +34,7 @@ public struct PushLeaseCommunity: Codable, Equatable, Sendable {
|
||||
self.relayMetadataPubkey = relayMetadataPubkey
|
||||
self.pubkey = pubkey
|
||||
self.policies = policies
|
||||
self.notificationSessionID = notificationSessionID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ extension BuzzPushNotificationResolverTests {
|
||||
XCTAssertEqual(result.subtitle, "Community")
|
||||
}
|
||||
|
||||
func testFreshEvictedMembershipDigestsTriggerOneBoundedRefresh() throws {
|
||||
func testPartialMembershipRefreshDoesNotReuseCachedChannel() throws {
|
||||
let message = try Self.signedEvent(
|
||||
privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now,
|
||||
@@ -142,7 +142,7 @@ extension BuzzPushNotificationResolverTests {
|
||||
if URLProtocolStub.requests.count == 1 {
|
||||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||||
}
|
||||
XCTAssertEqual(request.timeoutInterval, 3)
|
||||
XCTAssertEqual(request.timeoutInterval, 60)
|
||||
return Self.response(
|
||||
request,
|
||||
status: 200,
|
||||
@@ -160,9 +160,10 @@ extension BuzzPushNotificationResolverTests {
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.conversationDisplayName, "#General")
|
||||
XCTAssertEqual(result.conversationRecipientCount, 1)
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||||
// Only membership was returned. Do not pair it with the cached channel.
|
||||
XCTAssertNil(result.conversationDisplayName)
|
||||
XCTAssertNil(result.conversationRecipientCount)
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 4)
|
||||
}
|
||||
|
||||
func testTwoPersonDMUsesDirectCommunicationPresentation() throws {
|
||||
|
||||
@@ -58,6 +58,77 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
|
||||
}
|
||||
|
||||
func testInitialAccountFanoutReadsSnapshotOnce() throws {
|
||||
let communities = (0..<64).map { community(id: "community-\($0)") }
|
||||
let snapshot = try snapshotData(communities)
|
||||
let lock = NSLock()
|
||||
var reads = 0
|
||||
let started = expectation(description: "All community requests started")
|
||||
started.expectedFulfillmentCount = communities.count
|
||||
let finished = expectation(description: "Cancelled requests complete resolution")
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [URLProtocolStub.self]
|
||||
let session = URLSession(configuration: configuration)
|
||||
URLProtocolStub.handler = { request in
|
||||
started.fulfill()
|
||||
return Self.response(request, status: 200, data: Data("[]".utf8))
|
||||
}
|
||||
URLProtocolStub.finishLoading = { _ in false }
|
||||
let resolver = BuzzPushNotificationResolver(session: session,
|
||||
loadCommunitiesData: {
|
||||
lock.lock()
|
||||
reads += 1
|
||||
lock.unlock()
|
||||
return snapshot
|
||||
}, loadPrivateKey: { _ in Self.privateKey })
|
||||
resolver.resolve { result in
|
||||
XCTAssertNil(result)
|
||||
finished.fulfill()
|
||||
}
|
||||
wait(for: [started], timeout: 3)
|
||||
lock.lock()
|
||||
let initialReads = reads
|
||||
lock.unlock()
|
||||
XCTAssertEqual(initialReads, 1)
|
||||
session.invalidateAndCancel()
|
||||
wait(for: [finished], timeout: 3)
|
||||
}
|
||||
|
||||
func testSnapshotAccountMustMatchKeychainBeforeAnyRequest() throws {
|
||||
let configured = community(pubkey: try Self.pubkey(for: Self.profilePrivateKey))
|
||||
URLProtocolStub.handler = { request in
|
||||
XCTFail("Mismatched account key must not issue a request")
|
||||
return Self.response(request, status: 200, data: Data("[]".utf8))
|
||||
}
|
||||
XCTAssertNil(resolve(makeResolver(communitiesData: try snapshotData([configured]))))
|
||||
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
|
||||
}
|
||||
|
||||
func testKeyReplacementWhileRequestIsInFlightRejectsItsResult() throws {
|
||||
let data = try snapshotData([community()])
|
||||
let lock = NSLock()
|
||||
var key = Self.privateKey
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [URLProtocolStub.self]
|
||||
let message = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Old account result")
|
||||
URLProtocolStub.handler = { request in
|
||||
lock.lock()
|
||||
key = Self.profilePrivateKey
|
||||
lock.unlock()
|
||||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||||
}
|
||||
let resolver = BuzzPushNotificationResolver(
|
||||
session: URLSession(configuration: configuration), loadCommunitiesData: { data },
|
||||
loadPrivateKey: { _ in
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return key
|
||||
})
|
||||
XCTAssertNil(resolve(resolver))
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
||||
}
|
||||
|
||||
func testResolveReturnsNilForNon2xxRelayResponse() throws {
|
||||
URLProtocolStub.handler = { request in
|
||||
Self.response(request, status: 503, data: Data())
|
||||
@@ -133,20 +204,13 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
XCTAssertEqual(result?.0.body, "lower ID")
|
||||
}
|
||||
|
||||
func testDecodeResolutionSenderTitlesCanonicalizeKeysOrFallBackToNeutral() {
|
||||
// The sender-identity presentation boundary: a verifiable key renders
|
||||
// as the same compact npub whether it arrives as hex or as an npub, and
|
||||
// an unverifiable key — including radix impostors like "+a"×32 that
|
||||
// parse as hex pairs but are not literal hex keys — gets the neutral
|
||||
// identity, never raw key material, while body, subtitle, and internal
|
||||
// payload fields pass through untouched. Malformed-input classification
|
||||
// itself is pinned at the codec (Bech32Tests); named senders winning
|
||||
// over these labels is covered by the cached-profile resolve tests.
|
||||
func testMissingSenderNameOmitsLabelWithoutExposingKey() {
|
||||
// Keep identity in the routing payload, but do not use it as a display name.
|
||||
let senders: [(pubkey: String, title: String)] = [
|
||||
(Self.unnamedSenderHex, "npub14f8…9nsy"),
|
||||
(Self.unnamedSenderNpub, "npub14f8…9nsy"),
|
||||
("author-pubkey", "Someone"),
|
||||
(String(repeating: "+a", count: 32), "Someone"),
|
||||
(Self.unnamedSenderHex, ""),
|
||||
(Self.unnamedSenderNpub, ""),
|
||||
("author-pubkey", ""),
|
||||
(String(repeating: "+a", count: 32), ""),
|
||||
]
|
||||
for sender in senders {
|
||||
let result = BuzzPushNotificationResolver.decodeResolution(
|
||||
@@ -210,7 +274,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
|
||||
XCTAssertNotEqual(result.title, Self.gatewayBody)
|
||||
XCTAssertNotEqual(result.body, Self.gatewayBody)
|
||||
XCTAssertEqual(result.title, "npub1ccz…mnyd")
|
||||
XCTAssertEqual(result.title, "")
|
||||
XCTAssertEqual(result.body, "Hello Buzz")
|
||||
XCTAssertEqual(result.subtitle, "Community")
|
||||
XCTAssertEqual(
|
||||
@@ -295,7 +359,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
||||
}
|
||||
|
||||
func testStaleVerifiedCacheIsUsedWhileOneBoundedRefreshFails() throws {
|
||||
func testStaleVerifiedCacheIsUsedWhileBoundedRefreshesFail() throws {
|
||||
let message = try Self.signedEvent(
|
||||
privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now,
|
||||
@@ -342,7 +406,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
if URLProtocolStub.requests.count == 1 {
|
||||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||||
}
|
||||
XCTAssertEqual(request.timeoutInterval, 3)
|
||||
XCTAssertEqual(request.timeoutInterval, 60)
|
||||
return Self.response(request, status: 503, data: Data())
|
||||
}
|
||||
|
||||
@@ -359,7 +423,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
XCTAssertEqual(result.title, "Stale Alice")
|
||||
XCTAssertEqual(result.conversationDisplayName, "#Stale General")
|
||||
XCTAssertEqual(result.conversationRecipientCount, 1)
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 4)
|
||||
}
|
||||
|
||||
func testOlderVerifiedRefreshCannotReplaceNewerStaleCache() throws {
|
||||
@@ -519,7 +583,27 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
XCTAssertEqual(result.conversationDisplayName, "#Stale General")
|
||||
}
|
||||
|
||||
func testMissingCacheRefreshesVerifiedProfileAndChannelTogether() throws {
|
||||
func testLegacyAccountSnapshotEnrichesBeforeFirstAppLaunchAfterUpgrade() throws {
|
||||
let message = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Hello before app launch")
|
||||
let profile = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now, kind: 0, content: #"{"name":"Alice"}"#)
|
||||
var legacy = community()
|
||||
legacy.notificationSessionID = nil
|
||||
URLProtocolStub.handler = { request in
|
||||
Self.response(request, status: 200,
|
||||
data: try JSONEncoder().encode(URLProtocolStub.requests.count == 1 ? [message] : [profile]))
|
||||
}
|
||||
let result = try XCTUnwrap(resolve(makeResolver(communitiesData: try snapshotData([legacy]))))
|
||||
XCTAssertEqual(result.title, "Alice")
|
||||
XCTAssertEqual(result.body, "Hello before app launch")
|
||||
XCTAssertNotNil(result.notificationScope)
|
||||
var newSignIn = legacy
|
||||
newSignIn.notificationSessionID = UUID().uuidString
|
||||
XCTAssertNotEqual(result.notificationScope, BuzzNotificationCacheScope(community: newSignIn))
|
||||
}
|
||||
|
||||
func testMissingCacheRefreshesVerifiedProfileAndChannelIndependently() throws {
|
||||
let message = try Self.signedEvent(
|
||||
privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now,
|
||||
@@ -575,7 +659,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
XCTAssertEqual(result.conversationDisplayName, "#Fresh General")
|
||||
XCTAssertEqual(result.conversationRecipientCount, 1)
|
||||
XCTAssertNil(result.senderAvatarPNG)
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 4)
|
||||
}
|
||||
|
||||
func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws {
|
||||
@@ -623,13 +707,19 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.title, "npub1ccz…mnyd")
|
||||
XCTAssertEqual(result.title, "")
|
||||
XCTAssertNil(result.conversationDisplayName)
|
||||
XCTAssertEqual(result.subtitle, "Community")
|
||||
XCTAssertEqual(result.body, "Fallback content")
|
||||
}
|
||||
|
||||
func testOversizedPresentationRefreshFallsBackWithoutBlockingMessage() throws {
|
||||
let cancelled = expectation(description: "Oversized streaming responses are cancelled")
|
||||
cancelled.expectedFulfillmentCount = 3
|
||||
URLProtocolStub.finishLoading = { _ in URLProtocolStub.requests.count == 1 }
|
||||
URLProtocolStub.onStopLoading = { _ in
|
||||
if URLProtocolStub.requests.count > 1 { cancelled.fulfill() }
|
||||
}
|
||||
let message = try Self.signedEvent(
|
||||
privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now,
|
||||
@@ -666,10 +756,60 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.title, "npub1ccz…mnyd")
|
||||
XCTAssertEqual(result.title, "")
|
||||
XCTAssertNil(result.conversationDisplayName)
|
||||
XCTAssertEqual(result.body, "Bounded fallback")
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 4)
|
||||
wait(for: [cancelled], timeout: 1)
|
||||
}
|
||||
|
||||
func testLargeRosterCannotDiscardSenderProfileOrChannelMetadata() throws {
|
||||
let message = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Large channel")
|
||||
let profile = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
||||
createdAt: Self.now, kind: 0, content: #"{"display_name":"Fizz"}"#)
|
||||
let members = try Self.signedEvent(privateKey: Self.relayPrivateKey,
|
||||
createdAt: Self.now, kind: 39002,
|
||||
tags: [["d", Self.channelID]] + (0..<30000).map { ["p", String(format: "%064x", $0)] })
|
||||
let channel = try Self.signedEvent(privateKey: Self.relayPrivateKey,
|
||||
createdAt: Self.now, kind: 39000,
|
||||
tags: [["d", Self.channelID], ["name", "Large Channel"], ["t", "stream"]])
|
||||
let roster = try JSONEncoder().encode([profile, channel, members])
|
||||
XCTAssertGreaterThan(roster.count, BuzzPushNotificationResolver.maximumPresentationResponseBytes)
|
||||
URLProtocolStub.handler = { request in
|
||||
if URLProtocolStub.requests.count == 1 {
|
||||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||||
}
|
||||
var body = request.httpBody ?? Data()
|
||||
if let stream = request.httpBodyStream {
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
while stream.hasBytesAvailable {
|
||||
let count = stream.read(&buffer, maxLength: buffer.count)
|
||||
if count <= 0 { break }
|
||||
body.append(contentsOf: buffer.prefix(count))
|
||||
}
|
||||
}
|
||||
let filters = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [[String: Any]])
|
||||
let kinds = filters.flatMap { $0["kinds"] as? [Int] ?? [] }
|
||||
return Self.response(request, status: 200,
|
||||
data: kinds.contains(39002) ? roster : try JSONEncoder().encode(kinds.contains(0) ? [profile] : [channel]))
|
||||
}
|
||||
let configured = community(relayMetadataPubkey: members.pubkey)
|
||||
let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: configured))
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
||||
let result = try XCTUnwrap(resolve(makeResolver(
|
||||
communitiesData: try snapshotData([configured]), cacheOverride: cache,
|
||||
now: Date(timeIntervalSince1970: TimeInterval(Self.now)))))
|
||||
XCTAssertEqual(result.title, "Fizz")
|
||||
XCTAssertEqual(result.body, "Large channel")
|
||||
XCTAssertEqual(try cache.metadata(scope: scope, kind: 39000,
|
||||
author: channel.pubkey, identifier: Self.channelID)?.event, channel)
|
||||
// Group specialization still requires a verified complete recipient count.
|
||||
XCTAssertNil(result.conversationRecipientCount)
|
||||
}
|
||||
|
||||
func testBoundedInlineAvatarProfileRefreshStillResolvesDisplayName() throws {
|
||||
@@ -680,7 +820,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
tags: [["h", Self.channelID]],
|
||||
content: "Inline avatar profile"
|
||||
)
|
||||
let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000)
|
||||
let picture = "data:image/png;base64," + String(repeating: "A", count: 262_050)
|
||||
let profileContent = try XCTUnwrap(
|
||||
String(
|
||||
data: JSONSerialization.data(withJSONObject: [
|
||||
@@ -696,7 +836,19 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
kind: 0,
|
||||
content: profileContent
|
||||
)
|
||||
let presentationData = try JSONEncoder().encode([profile])
|
||||
let channel = try Self.signedEvent(
|
||||
privateKey: Self.relayPrivateKey, createdAt: Self.now, kind: 39000,
|
||||
tags: [["d", Self.channelID], ["name", "Large Channel"], ["t", "stream"]],
|
||||
content: String(repeating: "\"", count: 256 * 1024)
|
||||
)
|
||||
let membership = try Self.signedEvent(
|
||||
privateKey: Self.relayPrivateKey, createdAt: Self.now, kind: 39002,
|
||||
tags: [["d", Self.channelID], ["p", Self.ownPubkey]],
|
||||
content: String(repeating: "\"", count: 256 * 1024)
|
||||
)
|
||||
let presentationData = try JSONEncoder().encode([profile, channel, membership])
|
||||
XCTAssertLessThanOrEqual(profileContent.utf8.count, 256 * 1024)
|
||||
XCTAssertGreaterThan(presentationData.count, 256 * 1024)
|
||||
XCTAssertLessThan(
|
||||
presentationData.count,
|
||||
BuzzPushNotificationResolver.maximumPresentationResponseBytes
|
||||
@@ -713,16 +865,18 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
let result = try XCTUnwrap(
|
||||
resolve(
|
||||
makeResolver(
|
||||
communitiesData: try snapshotData([community()]),
|
||||
communitiesData: try snapshotData([community(relayMetadataPubkey: channel.pubkey)]),
|
||||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.title, "Fizz")
|
||||
XCTAssertEqual(result.conversationDisplayName, "#Large Channel")
|
||||
XCTAssertEqual(result.conversationRecipientCount, 1)
|
||||
XCTAssertNil(result.senderAvatarPNG)
|
||||
XCTAssertEqual(result.body, "Inline avatar profile")
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||||
XCTAssertEqual(URLProtocolStub.requests.count, 4)
|
||||
}
|
||||
|
||||
func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws {
|
||||
@@ -744,6 +898,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
communitiesData: Data?,
|
||||
privateKeys: [String: String] = ["community-id": privateKey],
|
||||
presentationCacheData: Data? = nil,
|
||||
cacheOverride: BuzzNotificationCache? = nil,
|
||||
now: Date = Date()
|
||||
) -> BuzzPushNotificationResolver {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
@@ -752,11 +907,60 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
session: URLSession(configuration: configuration),
|
||||
loadCommunitiesData: { communitiesData },
|
||||
loadPrivateKey: { privateKeys[$0] },
|
||||
loadPresentationCacheData: { presentationCacheData },
|
||||
notificationCache: cacheOverride ?? makeNotificationCache(communitiesData: communitiesData, fixture: presentationCacheData, now: now),
|
||||
now: { now }
|
||||
)
|
||||
}
|
||||
|
||||
private func makeNotificationCache(communitiesData: Data?, fixture: Data?, now: Date) -> BuzzNotificationCache {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
||||
let communities = communitiesData.flatMap { try? JSONDecoder().decode(PushLeaseSnapshot.self, from: $0) }?.communities ?? []
|
||||
let scopes = communities.compactMap(BuzzNotificationCacheScope.init)
|
||||
let cache = BuzzNotificationCache(directory: root, now: { now }, isCurrent: { scopes.contains($0) })
|
||||
let snapshot = BuzzPushPresentationCacheSnapshot.decode(fixture)
|
||||
do {
|
||||
for profile in snapshot.profiles {
|
||||
let scope = try XCTUnwrap(scopes.first { $0.communityID == profile.communityID })
|
||||
var content = ["display_name": profile.displayName ?? ""]
|
||||
if profile.avatarPNG != nil { content["picture"] = "https://images.example/avatar.png" }
|
||||
let event = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
||||
createdAt: profile.eventCreatedAt, kind: 0,
|
||||
content: String(decoding: JSONEncoder().encode(content), as: UTF8.self))
|
||||
let writer = BuzzNotificationCache(directory: root,
|
||||
now: { Date(timeIntervalSince1970: Double(profile.cachedAt)) }, isCurrent: { scopes.contains($0) })
|
||||
try writer.save(event: event, scope: scope)
|
||||
if let image = profile.avatarPNG {
|
||||
let requestID = try XCTUnwrap(writer.beginAvatarRequest(
|
||||
profileID: event.id, pubkey: event.pubkey, scope: scope))
|
||||
try writer.saveAvatar(image, source: "https://images.example/avatar.png",
|
||||
profileID: event.id, requestID: requestID, pubkey: event.pubkey, scope: scope)
|
||||
}
|
||||
}
|
||||
for channel in snapshot.channels {
|
||||
let scope = try XCTUnwrap(scopes.first { $0.communityID == channel.communityID })
|
||||
let event = try Self.signedEvent(privateKey: Self.relayPrivateKey,
|
||||
createdAt: channel.eventCreatedAt, kind: 39000,
|
||||
tags: [["d", channel.channelID], ["name", channel.displayName ?? ""], ["t", channel.channelType ?? "stream"]])
|
||||
let writer = BuzzNotificationCache(directory: root,
|
||||
now: { Date(timeIntervalSince1970: Double(channel.cachedAt)) }, isCurrent: { scopes.contains($0) })
|
||||
try writer.save(event: event, scope: scope)
|
||||
guard let digests = channel.memberDigests else { continue }
|
||||
let candidates = [Self.ownPubkey, try Self.pubkey(for: Self.profilePrivateKey), try Self.pubkey(for: Self.relayPrivateKey)]
|
||||
let members = candidates.filter { candidate in
|
||||
Self.memberDigests([candidate]).contains { digests.contains($0) }
|
||||
}
|
||||
let membership = try Self.signedEvent(privateKey: Self.relayPrivateKey,
|
||||
createdAt: channel.membershipEventCreatedAt ?? channel.eventCreatedAt, kind: 39002,
|
||||
tags: [["d", channel.channelID]] + members.map { ["p", $0] })
|
||||
let membershipWriter = BuzzNotificationCache(directory: root,
|
||||
now: { Date(timeIntervalSince1970: Double(channel.membershipCachedAt ?? channel.cachedAt)) }, isCurrent: { scopes.contains($0) })
|
||||
try membershipWriter.save(event: membership, scope: scope)
|
||||
}
|
||||
} catch { XCTFail("Cannot seed verified cache: \(error)") }
|
||||
return cache
|
||||
}
|
||||
|
||||
func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? {
|
||||
let completed = expectation(description: "resolver completed")
|
||||
var result: BuzzPushResolution?
|
||||
@@ -788,7 +992,8 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
hTags: [Self.channelID]
|
||||
)
|
||||
)
|
||||
]
|
||||
],
|
||||
notificationSessionID: "test-session"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -884,6 +1089,8 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
final class URLProtocolStub: URLProtocol, @unchecked Sendable {
|
||||
static let lock = NSLock()
|
||||
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
static var finishLoading: ((URLRequest) -> Bool)?
|
||||
static var onStopLoading: ((URLRequest) -> Void)?
|
||||
static var requests: [URLRequest] = []
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
@@ -898,17 +1105,21 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
|
||||
let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }()
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
if Self.finishLoading?(request) ?? true {
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
override func stopLoading() { Self.onStopLoading?(request) }
|
||||
|
||||
static func reset() {
|
||||
lock.lock()
|
||||
handler = nil
|
||||
finishLoading = nil
|
||||
onStopLoading = nil
|
||||
requests = []
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
@@ -675,8 +675,8 @@ struct BuzzPushPresentationCacheTests {
|
||||
#expect(snapshot.channels.count == 1)
|
||||
}
|
||||
|
||||
@Test("Community removal prunes profile and channel state")
|
||||
func communityPruning() throws {
|
||||
@Test("Account sync discards old presentation exports even for a retained account")
|
||||
func discardLegacyExports() throws {
|
||||
let directory = try temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
||||
@@ -687,12 +687,12 @@ struct BuzzPushPresentationCacheTests {
|
||||
tags: [["d", "opaque"], ["name", "General"]]
|
||||
)
|
||||
try store.updateProfiles(
|
||||
communityID: "removed",
|
||||
communityID: "retained",
|
||||
relayOrigin: "https://relay.example",
|
||||
updates: [BuzzPushProfileCacheUpdate(event: profile)]
|
||||
)
|
||||
try store.updateChannels(
|
||||
communityID: "removed",
|
||||
communityID: "retained",
|
||||
relayOrigin: "https://relay.example",
|
||||
relayMetadataPubkey: try pubkey(for: relayKey),
|
||||
metadataEvents: [channel],
|
||||
|
||||
@@ -28,11 +28,16 @@ final class NostrHTTPAuthTests: XCTestCase {
|
||||
XCTAssertEqual(event.createdAt, 1_700_000_000)
|
||||
XCTAssertEqual(event.kind, 27235)
|
||||
XCTAssertEqual(event.content, "")
|
||||
XCTAssertEqual(event.tags, [
|
||||
XCTAssertEqual(Array(event.tags.prefix(3)), [
|
||||
["u", "https://relay.example/query"],
|
||||
["method", "POST"],
|
||||
["payload", SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()],
|
||||
])
|
||||
XCTAssertEqual(event.tags.count, 4)
|
||||
let nonce = try XCTUnwrap(event.tags.last)
|
||||
XCTAssertEqual(nonce.first, "nonce")
|
||||
XCTAssertEqual(nonce.count, 2)
|
||||
XCTAssertNotNil(UUID(uuidString: nonce[1]))
|
||||
XCTAssertTrue(event.hasValidIDAndSignature())
|
||||
}
|
||||
|
||||
@@ -44,6 +49,15 @@ final class NostrHTTPAuthTests: XCTestCase {
|
||||
XCTAssertFalse(copy(event, content: "tampered").hasValidIDAndSignature())
|
||||
}
|
||||
|
||||
func testSeparateRequestsInTheSameSecondHaveDistinctEventIDs() throws {
|
||||
let first = try makeEvent()
|
||||
let second = try makeEvent()
|
||||
XCTAssertTrue(first.hasValidIDAndSignature())
|
||||
XCTAssertTrue(second.hasValidIDAndSignature())
|
||||
XCTAssertNotEqual(first.id, second.id,
|
||||
"The relay rejects a second request with the same authentication event ID as a replay")
|
||||
}
|
||||
|
||||
private func makeEvent() throws -> VerifiedNostrEvent {
|
||||
let header = try NostrHTTPAuth.authorizationHeader(
|
||||
url: URL(string: "https://relay.example/query")!,
|
||||
|
||||
@@ -6,6 +6,7 @@ import UserNotifications
|
||||
|
||||
final class NotificationService: UNNotificationServiceExtension {
|
||||
private var handoff: BuzzNotificationHandoff<UNNotificationContent>?
|
||||
private var resolvedScope: BuzzNotificationCacheScope?
|
||||
private var bestAttemptContent: UNMutableNotificationContent?
|
||||
private var restrictedFallbackContent: UNMutableNotificationContent?
|
||||
private lazy var interactionCleanup = BuzzInteractionCleanupRetry(
|
||||
@@ -49,16 +50,56 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
keychainAccessGroup: keychainAccessGroup
|
||||
)
|
||||
},
|
||||
loadPresentationCacheData: { [self] in
|
||||
Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier)
|
||||
}
|
||||
notificationCache: notificationCache,
|
||||
avatarLoader: notificationAvatarLoader
|
||||
)
|
||||
}()
|
||||
|
||||
private var notificationCacheRoot: URL? {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("notification-enrichment-v1", isDirectory: true)
|
||||
}
|
||||
|
||||
private lazy var notificationCache: BuzzNotificationCache? = {
|
||||
guard let directory = notificationCacheRoot else { return nil }
|
||||
let cache = BuzzNotificationCache(directory: directory, isCurrent: { [weak self] scope in
|
||||
self?.isCurrent(scope) == true
|
||||
})
|
||||
let scopes = Self.communities(appGroupIdentifier: appGroupIdentifier)
|
||||
.compactMap(BuzzNotificationCacheScope.init(community:))
|
||||
do { try cache.removeRevokedScopes(keeping: scopes) }
|
||||
catch { NSLog("Notification cache cleanup failed: %@", String(describing: error)) }
|
||||
return cache
|
||||
}()
|
||||
|
||||
private lazy var notificationAvatarLoader: BuzzNotificationAvatarLoader? = {
|
||||
guard let directory = notificationCacheRoot else { return nil }
|
||||
let accessGroup = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
|
||||
return BuzzNotificationAvatarLoader(directory: directory,
|
||||
privateKey: { Self.loadPrivateKey(communityID: $0, keychainAccessGroup: accessGroup) },
|
||||
isCurrent: { [weak self] in self?.isCurrent($0) == true })
|
||||
}()
|
||||
|
||||
private func isCurrent(_ scope: BuzzNotificationCacheScope) -> Bool {
|
||||
let accessGroup = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
|
||||
return Self.communities(appGroupIdentifier: appGroupIdentifier).contains {
|
||||
BuzzNotificationCacheScope(community: $0) == scope
|
||||
} && scope.matchesAccountKey(
|
||||
Self.loadPrivateKey(communityID: scope.communityID, keychainAccessGroup: accessGroup))
|
||||
}
|
||||
|
||||
private static func communities(appGroupIdentifier: String?) -> [PushLeaseCommunity] {
|
||||
guard let data = loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier),
|
||||
let snapshot = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data)
|
||||
else { return [] }
|
||||
return snapshot.communities
|
||||
}
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
resolvedScope = nil
|
||||
handoff = BuzzNotificationHandoff(handler: contentHandler)
|
||||
restrictedFallbackContent = Self.restrictedFallback(from: request.content)
|
||||
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
||||
@@ -73,6 +114,11 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
resolver.resolve { [weak self] resolution in
|
||||
guard let self else { return }
|
||||
if let resolution {
|
||||
guard let scope = resolution.notificationScope, self.isCurrent(scope) else {
|
||||
self.finish(Self.restrictedFallback(from: content))
|
||||
return
|
||||
}
|
||||
self.resolvedScope = scope
|
||||
content.title = resolution.title
|
||||
content.body = resolution.body
|
||||
if let subtitle = resolution.subtitle {
|
||||
@@ -91,7 +137,8 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
ordinaryContent: content,
|
||||
resolution: resolution,
|
||||
isStillAllowed: { [weak self] in
|
||||
!(self?.isAgeRestricted() ?? false)
|
||||
guard let self else { return false }
|
||||
return self.isCurrent(scope) && !self.isAgeRestricted()
|
||||
},
|
||||
onDeletionFailure: { error in
|
||||
NSLog("Notification cleanup retry failed: %@", String(describing: error))
|
||||
@@ -113,7 +160,8 @@ final class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
private func finish(_ content: UNNotificationContent) {
|
||||
handoff?.finish(
|
||||
content,
|
||||
resolvedScope.map { isCurrent($0) } == false
|
||||
? Self.restrictedFallback(from: content) : content,
|
||||
restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content),
|
||||
handoffIfAllowed: { deliver in
|
||||
BuzzAgeRestrictionSession.handoffIfAllowed(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "08a49d61c7de953c8fb77e34cc8578189c85a707e518c346697669ad28235ec0",
|
||||
"originHash" : "62fc6df0fb0a452720ac04b0bfacf7c5dbe509dd06046cc6c44487a812f0502e",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-secp256k1",
|
||||
@@ -9,6 +9,15 @@
|
||||
"revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2",
|
||||
"version" : "0.21.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftdraw",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swhitty/SwiftDraw.git",
|
||||
"state" : {
|
||||
"revision" : "82699f4bdf2f075793cfa0d392eb27c59b41a3fe",
|
||||
"version" : "0.29.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -83,9 +83,6 @@ final class BuzzPushSnapshotBridge {
|
||||
requiresStore: strictAgeGateWrite,
|
||||
result: result
|
||||
)
|
||||
case "profiles": cacheProfiles(arguments, result: result)
|
||||
case "channels": cacheChannels(arguments, result: result)
|
||||
case "avatar": cacheAvatar(arguments, result: result)
|
||||
default: return false
|
||||
}
|
||||
return true
|
||||
@@ -233,150 +230,6 @@ final class BuzzPushSnapshotBridge {
|
||||
}?.relayMetadataPubkey
|
||||
}
|
||||
|
||||
private func cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let arguments = rawArguments as? [String: Any],
|
||||
let communityID = arguments["communityId"] as? String,
|
||||
let rawEvents = arguments["events"] as? [[String: Any]],
|
||||
rawEvents.count <= BuzzPushPresentationCacheStore.maximumProfiles
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "invalid_arguments",
|
||||
message: "Expected communityId and profile events.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
queue.async { [weak self] in
|
||||
do {
|
||||
guard let self, let community = community(id: communityID) else {
|
||||
Self.complete(result, value: nil)
|
||||
return
|
||||
}
|
||||
let events = try decodeEvents(rawEvents)
|
||||
try store?.updateProfiles(
|
||||
communityID: communityID,
|
||||
relayOrigin: community.relayUrl,
|
||||
updates: events.map { BuzzPushProfileCacheUpdate(event: $0) }
|
||||
)
|
||||
Self.complete(result, value: nil)
|
||||
} catch {
|
||||
Self.complete(
|
||||
result,
|
||||
value: FlutterError(
|
||||
code: "profile_cache_failed",
|
||||
message: "Unable to cache push sender profiles.",
|
||||
details: error.localizedDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheChannels(_ rawArguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let arguments = rawArguments as? [String: Any],
|
||||
let communityID = arguments["communityId"] as? String,
|
||||
let rawMetadataEvents = arguments["metadataEvents"] as? [[String: Any]],
|
||||
let rawMembershipEvents = arguments["membershipEvents"] as? [[String: Any]],
|
||||
rawMetadataEvents.count <= BuzzPushPresentationCacheStore.maximumChannels,
|
||||
rawMembershipEvents.count <= BuzzPushPresentationCacheStore.maximumChannels
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "invalid_arguments",
|
||||
message: "Expected communityId, channel metadata, and membership events.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
queue.async { [weak self] in
|
||||
do {
|
||||
guard let self, let community = community(id: communityID),
|
||||
let relayMetadataPubkey = community.relayMetadataPubkey
|
||||
else {
|
||||
Self.complete(result, value: nil)
|
||||
return
|
||||
}
|
||||
try store?.updateChannels(
|
||||
communityID: communityID,
|
||||
relayOrigin: community.relayUrl,
|
||||
relayMetadataPubkey: relayMetadataPubkey,
|
||||
metadataEvents: try decodeEvents(rawMetadataEvents),
|
||||
membershipEvents: try decodeEvents(rawMembershipEvents)
|
||||
)
|
||||
Self.complete(result, value: nil)
|
||||
} catch {
|
||||
Self.complete(
|
||||
result,
|
||||
value: FlutterError(
|
||||
code: "channel_cache_failed",
|
||||
message: "Unable to cache push channel metadata.",
|
||||
details: error.localizedDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheAvatar(_ rawArguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let arguments = rawArguments as? [String: Any],
|
||||
let communityID = arguments["communityId"] as? String,
|
||||
let sourceURL = arguments["sourceUrl"] as? String,
|
||||
let avatar = arguments["png"] as? FlutterStandardTypedData
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "invalid_arguments",
|
||||
message: "Expected an avatar source and PNG thumbnail.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
let avatarData = avatar.data
|
||||
queue.async { [weak self] in
|
||||
do {
|
||||
guard let self, let community = community(id: communityID) else {
|
||||
Self.complete(result, value: false)
|
||||
return
|
||||
}
|
||||
let updated =
|
||||
try store?.updateAvatar(
|
||||
communityID: communityID,
|
||||
relayOrigin: community.relayUrl,
|
||||
sourceURL: sourceURL,
|
||||
avatarPNG: avatarData
|
||||
) ?? false
|
||||
Self.complete(result, value: updated)
|
||||
} catch {
|
||||
Self.complete(
|
||||
result,
|
||||
value: FlutterError(
|
||||
code: "avatar_cache_failed",
|
||||
message: "Unable to cache a push sender avatar.",
|
||||
details: error.localizedDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func community(id: String) -> PushLeaseCommunity? {
|
||||
guard let container = containerURL(),
|
||||
let data = try? Data(
|
||||
contentsOf: container.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)),
|
||||
let snapshot = try? JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data)
|
||||
else { return nil }
|
||||
return snapshot.communities.first { $0.id == id }
|
||||
}
|
||||
|
||||
private func decodeEvents(_ rawEvents: [[String: Any]]) throws -> [VerifiedNostrEvent] {
|
||||
let data = try JSONSerialization.data(withJSONObject: rawEvents)
|
||||
return try JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
|
||||
}
|
||||
|
||||
private static func complete(_ result: @escaping FlutterResult, value: Any?) {
|
||||
DispatchQueue.main.async {
|
||||
result(value)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,8 @@ import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/community/community_provider.dart';
|
||||
import '../../shared/push/push_presentation_cache.dart';
|
||||
import '../../shared/push/push_presentation_export_recovery.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme_provider.dart';
|
||||
import '../../shared/utils/string_utils.dart';
|
||||
@@ -24,7 +20,6 @@ import 'unread_badge/observed_unread_event.dart';
|
||||
import 'unread_badge/should_notify_for_event.dart';
|
||||
|
||||
part 'channel_directory.dart';
|
||||
part 'channel_push_cache.dart';
|
||||
part 'channel_member_snapshots.dart';
|
||||
part 'channels_provider_lifecycle.dart';
|
||||
|
||||
@@ -44,10 +39,6 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1';
|
||||
/// for any visible channel event kind. Chunks stay within the relay's explicit
|
||||
/// channel cap and incoming events bump `lastMessageAt` for their channel.
|
||||
class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
final _pushExport = PushPresentationExportRecovery();
|
||||
bool _pushCacheExporting = false;
|
||||
bool _pushCacheDirty = false;
|
||||
|
||||
static const _backstopInterval = Duration(seconds: 60);
|
||||
|
||||
final Map<String, _LiveChunkSubscription> _liveSubscriptionsByChunk = {};
|
||||
@@ -215,7 +206,6 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}) async {
|
||||
final myPk = ref.read(myPubkeyProvider);
|
||||
if (myPk == null) throw StateError('No signing identity available');
|
||||
final communityID = ref.read(activeCommunityProvider).value?.id;
|
||||
_loadThreadInterestStores(myPk);
|
||||
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
@@ -342,12 +332,6 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
// Use the membership snapshots already fetched above for both Huddle
|
||||
// linkage validation and member-count hydration.
|
||||
if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents);
|
||||
unawaited(
|
||||
_exportPushCache(communityID, dedupedMetas, [
|
||||
...memberships,
|
||||
...memberEvents,
|
||||
]),
|
||||
);
|
||||
final memberCounts = _memberCountsByChannelId(memberEvents);
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
final count = memberCounts[channels[i].id];
|
||||
|
||||
@@ -2,9 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../community/community_provider.dart';
|
||||
import '../push/push_presentation_cache.dart';
|
||||
import '../push/push_presentation_export_recovery.dart';
|
||||
import '../relay/relay.dart';
|
||||
import 'user_profile.dart';
|
||||
import 'profile_event_parser.dart';
|
||||
@@ -19,7 +16,6 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
static const _maximumProfilesPerQuery = 1000;
|
||||
|
||||
final Set<String> _pending = {};
|
||||
final _pushExport = PushPresentationExportRecovery();
|
||||
final Map<String, ({int createdAt, String eventId})> _profileEventOrders = {};
|
||||
int _generation = 0;
|
||||
bool _flushInFlight = false;
|
||||
@@ -131,7 +127,6 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
final generation = _generation;
|
||||
var succeeded = false;
|
||||
try {
|
||||
final communityID = ref.read(activeCommunityProvider).value?.id;
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
var remaining = pubkeys.length;
|
||||
for (final batch in _profileQueryBatches(pubkeys)) {
|
||||
@@ -143,17 +138,10 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
||||
if (!await _verifyAndMerge(events, generation)) return;
|
||||
remaining -= batch.length;
|
||||
if (remaining == 0) {
|
||||
// All requested pages are ready. Native export does not gate readers.
|
||||
// All requested pages are ready.
|
||||
succeeded = true;
|
||||
completer?.complete(true);
|
||||
}
|
||||
if (communityID != null) {
|
||||
// Drain each page before fetching another, retaining at most one raw
|
||||
// response while later requests coalesce as pubkeys in _pending.
|
||||
await _pushExport.export(
|
||||
() => cacheBuzzPushProfileEvents(communityID, events),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Silently fail — non-gating callers will just show pubkeys.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,8 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../animated_avatar.dart';
|
||||
import '../community/community_provider.dart';
|
||||
import '../emoji/emoji_avatar.dart';
|
||||
import '../emoji/native_emoji_glyph.dart';
|
||||
import '../push/push_presentation_cache.dart';
|
||||
import '../relay/relay.dart';
|
||||
|
||||
part 'avatar_image_native.dart';
|
||||
@@ -83,7 +81,6 @@ class AvatarImageContent extends ConsumerStatefulWidget {
|
||||
|
||||
class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
|
||||
late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl);
|
||||
String? _scheduledPushAvatar;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AvatarImageContent oldWidget) {
|
||||
@@ -96,7 +93,6 @@ class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final centeredFallback = Center(child: widget.fallback);
|
||||
final communityID = ref.watch(activeCommunityProvider).value?.id;
|
||||
|
||||
return switch (_source) {
|
||||
_EmojiAvatarSource(:final emoji, :final color) => ColoredBox(
|
||||
@@ -121,43 +117,19 @@ class _AvatarImageContentState extends ConsumerState<AvatarImageContent> {
|
||||
errorBuilder: (_, _, _) => centeredFallback,
|
||||
),
|
||||
_RasterDataAvatarSource(:final bytes) => _rasterImage(
|
||||
communityID: communityID,
|
||||
sourceURL: widget.imageUrl,
|
||||
bytes: bytes,
|
||||
fallback: centeredFallback,
|
||||
),
|
||||
_NetworkAvatarSource(:final url) => MediaImage(
|
||||
url: url,
|
||||
fit: widget.fit,
|
||||
onBytesLoaded: communityID == null
|
||||
? null
|
||||
: (bytes) => unawaited(
|
||||
cacheBuzzPushAvatarFromLoadedBytes(communityID, url, bytes),
|
||||
),
|
||||
errorBuilder: (_, _, _) => centeredFallback,
|
||||
),
|
||||
null => centeredFallback,
|
||||
};
|
||||
}
|
||||
|
||||
Widget _rasterImage({
|
||||
required String? communityID,
|
||||
required String? sourceURL,
|
||||
required Uint8List bytes,
|
||||
required Widget fallback,
|
||||
}) {
|
||||
if (communityID != null && sourceURL != null) {
|
||||
final identity = '$communityID\u0000$sourceURL';
|
||||
if (_scheduledPushAvatar != identity) {
|
||||
_scheduledPushAvatar = identity;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _scheduledPushAvatar != identity) return;
|
||||
unawaited(
|
||||
cacheBuzzPushAvatarFromLoadedBytes(communityID, sourceURL, bytes),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Widget _rasterImage({required Uint8List bytes, required Widget fallback}) {
|
||||
return Image.memory(
|
||||
bytes,
|
||||
fit: widget.fit,
|
||||
|
||||
@@ -5,35 +5,18 @@ import 'package:buzz/shared/community/community.dart';
|
||||
import 'package:buzz/shared/community/community_provider.dart';
|
||||
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const bridge = MethodChannel('buzz/push');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
test(
|
||||
'pending profiles drain in bounded queries after delayed export',
|
||||
'pending profiles drain in bounded queries after a delayed relay query',
|
||||
() async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final entered = Completer<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(
|
||||
nostr.Event.from(
|
||||
kind: 0,
|
||||
@@ -43,7 +26,15 @@ void main() {
|
||||
secretKey: '1'.padLeft(64, '0'),
|
||||
).toMap(),
|
||||
);
|
||||
final session = _ClampingSession(first: signed);
|
||||
final session = _ClampingSession(
|
||||
first: signed,
|
||||
beforeFetch: (index) async {
|
||||
if (index == 0) {
|
||||
entered.complete();
|
||||
await release.future;
|
||||
}
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
@@ -60,7 +51,7 @@ void main() {
|
||||
addTearDown(container.dispose);
|
||||
await container.read(activeCommunityProvider.future);
|
||||
final cache = container.read(userCacheProvider.notifier);
|
||||
expect(await cache.preload([signed.pubkey]), isTrue);
|
||||
final first = cache.preload([signed.pubkey]);
|
||||
await entered.future.timeout(const Duration(seconds: 5));
|
||||
final keys = List.generate(1001, (i) => 'profile-$i');
|
||||
var completed = false;
|
||||
@@ -76,6 +67,7 @@ void main() {
|
||||
} finally {
|
||||
release.complete();
|
||||
}
|
||||
expect(await first, isTrue);
|
||||
expect(await pending, isTrue);
|
||||
expect(await duplicate, isTrue);
|
||||
expect(container.read(userCacheProvider).keys, containsAll(keys));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/widgets/avatar_image.dart';
|
||||
import 'package:buzz/shared/emoji/native_emoji_glyph.dart';
|
||||
import 'package:buzz/shared/push/push_presentation_cache.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -29,15 +28,6 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
test('accepts bounded raster data avatars for the push cache', () {
|
||||
expect(isCacheablePushAvatarSource('data:image/png;base64,AA=='), isTrue);
|
||||
expect(
|
||||
isCacheablePushAvatarSource('data:image/svg+xml;base64,AA=='),
|
||||
isFalse,
|
||||
);
|
||||
expect(isCacheablePushAvatarSource('data:image/png;base64,%%%'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('clips agents to a 30 percent squircle', (tester) async {
|
||||
await tester.pumpWidget(subject(null, isAgent: true));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user