Files
buzz/mobile/ios/NotificationService/NotificationService.swift
Tom Brow 779af8886c fix(mobile): fail open when age checks are unavailable (#7714)
Age-check errors previously blocked Buzz startup. Normal app startup now
proceeds immediately; only a valid under-18 response from the current
launch request can restrict access.

Age check enforcement remains disabled at compile time by default.
Builds can opt in with `--dart-define=BUZZ_AGE_GATING_ENABLED=true`.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-17 16:44:08 -07:00

211 lines
7.4 KiB
Swift

import BuzzPushKit
import Foundation
import Intents
import Security
import UserNotifications
final class NotificationService: UNNotificationServiceExtension {
private var handoff: BuzzNotificationHandoff<UNNotificationContent>?
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
)
},
loadPresentationCacheData: { [self] in
Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier)
}
)
}()
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
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 {
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
!(self?.isAgeRestricted() ?? false)
},
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(
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)
}
}