From 32915c58d7a06e458e7cabb2dc1def14e97b964c Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Fri, 31 Jul 2026 19:37:49 -0700 Subject: [PATCH] test(mobile/ios): exercise push notification resolver Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- .../BuzzPushNotificationResolver.swift | 192 +++++++++++++ .../BuzzPushNotificationResolverTests.swift | 262 ++++++++++++++++++ .../NotificationService.swift | 198 +++---------- 3 files changed, 488 insertions(+), 164 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift new file mode 100644 index 0000000000..a80b5988d9 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -0,0 +1,192 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Content resolved from unread Buzz events for a mutable push notification. +public struct BuzzPushResolution: Decodable, Equatable, Sendable { + public let title: String + public let body: String + public let subtitle: String? + public let threadIdentifier: String? + + public init(title: String, body: String, subtitle: String?, threadIdentifier: String?) { + self.title = title + self.body = body + self.subtitle = subtitle + self.threadIdentifier = threadIdentifier + } +} + +/// Resolves the content used to mutate a generic Buzz push notification. +public protocol BuzzPushNotificationResolving { + func resolve(completion: @escaping (BuzzPushResolution?) -> Void) +} + +/// Reads configured Buzz communities and resolves their newest unread event. +public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { + private let session: URLSession + private let defaults: UserDefaults? + private let loadCommunitiesData: () -> Data? + private let loadPrivateKey: (String) -> String? + private let now: () -> Int + + /// Creates a resolver around the notification extension's App Group and Keychain I/O. + public init( + session: URLSession, + defaults: UserDefaults?, + loadCommunitiesData: @escaping () -> Data?, + loadPrivateKey: @escaping (String) -> String?, + now: @escaping () -> Int = { Int(Date().timeIntervalSince1970) } + ) { + self.session = session + self.defaults = defaults + self.loadCommunitiesData = loadCommunitiesData + self.loadPrivateKey = loadPrivateKey + self.now = now + } + + public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { + let loadedCommunities = loadCommunities() + removeStaleWatermarks(activeCommunityIDs: Set(loadedCommunities.map(\.id))) + let communities = loadedCommunities.filter { + $0.pubkey?.isEmpty == false && loadPrivateKey($0.id) != nil + } + guard !communities.isEmpty else { completion(nil); return } + let group = DispatchGroup() + let lock = NSLock() + var candidates: [(BuzzPushResolution, VerifiedNostrEvent, BuzzPushCommunity)] = [] + for community in communities { + group.enter() + query(community) { candidate in + if let candidate { + lock.lock(); candidates.append((candidate.0, candidate.1, community)); lock.unlock() + } + group.leave() + } + } + group.notify(queue: .global(qos: .userInitiated)) { [weak self] in + guard let self else { return } + let newest = candidates.max { + $0.1.createdAt == $1.1.createdAt ? $0.1.id > $1.1.id : $0.1.createdAt < $1.1.createdAt + } + for candidate in candidates { + self.defaults?.set( + PushWatermark.persistedTimestamp(eventTimestamp: candidate.1.createdAt), + forKey: PushWatermark.key(communityID: candidate.2.id) + ) + } + completion(newest?.0) + } + } + + private func query( + _ community: BuzzPushCommunity, + completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void + ) { + guard let privateKey = loadPrivateKey(community.id), let pubkey = community.pubkey else { + completion(nil); return + } + var filter: [String: Any] = ["kinds": [9, 40002, 45001, 45003], "#p": [pubkey], "limit": 10] + let watermarkKey = PushWatermark.key(communityID: community.id) + let storedWatermark = defaults?.integer(forKey: watermarkKey) ?? 0 + let watermark = PushWatermark.queryTimestamp(storedWatermark: storedWatermark) + if watermark != storedWatermark { defaults?.set(watermark, forKey: watermarkKey) } + if let since = PushWatermark.querySince(watermark: watermark) { filter["since"] = since } + guard let body = try? JSONSerialization.data(withJSONObject: [filter]) else { completion(nil); return } + let url = URL(string: "/query", relativeTo: community.relayURL)! + var request = URLRequest(url: url) + request.httpMethod = "POST"; request.httpBody = body; request.timeoutInterval = 8 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, method: "POST", body: body, privateKeyHex: privateKey + ) else { completion(nil); return } + request.setValue(auth, forHTTPHeaderField: "Authorization") + session.dataTask(with: request) { data, response, _ in + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), + let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { completion(nil); return } + completion(Self.decodeResolution( + events: events.filter { + $0.hasValidIDAndSignature() + && PushWatermark.isAcceptable(eventTimestamp: $0.createdAt, now: self.now()) + }, + community: community + )) + }.resume() + } + + static func decodeResolution( + events: [VerifiedNostrEvent], community: BuzzPushCommunity + ) -> (BuzzPushResolution, VerifiedNostrEvent)? { + guard let mine = community.pubkey?.lowercased() else { return nil } + let event = events.filter { + $0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind) + }.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + }.first + guard let event else { return nil } + let body = previewBody(event.content) + guard !body.isEmpty else { return nil } + let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1] + return (BuzzPushResolution( + title: shortPubkey(event.pubkey), body: body, subtitle: community.name, + threadIdentifier: channel ?? community.id + ), event) + } + + static func previewBody(_ content: String) -> String { + var result = content.replacingOccurrences(of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression) + result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences(of: #"!?\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences(of: #"https?://\S+"#, with: "[link]", options: .regularExpression) + result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) + return result.count > 180 ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result + } + + static func shortPubkey(_ pubkey: String) -> String { + pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey + } + + private func loadCommunities() -> [BuzzPushCommunity] { + guard let data = loadCommunitiesData(), + let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data) + else { return [] } + return decoded.communities + } + + private func removeStaleWatermarks(activeCommunityIDs: Set) { + guard let defaults else { return } + for key in PushWatermark.staleKeys( + in: Array(defaults.dictionaryRepresentation().keys), + activeCommunityIDs: activeCommunityIDs + ) { + defaults.removeObject(forKey: key) + } + } +} + +/// Serialized community configuration shared with the notification service extension. +public struct BuzzPushSnapshot: Decodable, Sendable { + public let communities: [BuzzPushCommunity] +} + +/// Community configuration needed to query unread events for a push notification. +public struct BuzzPushCommunity: Decodable, Equatable, Sendable { + public let id: String + public let name: String + public let relayUrl: String + public let pubkey: String? + + public init(id: String, name: String, relayUrl: String, pubkey: String?) { + self.id = id + self.name = name + self.relayUrl = relayUrl + self.pubkey = pubkey + } + + var relayURL: URL { + URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")! + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift new file mode 100644 index 0000000000..8e5bdecb80 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -0,0 +1,262 @@ +import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif +import XCTest + +@testable import BuzzPushKit + +final class BuzzPushNotificationResolverTests: XCTestCase { + private static let privateKey = String(repeating: "0", count: 63) + "1" + private static let ownPubkey = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + private static let now = Int(Date().timeIntervalSince1970) + private static let gatewayBody = "Reconnect to your relay now" + + override func setUp() { + super.setUp() + URLProtocolStub.reset() + } + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func testResolveReturnsNilWhenCommunitiesDataIsMissing() { + let result = resolve(makeResolver(communitiesData: nil)) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilWhenCommunitiesDataIsUndecodable() { + let result = resolve(makeResolver(communitiesData: Data("not json".utf8))) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilOnKeychainMiss() throws { + let result = resolve(makeResolver( + communitiesData: try snapshotData([community()]), + privateKeys: [:] + )) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilForNon2xxRelayResponse() throws { + URLProtocolStub.handler = { request in + Self.response(request, status: 503, data: Data()) + } + let result = resolve(makeResolver(communitiesData: try snapshotData([community()]))) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testResolveReturnsNilForUndecodableRelayResponse() throws { + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: Data("not events".utf8)) + } + let result = resolve(makeResolver(communitiesData: try snapshotData([community()]))) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testDecodeResolutionFiltersOwnPubkeyEvent() { + let result = BuzzPushNotificationResolver.decodeResolution( + events: [event(pubkey: Self.ownPubkey, content: "This should be filtered")], + community: community() + ) + + XCTAssertNil(result) + } + + func testDecodeResolutionReturnsNilWhenSanitizedPreviewIsEmpty() { + let event = event(content: " \n\t ") + + let result = BuzzPushNotificationResolver.decodeResolution( + events: [event], + community: community() + ) + + XCTAssertNil(result) + } + + func testPreviewBodySanitizesCodeLinksAndWhitespace() { + let content = """ + Before ```swift + print("secret") + ``` `inline` [docs](https://example.com/docs) + ![image](https://example.com/image.png) https://example.com/raw + After + """ + + XCTAssertEqual( + BuzzPushNotificationResolver.previewBody(content), + "Before [code] inline docs image [link] After" + ) + } + + func testPreviewBodyTruncatesTo178CharactersIncludingEllipsis() { + let preview = BuzzPushNotificationResolver.previewBody(String(repeating: "x", count: 200)) + + XCTAssertEqual(preview.count, 178) + XCTAssertEqual(preview, String(repeating: "x", count: 177) + "…") + } + + func testDecodeResolutionUsesLowestIDWhenCreatedAtTies() { + let result = BuzzPushNotificationResolver.decodeResolution( + events: [ + event(id: "a", content: "lower ID", createdAt: Self.now), + event(id: "b", content: "higher ID", createdAt: Self.now), + ], + community: community() + ) + + XCTAssertEqual(result?.1.id, "a") + XCTAssertEqual(result?.0.body, "lower ID") + } + + func testResolveSucceedsAndMutatesGatewayContent() throws { + let event = try JSONDecoder().decode( + VerifiedNostrEvent.self, + from: Data(Self.fixtureEvent.utf8) + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([event])) + } + + let result = try XCTUnwrap(resolve(makeResolver( + communitiesData: try snapshotData([community()]) + ))) + + XCTAssertNotEqual(result.title, Self.gatewayBody) + XCTAssertNotEqual(result.body, Self.gatewayBody) + XCTAssertEqual(result.title, String(event.pubkey.prefix(8)) + "…") + XCTAssertEqual(result.body, "Hello Buzz") + XCTAssertEqual(result.subtitle, "Community") + XCTAssertEqual(result.threadIdentifier, "channel-id") + } + + private func makeResolver( + communitiesData: Data?, + privateKeys: [String: String] = ["community-id": privateKey] + ) -> BuzzPushNotificationResolver { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return BuzzPushNotificationResolver( + session: URLSession(configuration: configuration), + defaults: nil, + loadCommunitiesData: { communitiesData }, + loadPrivateKey: { privateKeys[$0] }, + now: { Self.now } + ) + } + + private func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? { + let completed = expectation(description: "resolver completed") + var result: BuzzPushResolution? + resolver.resolve { + result = $0 + completed.fulfill() + } + wait(for: [completed], timeout: 2) + return result + } + + private func community( + id: String = "community-id", + name: String = "Community", + relayUrl: String = "https://relay.example", + pubkey: String? = ownPubkey + ) -> BuzzPushCommunity { + BuzzPushCommunity(id: id, name: name, relayUrl: relayUrl, pubkey: pubkey) + } + + private func snapshotData(_ communities: [BuzzPushCommunity]) throws -> Data { + let dictionaries: [[String: Any]] = communities.map { + [ + "id": $0.id, + "name": $0.name, + "relayUrl": $0.relayUrl, + "pubkey": $0.pubkey as Any, + ] + } + return try JSONSerialization.data(withJSONObject: ["communities": dictionaries]) + } + + private func event( + id: String = "event-id", + pubkey: String = "author-pubkey", + content: String, + createdAt: Int = now, + kind: Int = 9, + tags: [[String]] = [] + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: "signature" + ) + } + + private static let fixtureEvent = #""" + {"id":"2d827228c07234a9c30ac19e0d2f01bcc09ca3b1004e95a429b4771a8b42ebb8","sig":"44085c838c0afc8328ce6fb95d4c851021d6b43f15b7f1206ce225a433c2337336837af3df26cdd9301111d16a72508d47130bb2c9206ae4d7dbea336ede1d4d","content":" Hello [Buzz](https://buzz.block.xyz) ","kind":9,"pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1785551670,"tags":[["h","channel-id"]]} + """# + + private static func response( + _ request: URLRequest, + status: Int, + data: Data + ) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } +} + +private final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let handler = Self.handler + Self.lock.unlock() + do { + let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + static func reset() { + lock.lock() + handler = nil + requests = [] + lock.unlock() + } +} diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index b85017ffb4..b77dc90446 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -6,7 +6,27 @@ import UserNotifications final class NotificationService: UNNotificationServiceExtension { private var contentHandler: ((UNNotificationContent) -> Void)? private var bestAttemptContent: UNMutableNotificationContent? - private var resolver: BuzzPushNotificationResolving = BuzzPushNotificationResolver() + private lazy var resolver: BuzzPushNotificationResolving = { + let appGroupIdentifier = Bundle.main.object( + forInfoDictionaryKey: "BuzzAppGroupIdentifier" + ) as? String + let keychainAccessGroup = Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + return BuzzPushNotificationResolver( + session: .shared, + defaults: appGroupIdentifier.flatMap(UserDefaults.init(suiteName:)), + loadCommunitiesData: { + Self.loadCommunitiesData(appGroupIdentifier: appGroupIdentifier) + }, + loadPrivateKey: { communityID in + Self.loadPrivateKey( + communityID: communityID, + keychainAccessGroup: keychainAccessGroup + ) + } + ) + }() override func didReceive( _ request: UNNotificationRequest, @@ -46,148 +66,11 @@ final class NotificationService: UNNotificationServiceExtension { self.contentHandler = nil contentHandler(content) } -} -struct BuzzPushResolution: Decodable { - let title: String - let body: String - let subtitle: String? - let threadIdentifier: String? -} - -protocol BuzzPushNotificationResolving { - func resolve(completion: @escaping (BuzzPushResolution?) -> Void) -} - -final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { - private let session: URLSession - private let appGroupIdentifier: String? - private let keychainAccessGroup: String? - private let defaults: UserDefaults? - - init( - session: URLSession = .shared, - appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String, - keychainAccessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String - ) { - self.session = session - self.appGroupIdentifier = appGroupIdentifier - self.keychainAccessGroup = keychainAccessGroup - defaults = appGroupIdentifier.flatMap(UserDefaults.init(suiteName:)) - } - - func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { - let loadedCommunities = loadCommunities() - removeStaleWatermarks(activeCommunityIDs: Set(loadedCommunities.map(\.id))) - let communities = loadedCommunities.filter { - $0.pubkey?.isEmpty == false && loadPrivateKey(communityID: $0.id) != nil - } - guard !communities.isEmpty else { completion(nil); return } - let group = DispatchGroup() - let lock = NSLock() - var candidates: [(BuzzPushResolution, VerifiedNostrEvent, BuzzPushCommunity)] = [] - for community in communities { - group.enter() - query(community) { candidate in - if let candidate { - lock.lock(); candidates.append((candidate.0, candidate.1, community)); lock.unlock() - } - group.leave() - } - } - group.notify(queue: .global(qos: .userInitiated)) { [weak self] in - guard let self else { return } - let newest = candidates.max { - $0.1.createdAt == $1.1.createdAt ? $0.1.id > $1.1.id : $0.1.createdAt < $1.1.createdAt - } - for candidate in candidates { - self.defaults?.set( - PushWatermark.persistedTimestamp(eventTimestamp: candidate.1.createdAt), - forKey: PushWatermark.key(communityID: candidate.2.id) - ) - } - completion(newest?.0) - } - } - - private func query( - _ community: BuzzPushCommunity, - completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void - ) { - guard let privateKey = loadPrivateKey(communityID: community.id), let pubkey = community.pubkey else { - completion(nil); return - } - var filter: [String: Any] = ["kinds": [9, 40002, 45001, 45003], "#p": [pubkey], "limit": 10] - let watermarkKey = PushWatermark.key(communityID: community.id) - let storedWatermark = defaults?.integer(forKey: watermarkKey) ?? 0 - let watermark = PushWatermark.queryTimestamp(storedWatermark: storedWatermark) - if watermark != storedWatermark { defaults?.set(watermark, forKey: watermarkKey) } - if let since = PushWatermark.querySince(watermark: watermark) { filter["since"] = since } - guard let body = try? JSONSerialization.data(withJSONObject: [filter]) else { completion(nil); return } - let url = URL(string: "/query", relativeTo: community.relayURL)! - var request = URLRequest(url: url) - request.httpMethod = "POST"; request.httpBody = body; request.timeoutInterval = 8 - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - guard let auth = try? NostrHTTPAuth.authorizationHeader( - url: url, method: "POST", body: body, privateKeyHex: privateKey - ) else { completion(nil); return } - request.setValue(auth, forHTTPHeaderField: "Authorization") - session.dataTask(with: request) { data, response, _ in - guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), - let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) - else { completion(nil); return } - completion(Self.decodeResolution( - events: events.filter { - $0.hasValidIDAndSignature() && PushWatermark.isAcceptable(eventTimestamp: $0.createdAt) - }, - community: community - )) - }.resume() - } - - private static func decodeResolution( - events: [VerifiedNostrEvent], community: BuzzPushCommunity - ) -> (BuzzPushResolution, VerifiedNostrEvent)? { - guard let mine = community.pubkey?.lowercased() else { return nil } - let event = events.filter { - $0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind) - }.sorted { - $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt - }.first - guard let event else { return nil } - let body = previewBody(event.content) - guard !body.isEmpty else { return nil } - let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1] - return (BuzzPushResolution( - title: shortPubkey(event.pubkey), body: body, subtitle: community.name, - threadIdentifier: channel ?? community.id - ), event) - } - - private static func previewBody(_ content: String) -> String { - var result = content.replacingOccurrences(of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression) - result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression) - result = result.replacingOccurrences(of: #"!?\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression) - result = result.replacingOccurrences(of: #"https?://\S+"#, with: "[link]", options: .regularExpression) - result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) - return result.count > 180 ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result - } - - private static func shortPubkey(_ pubkey: String) -> String { - pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey - } - - private func removeStaleWatermarks(activeCommunityIDs: Set) { - guard let defaults else { return } - for key in PushWatermark.staleKeys( - in: Array(defaults.dictionaryRepresentation().keys), - activeCommunityIDs: activeCommunityIDs - ) { - defaults.removeObject(forKey: key) - } - } - - private func loadPrivateKey(communityID: String) -> String? { + private static func loadPrivateKey( + communityID: String, + keychainAccessGroup: String? + ) -> String? { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "buzz.push.nse.signing", @@ -195,34 +78,21 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] - if let keychainAccessGroup, !keychainAccessGroup.isEmpty { query[kSecAttrAccessGroup as String] = keychainAccessGroup } + if let keychainAccessGroup, !keychainAccessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = keychainAccessGroup + } var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } return String(data: data, encoding: .utf8) } - private func loadCommunities() -> [BuzzPushCommunity] { + private static func loadCommunitiesData(appGroupIdentifier: String?) -> Data? { guard let appGroupIdentifier, - let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier), - let data = try? Data(contentsOf: container.appendingPathComponent("push-communities.json")), - let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data) - else { return [] } - return decoded.communities - } -} - -struct BuzzPushSnapshot: Decodable { - let communities: [BuzzPushCommunity] -} - -struct BuzzPushCommunity: Decodable { - let id: String - let name: String - let relayUrl: String - let pubkey: String? - - var relayURL: URL { - URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")! + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { return nil } + return try? Data(contentsOf: container.appendingPathComponent("push-communities.json")) } }