mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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>
259 lines
9.8 KiB
Swift
259 lines
9.8 KiB
Swift
import BuzzPushKit
|
|
import Foundation
|
|
import Intents
|
|
import Security
|
|
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(
|
|
containerURL: ageRestrictionContainerURL, deletion: interactionDeletionDeadline)
|
|
private lazy var communicationPresenter = BuzzCommunicationNotificationPresenter(
|
|
donate: { interaction, completion in interaction.donate(completion: completion) },
|
|
deleteAllInteractions: { [cleanup = interactionCleanup] completion in
|
|
cleanup.request(completion: completion)
|
|
},
|
|
updateContent: { content, intent in try content.updating(from: intent) }
|
|
)
|
|
private let interactionDeletionDeadline = BuzzInteractionDeletionDeadline(
|
|
timeout: 5,
|
|
deleteAllInteractions: { completion in
|
|
INInteraction.deleteAll(completion: completion)
|
|
},
|
|
scheduleTimeout: { delay, action in
|
|
DispatchQueue.global(qos: .utility).asyncAfter(
|
|
deadline: .now() + delay,
|
|
execute: action
|
|
)
|
|
}
|
|
)
|
|
private lazy var appGroupIdentifier =
|
|
Bundle.main.object(
|
|
forInfoDictionaryKey: "BuzzAppGroupIdentifier"
|
|
) as? String
|
|
private lazy var resolver: BuzzPushNotificationResolving = {
|
|
let keychainAccessGroup =
|
|
Bundle.main.object(
|
|
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
|
|
) as? String
|
|
return BuzzPushNotificationResolver(
|
|
session: .shared,
|
|
loadCommunitiesData: { [self] in
|
|
Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier)
|
|
},
|
|
loadPrivateKey: { communityID in
|
|
Self.loadPrivateKey(
|
|
communityID: communityID,
|
|
keychainAccessGroup: keychainAccessGroup
|
|
)
|
|
},
|
|
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 {
|
|
finish(request.content)
|
|
return
|
|
}
|
|
bestAttemptContent = content
|
|
var cleanUserInfo = content.userInfo
|
|
cleanUserInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey)
|
|
content.userInfo = cleanUserInfo
|
|
|
|
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 {
|
|
content.subtitle = subtitle
|
|
}
|
|
if let threadIdentifier = resolution.threadIdentifier {
|
|
content.threadIdentifier = threadIdentifier
|
|
}
|
|
if let navigationTarget = resolution.navigationTarget {
|
|
var userInfo = content.userInfo
|
|
userInfo[BuzzPushNavigationTarget.userInfoKey] = navigationTarget.userInfoValue
|
|
content.userInfo = userInfo
|
|
}
|
|
self.bestAttemptContent = content
|
|
self.communicationPresenter.present(
|
|
ordinaryContent: content,
|
|
resolution: resolution,
|
|
isStillAllowed: { [weak self] in
|
|
guard let self else { return false }
|
|
return self.isCurrent(scope) && !self.isAgeRestricted()
|
|
},
|
|
onDeletionFailure: { error in
|
|
NSLog("Notification cleanup retry failed: %@", String(describing: error))
|
|
}
|
|
) { [weak self] specializedContent in
|
|
self?.finish(specializedContent)
|
|
}
|
|
return
|
|
}
|
|
self.finish(content)
|
|
}
|
|
}
|
|
|
|
override func serviceExtensionTimeWillExpire() {
|
|
if let bestAttemptContent {
|
|
finish(bestAttemptContent)
|
|
}
|
|
}
|
|
|
|
private func finish(_ content: UNNotificationContent) {
|
|
handoff?.finish(
|
|
resolvedScope.map { isCurrent($0) } == false
|
|
? Self.restrictedFallback(from: content) : content,
|
|
restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content),
|
|
handoffIfAllowed: { deliver in
|
|
BuzzAgeRestrictionSession.handoffIfAllowed(
|
|
containerURL: ageRestrictionContainerURL, deliver: deliver)
|
|
}
|
|
) { [self] in
|
|
// The service deadline cannot wait for Intents cleanup. The safe content
|
|
// has already been handed back synchronously, including on expiration.
|
|
let center = UNUserNotificationCenter.current()
|
|
center.removeAllDeliveredNotifications()
|
|
center.removeAllPendingNotificationRequests()
|
|
interactionCleanup.request { error in
|
|
if error != nil { NSLog("Notification cleanup failed; age access is unchanged.") }
|
|
center.removeAllDeliveredNotifications()
|
|
center.removeAllPendingNotificationRequests()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var ageRestrictionContainerURL: URL? {
|
|
appGroupIdentifier.flatMap {
|
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0)
|
|
}
|
|
}
|
|
|
|
private func isAgeRestricted() -> Bool {
|
|
BuzzAgeRestrictionSession.isRestricted(containerURL: ageRestrictionContainerURL)
|
|
}
|
|
|
|
private static func restrictedFallback(
|
|
from content: UNNotificationContent
|
|
) -> UNMutableNotificationContent {
|
|
let fallback =
|
|
(content.mutableCopy() as? UNMutableNotificationContent)
|
|
?? UNMutableNotificationContent()
|
|
fallback.title = "Buzz"
|
|
fallback.subtitle = ""
|
|
fallback.body = "Open Buzz to view this message."
|
|
fallback.threadIdentifier = ""
|
|
var userInfo = fallback.userInfo
|
|
userInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey)
|
|
fallback.userInfo = userInfo
|
|
return fallback
|
|
}
|
|
|
|
private static func loadPrivateKey(
|
|
communityID: String,
|
|
keychainAccessGroup: String?
|
|
) -> String? {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "buzz.push.nse.signing",
|
|
kSecAttrAccount as String: communityID,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
]
|
|
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 static func loadPushSnapshotData(appGroupIdentifier: String?) -> Data? {
|
|
loadAppGroupData(
|
|
fileName: BuzzPushPresentationCacheStore.fileName,
|
|
appGroupIdentifier: appGroupIdentifier,
|
|
maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes
|
|
)
|
|
}
|
|
|
|
private static func loadAppGroupData(
|
|
fileName: String,
|
|
appGroupIdentifier: String?,
|
|
maximumBytes: Int? = nil
|
|
) -> Data? {
|
|
guard let appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
)
|
|
else { return nil }
|
|
let fileURL = container.appendingPathComponent(fileName)
|
|
if let maximumBytes {
|
|
guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
|
let fileSize = values.fileSize,
|
|
fileSize <= maximumBytes
|
|
else { return nil }
|
|
}
|
|
return try? Data(contentsOf: fileURL)
|
|
}
|
|
}
|