Files
buzz/mobile/ios/NotificationService/NotificationService.swift
Tom Brow d263e2c7f7 fix(mobile): resolve iOS pushes on a fresh connection and retry a stale one (#8722)
## Summary

iOS users were seeing a run of push notifications that just said
"Reconnect to your relay now". That is the push gateway's fixed APNs
body. It shows when the Notification Service Extension (NSE) can't fetch
the message from the relay.

On-device logs from one affected iPhone over a full day showed 12 of 385
NSE runs fail to resolve. Each failed relay `/query`: 10 hit the 8
second timeout and 2 got `NSURLErrorNetworkConnectionLost` (-1005). All
12 ran on a reused pooled connection from `URLSession.shared` in a
long-lived NSE process. That process lived for hours while the network
path changed between VPN and cellular. Of the fresh connections, 0 of
473 failed.

This change:

- Gives each notification its own ephemeral session, so it never
inherits a pooled connection. The session is cancelled when the
extension finishes or expires.
- Retries `/query` once if it times out or loses its connection
mid-request. The retry uses a new session and a freshly signed NIP-98
proof, because the relay rejects a replayed proof. Other failures (HTTP
errors, offline, cancellation) are not retried. The retry is bounded to
one attempt, so the worst case is about 16 seconds, inside the NSE's
roughly 30 second budget.
- Replaces the gateway body with "You have a new message." when the NSE
can't resolve the push. The restricted fallback ("Open Buzz to view this
message.") is unchanged, and so is the gateway payload.

### Related issue

N/A. Reported in Buzz #buzz-mobile.

### Validation

- Added tests in `BuzzPushNotificationResolverTests`:
- A timeout or -1005 is retried once, on a new session and with a
different `Authorization` proof, and resolves.
  - Repeated timeouts stop after 2 attempts.
  - `notConnectedToInternet` is not retried.
- The retry is skipped if the account key changed during the first
attempt.
- `cancel()` (called when the NSE finishes or expires) stops a pending
retry.
- Each new test fails when the behavior it covers is removed: the retry,
the new session, the account recheck, or the cancel guard.
- The full BuzzPushKit `swift test` suite passes.
- A debug simulator build of Runner and the NotificationService
extension succeeded.
- An agent review of the diff found no blockers. Its suggestions to
create the session per `didReceive` and to cancel it on expiry are
included.
- Limitations:
- The NSE target has no unit tests, so the fallback text and the session
lifecycle are covered only by build and review.
- I couldn't send a real push through the NSE. Test phones run a
personal-team bundle that isn't the gateway's APNs topic. In the
simulator, `simctl push` needs notification permission, which the app
only requests after joining a community.
- Under the mobile testing exception, the human-test step does not
apply.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:34:12 +00:00

277 lines
11 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
// A session per notification keeps a long-lived extension process from reusing
// connections that went stale when the network path changed (for example a VPN
// coming up or dropping).
private var session: URLSession?
private var resolver: BuzzPushNotificationResolving?
private func makeResolver(session: URLSession) -> BuzzPushNotificationResolving {
let keychainAccessGroup =
Bundle.main.object(
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
) as? String
return BuzzPushNotificationResolver(
session: session,
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)
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
finish(request.content)
return
}
// Workaround until https://github.com/block/buzz/pull/8431 lands.
if content.sound == nil { content.sound = .default }
// Shown if the relay can't be reached in time. The gateway's fixed body is meant
// to be replaced and reads like an instruction to the user.
content.body = "You have a new message."
restrictedFallbackContent = Self.restrictedFallback(from: content)
bestAttemptContent = content
var cleanUserInfo = content.userInfo
cleanUserInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey)
content.userInfo = cleanUserInfo
let session = URLSession(configuration: .ephemeral)
self.session = session
let resolver = makeResolver(session: session)
self.resolver = resolver
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) {
// Cancelling on expiry also stops a retry that is running or about to start.
resolver?.cancel()
session?.invalidateAndCancel()
session = nil
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)
}
}