import BuzzPushKit import Foundation import Intents import Security import UserNotifications final class NotificationService: UNNotificationServiceExtension { private var handoff: BuzzNotificationHandoff? 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) } }