diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationCache.swift index dc59b0b371..33ca6a9484 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationCache.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzNotificationCache.swift @@ -81,8 +81,31 @@ public final class BuzzNotificationCache: @unchecked Sendable { var metadata: [BuzzNotificationMetadata] = [] var avatars: [BuzzNotificationAvatar] = [] var avatarRequests: [String: String]? + /// Messages already shown in a notification, newest first. Optional so stores + /// written before it existed still decode. + var presented: [PresentedMessage]? } + private struct PresentedMessage: Codable { + let id: String + /// The event's own `created_at`. Retention follows it, so an entry stays while the + /// relay still ranks the event among the newest results. + let createdAt: Int + /// `createdAt` capped at the time it was shown, so a future-dated event can't hold + /// back later messages. + let watermark: Int + } + + /// How far before the newest shown message a message may be and still be shown. + /// Pushes can resolve out of order, and the relay accepts a `created_at` up to 15 + /// minutes off (`MAX_TIMESTAMP_DRIFT_SECS` in the relay's ingest handler), so a new + /// message can carry an earlier time than one already shown. Anything older is + /// history, not the message a push is for. + public static let presentationReorderWindow = 15 * 60 + /// Retention keeps up to this many entries by `watermark` and as many by `createdAt`, + /// so neither future-dated entries nor a burst of recent ones can evict the other. + static let maximumPresentedMessages = 256 + public init( directory: URL, now: @escaping () -> Date = Date.init, @@ -253,6 +276,61 @@ public final class BuzzNotificationCache: @unchecked Sendable { } } + /// Claims the first of `candidates`, newest first, that hasn't been shown and isn't + /// older than the newest message shown in any of `scopes` by more than + /// ``presentationReorderWindow``, and returns its index. A push carries no event + /// reference, so without this every push whose own event isn't the newest match would + /// repeat the same message. One lock covers the whole selection, so concurrent pushes + /// can't claim against a stale view, and only a claim writes. A revoked scope is + /// skipped. Broken storage must not hide messages, so a candidate whose store can't + /// be read or written is returned without being recorded. + public func claimFirstUnpresented( + _ candidates: [(event: VerifiedNostrEvent, scope: BuzzNotificationCacheScope)], + scopes: [BuzzNotificationCacheScope] + ) throws -> Int? { + try locked { + var shown: [String: Result<[PresentedMessage], Error>] = [:] + func presented(_ scope: BuzzNotificationCacheScope) -> Result<[PresentedMessage], Error> { + if let cached = shown[scope.directoryName] { return cached } + let result = Result { try unlockedAccess(scope) { $0.presented ?? [] } } + shown[scope.directoryName] = result + return result + } + let newestShown = (scopes + candidates.map(\.scope)) + .compactMap { try? presented($0).get().map(\.watermark).max() }.max() + let now = Int(now().timeIntervalSince1970) + for (index, candidate) in candidates.enumerated() { + let watermark = min(candidate.event.createdAt, now) + switch presented(candidate.scope) { + case .failure(BuzzNotificationCacheError.revoked): + continue + case .failure(let error): + NSLog("Notification dedupe unavailable: %@", String(describing: error)) + return index + case .success(let entries): + guard !entries.contains(where: { $0.id == candidate.event.id }) else { continue } + if let newestShown, watermark < newestShown - Self.presentationReorderWindow { + continue + } + } + do { + try unlockedAccess(candidate.scope, write: true) { state in + state.presented = + [PresentedMessage( + id: candidate.event.id, createdAt: candidate.event.createdAt, + watermark: watermark)] + (state.presented ?? []) + } + } catch BuzzNotificationCacheError.revoked { + continue + } catch { + NSLog("Notification dedupe not recorded: %@", String(describing: error)) + } + return index + } + return nil + } + } + /// Deletes abandoned sign-ins on the extension's next execution. public func removeRevokedScopes(keeping scopes: [BuzzNotificationCacheScope]) throws { try locked { @@ -291,53 +369,74 @@ public final class BuzzNotificationCache: @unchecked Sendable { _ 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 - } + try locked { try unlockedAccess(scope, write: write, operation) } + } + + /// ``access(_:write:_:)`` for a caller that already holds the lock. + private func unlockedAccess( + _ scope: BuzzNotificationCacheScope, write: Bool = false, + _ operation: (inout State) throws -> T + ) throws -> T { + 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 } - 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) + 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 } - return result } + 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) } + if let presented = state.presented, let newest = presented.map(\.watermark).max() { + // The age check rejects anything older than the window, so those entries can + // go. A future-dated entry stays while the relay may still rank it first. + let cutoff = newest - Self.presentationReorderWindow + // Entries are newest claim first, so ties keep the most recent claims. + let current = Array(presented.filter { $0.createdAt >= cutoff }.enumerated()) + func newestIDs(by key: (PresentedMessage) -> Int) -> [String] { + current.sorted { + key($0.element) == key($1.element) + ? $0.offset < $1.offset : key($0.element) > key($1.element) + }.prefix(Self.maximumPresentedMessages).map(\.element.id) + } + let retained = Set(newestIDs(by: \.watermark)).union(newestIDs(by: \.createdAt)) + state.presented = current.map(\.element).filter { retained.contains($0.id) } + } + let encoder = JSONEncoder() + var data = try encoder.encode(state) + while data.count > Self.maximumBytes { + if !state.avatars.isEmpty { + state.avatars.removeLast() + } else if !state.metadata.isEmpty { + state.metadata.removeLast() + } else { + throw BuzzNotificationCacheError.oversizedRecord + } + data = try encoder.encode(state) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: file, options: .atomic) + } + return result } private func locked(_ operation: () throws -> T) throws -> T { diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 3def120389..71a539a578 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -54,7 +54,7 @@ public protocol BuzzPushNotificationResolving { func cancel() } -/// Reads configured Buzz communities and resolves their newest unread event. +/// Reads configured Buzz communities and resolves their newest message not yet shown. public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { // Each independent refresh has a hard transfer limit, allowing bounded // content plus JSON escaping, tags, and envelopes. @@ -108,20 +108,18 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { var candidates: [(VerifiedNostrEvent, PushLeaseCommunity)] = [] for community in communities { group.enter() - query(community) { candidate in - if let candidate { - lock.lock() - candidates.append(candidate) - lock.unlock() - } + query(community) { messages in + lock.lock() + candidates += messages.map { ($0, community) } + lock.unlock() group.leave() } } group.notify(queue: .global(qos: .userInitiated)) { - let newest = candidates.max { - $0.0.createdAt == $1.0.createdAt ? $0.0.id > $1.0.id : $0.0.createdAt < $1.0.createdAt + let newestFirst = candidates.sorted { + $0.0.createdAt == $1.0.createdAt ? $0.0.id < $1.0.id : $0.0.createdAt > $1.0.createdAt } - guard let newest else { + guard let newest = self.firstUnpresented(newestFirst, communities: communities) else { completion(nil) return } @@ -129,6 +127,33 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { } } + /// The push doesn't say which event it is for, so take the newest message not yet + /// shown, in any community. Without extension storage, fall back to the newest message. + /// The claim is made before presentation: if presenting then fails or expires, that + /// push shows the generic text and the message isn't offered again, which is better + /// than repeating a message in later notifications. + private func firstUnpresented( + _ newestFirst: [(VerifiedNostrEvent, PushLeaseCommunity)], + communities: [PushLeaseCommunity] + ) -> (VerifiedNostrEvent, PushLeaseCommunity)? { + guard let notificationCache else { return newestFirst.first } + let candidates = newestFirst.compactMap { event, community in + BuzzNotificationCacheScope(community: community).map { (candidate: (event, community), scope: $0) } + } + do { + // Every community's history bounds the others, even one whose query failed, so a + // community with nothing shown yet can't surface its old messages. + return try notificationCache.claimFirstUnpresented( + candidates.map { (event: $0.candidate.0, scope: $0.scope) }, + scopes: communities.compactMap(BuzzNotificationCacheScope.init(community:)) + ).map { candidates[$0].candidate } + } catch { + // Unusable storage must not hide new messages; it can only fail to dedupe them. + NSLog("Notification dedupe unavailable: %@", String(describing: error)) + return candidates.first?.candidate + } + } + private func matchingPrivateKey(for community: PushLeaseCommunity) -> String? { guard let scope = BuzzNotificationCacheScope(community: community), loadCommunities().contains(where: { BuzzNotificationCacheScope(community: $0) == scope }) @@ -147,10 +172,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { private func query( _ community: PushLeaseCommunity, - completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void + completion: @escaping ([VerifiedNostrEvent]) -> Void ) { guard let privateKey = accountPrivateKey(for: community), community.pubkey?.isEmpty == false else { - completion(nil) + completion([]) return } guard @@ -161,7 +186,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { withJSONObject: community.policies.map { $0.filter.queryFilter(since: nil, limit: 10) } ) else { - completion(nil) + completion([]) return } send( @@ -172,19 +197,19 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) else { - completion(nil) + completion([]) return } - let candidate = Self.newestMessage( - events: events.filter { event in - event.hasValidIDAndSignature() - && community.policies.contains { policy in - PushLeaseMatcher.matches(event: event, policy: policy) - } - }, - community: community - ) - completion(candidate.map { ($0, community) }) + completion( + Self.messages( + events: events.filter { event in + event.hasValidIDAndSignature() + && community.policies.contains { policy in + PushLeaseMatcher.matches(event: event, policy: policy) + } + }, + community: community + )) } } @@ -382,12 +407,20 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { events: [VerifiedNostrEvent], community: PushLeaseCommunity ) -> VerifiedNostrEvent? { - guard let mine = community.pubkey?.lowercased() else { return nil } + messages(events: events, community: community).first + } + + /// Messages from others that a notification can show, newest first. + private static func messages( + events: [VerifiedNostrEvent], + community: PushLeaseCommunity + ) -> [VerifiedNostrEvent] { + guard let mine = community.pubkey?.lowercased() else { return [] } return events.filter { $0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind) }.sorted { $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt - }.first + } } static func makeResolution( diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index ad0c6cdc78..f27861c959 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -386,6 +386,155 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) } + /// A push carries no event reference. Each push must show a message not shown before, + /// including after the extension process restarts, instead of the newest match again. + func testEachPushShowsANewMessageAndNeverRepeatsOne() throws { + let snapshot = try snapshotData([community()]) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community())) + let now = Date(timeIntervalSince1970: TimeInterval(Self.now)) + // A fresh cache instance per push models a fresh extension process. + func push() -> String? { + resolve(makeResolver(communitiesData: snapshot, + cacheOverride: BuzzNotificationCache(directory: root, now: { now }, isCurrent: { $0 == scope }), + now: now))?.body + } + var relayEvents: [VerifiedNostrEvent] = [] + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode(relayEvents)) + } + func message(_ content: String, secondsAgo: Int) throws -> VerifiedNostrEvent { + try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: Self.now - secondsAgo, + kind: 9, tags: [["h", Self.channelID]], content: content) + } + let window = BuzzNotificationCache.presentationReorderWindow + + let history = try message("Last week", secondsAgo: 7 * 24 * 3600) + let first = try message("First", secondsAgo: 3600) + relayEvents = [history, first] + XCTAssertEqual(push(), "First", "with nothing shown yet, the newest message") + XCTAssertNil(push(), "a push for a non-message event must not repeat it") + + // Two messages whose pushes resolve out of order: the later push wins the first + // resolution, so the earlier push must still find its own message. + let early = try message("Early", secondsAgo: 60) + let late = try message("Late", secondsAgo: 59) + relayEvents = [late, early, first, history] + XCTAssertEqual(push(), "Late") + XCTAssertEqual(push(), "Early") + XCTAssertNil(push()) + + // Older history is never surfaced by a later push. + let stale = try message("Stale", secondsAgo: 59 + window + 1) + relayEvents = [late, early, stale, first, history] + XCTAssertNil(push(), "a message older than the reorder window is history") + + // A future-dated message is shown once and doesn't hide messages that follow it. + let future = try message("Future", secondsAgo: -3600) + relayEvents = [future, late] + XCTAssertEqual(push(), "Future") + let next = try message("Next", secondsAgo: 0) + relayEvents = [future, next, late] + XCTAssertEqual(push(), "Next") + XCTAssertNil(push()) + } + + func testShownMessagesStayShownAcrossCommunitiesFailuresAndBursts() throws { + let a = community(id: "community-a", relayUrl: "https://a.example") + let b = community(id: "community-b", relayUrl: "https://b.example") + let snapshot = try snapshotData([a, b]) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let scopes = [a, b].compactMap { BuzzNotificationCacheScope(community: $0) } + var clock = Self.now + func push() -> String? { + let now = Date(timeIntervalSince1970: TimeInterval(clock)) + return resolve(makeResolver(communitiesData: snapshot, + privateKeys: ["community-a": Self.privateKey, "community-b": Self.privateKey], + cacheOverride: BuzzNotificationCache( + directory: root, now: { now }, isCurrent: { scopes.contains($0) }), + now: now))?.body + } + var relayEvents: [String: [VerifiedNostrEvent]] = [:] + var failingHosts: Set = [] + URLProtocolStub.handler = { request in + let host = request.url?.host ?? "" + return Self.response(request, status: failingHosts.contains(host) ? 500 : 200, + data: try JSONEncoder().encode(relayEvents[host] ?? [])) + } + func message(_ content: String, createdAt: Int) throws -> VerifiedNostrEvent { + try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: createdAt, + kind: 9, tags: [["h", Self.channelID]], content: content) + } + + // B has shown nothing yet, but A's shown message still marks B's last week as + // history, even when A's query fails. + let mention = try message("A mention", createdAt: clock - 60) + relayEvents["a.example"] = [mention] + relayEvents["b.example"] = [try message("B last week", createdAt: clock - 7 * 24 * 3600)] + XCTAssertEqual(push(), "A mention") + XCTAssertNil(push(), "an old message in another community is not new") + failingHosts = ["a.example"] + XCTAssertNil(push(), "a failed query doesn't drop its community's history") + failingHosts = [] + + // A burst of newer messages doesn't evict one the relay still returns. + var newest = mention + for index in 0..<100 { + clock += 1 + newest = try message("Burst \(index)", createdAt: clock) + relayEvents["a.example"] = [newest, mention] + XCTAssertEqual(push(), "Burst \(index)") + } + XCTAssertNil(push(), "a shown message is not shown again after a burst") + + // A store that can't be written still remembers what it already shows. + let directory = root.appendingPathComponent(scopes[0].directoryName, isDirectory: true) + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory.path) + addTeardownBlock { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path) + } + XCTAssertNil(push(), "a read-only store does not repeat a shown message") + relayEvents["a.example"] = [try message("Unrecorded", createdAt: clock), newest, mention] + XCTAssertEqual(push(), "Unrecorded", "an unwritable store never hides a new message") + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path) + + // A future-dated message stays remembered while the relay keeps returning it first, + // even after later messages push its capped time out of the window. + let future = try message("Future", createdAt: clock + 3600) + relayEvents["a.example"] = [future] + XCTAssertEqual(push(), "Future") + for index in 0..<40 { + clock += 30 + relayEvents["a.example"] = [future, try message("Later \(index)", createdAt: clock)] + XCTAssertEqual(push(), "Later \(index)") + } + XCTAssertNil(push(), "a future-dated message is not shown again") + } + + func testFutureDatedEntriesDoNotEvictAClaimedMessage() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community())) + let cache = BuzzNotificationCache( + directory: root, now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) }, + isCurrent: { $0 == scope }) + func claim(_ event: VerifiedNostrEvent) throws -> Bool { + try cache.claimFirstUnpresented([(event: event, scope: scope)], scopes: [scope]) == 0 + } + for index in 0..<(BuzzNotificationCache.maximumPresentedMessages + 1) { + let ahead = try Self.signedEvent(privateKey: Self.profilePrivateKey, + createdAt: Self.now + 600 + index, kind: 9, tags: [["h", Self.channelID]], + content: "Ahead \(index)") + XCTAssertTrue(try claim(ahead)) + } + let current = try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: Self.now, + kind: 9, tags: [["h", Self.channelID]], content: "Current") + XCTAssertTrue(try claim(current)) + XCTAssertFalse(try claim(current), "a claimed message survives retention") + } + func testFreshVerifiedCacheResolvesSenderAvatarAndChannelWithoutRefresh() throws { let message = try Self.signedEvent( privateKey: Self.profilePrivateKey,