From cd983a9d45774358a64b9cfaeef668d987a7b362 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 17 Apr 2025 14:13:33 +0300 Subject: [PATCH] Introduce a `NotificationHandler` object to split out notification content handling from the rest of the NSE core logic. --- ElementX.xcodeproj/project.pbxproj | 4 + NSE/Sources/NotificationHandler.swift | 191 ++++++++++++++++++ .../NotificationServiceExtension.swift | 189 ++--------------- 3 files changed, 207 insertions(+), 177 deletions(-) create mode 100644 NSE/Sources/NotificationHandler.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index ba8a486ca..ddd3d3d5e 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -823,6 +823,7 @@ A009BDFB0A6816D4C392ADCB /* SettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF715D4FD4710EBB637D661 /* SettingsScreenViewModelProtocol.swift */; }; A021827B528F1EDC9101CA58 /* AppCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBC776F301D374A3298C69DA /* AppCoordinatorProtocol.swift */; }; A0601810597769B81C2358AF /* EncryptionResetPasswordScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2B5274C1D3D2999D643786 /* EncryptionResetPasswordScreenViewModelProtocol.swift */; }; + A07178337F3C0B208B5A77A8 /* NotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB6ED50FE104992419310EEB /* NotificationHandler.swift */; }; A0861B727B273B5B3DD7FBF6 /* KnockRequestsListScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09227E671DB30795C43FFFD /* KnockRequestsListScreenViewModel.swift */; }; A0868BDE84D2140A885BE3C9 /* EncryptionResetScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8562F4D7DE073BC32902AB /* EncryptionResetScreenViewModelProtocol.swift */; }; A0D7E5BD0298A97DCBDCE40B /* Version in Frameworks */ = {isa = PBXBuildFile; productRef = A05AF81DDD14AD58CB0E1B9B /* Version */; }; @@ -2276,6 +2277,7 @@ BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionProtocol.swift; sourceTree = ""; }; BB576F4118C35E6B5124FA22 /* test_apple_image.heic */ = {isa = PBXFileReference; path = test_apple_image.heic; sourceTree = ""; }; BB5B00A014307CE37B2812CD /* TimelineViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewModelProtocol.swift; sourceTree = ""; }; + BB6ED50FE104992419310EEB /* NotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHandler.swift; sourceTree = ""; }; BB8BC4C791D0E88CFCF4E5DF /* ServerSelectionScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenCoordinator.swift; sourceTree = ""; }; BBEC57C204D77908E355EF42 /* AudioRecorderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderProtocol.swift; sourceTree = ""; }; BC51BF90469412ABDE658CDD /* portrait_test_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = portrait_test_image.jpg; sourceTree = ""; }; @@ -4705,6 +4707,7 @@ isa = PBXGroup; children = ( D7BB243B26D54EF1A0C422C0 /* NotificationContentBuilder.swift */, + BB6ED50FE104992419310EEB /* NotificationHandler.swift */, 27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */, 566F2B84465726112B830CF6 /* Other */, ); @@ -6709,6 +6712,7 @@ 4DEEFB73181C3B023DB42686 /* NetworkMonitorProtocol.swift in Sources */, 5C02841B2A86327B2C377682 /* NotificationConstants.swift in Sources */, 2689D22EF1D10D22B0A4DAEA /* NotificationContentBuilder.swift in Sources */, + A07178337F3C0B208B5A77A8 /* NotificationHandler.swift in Sources */, 5D70FAE4D2BF4553AFFFFE41 /* NotificationItemProxy.swift in Sources */, B89990DD875B0B603D4D4332 /* NotificationItemProxyProtocol.swift in Sources */, B14BC354E56616B6B7D9A3D7 /* NotificationServiceExtension.swift in Sources */, diff --git a/NSE/Sources/NotificationHandler.swift b/NSE/Sources/NotificationHandler.swift new file mode 100644 index 000000000..bf80da5d6 --- /dev/null +++ b/NSE/Sources/NotificationHandler.swift @@ -0,0 +1,191 @@ +// +// Copyright 2022-2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. +// + +import CallKit +import MatrixRustSDK +import UserNotifications + +class NotificationHandler { + private let settings: CommonSettingsProtocol + private let contentHandler: (UNNotificationContent) -> Void + private var notificationContent: UNMutableNotificationContent + private let tag: String + + private let notificationContentBuilder: NotificationContentBuilder + + init(settings: CommonSettingsProtocol, + contentHandler: @escaping (UNNotificationContent) -> Void, + notificationContent: UNMutableNotificationContent, + tag: String) { + self.settings = settings + self.contentHandler = contentHandler + self.notificationContent = notificationContent + self.tag = tag + + let eventStringBuilder = RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(mentionBuilder: PlainMentionBuilder()), + destination: .notification) + + notificationContentBuilder = NotificationContentBuilder(messageEventStringBuilder: eventStringBuilder, + settings: settings) + } + + func processEvent(_ eventID: String, + roomID: String, + userSession: NSEUserSession) async { + MXLog.info("\(tag) Processing event: \(eventID) in room: \(roomID)") + + guard let notificationItemProxy = await userSession.notificationItemProxy(roomID: roomID, eventID: eventID) else { + MXLog.error("\(tag) Failed retrieving notification item") + discardNotification() + return + } + + switch await preprocessNotification(notificationItemProxy) { + case .processedShouldDiscard, .unsupportedShouldDiscard: + discardNotification() + case .shouldDisplay: + await notificationContentBuilder.process(notificationContent: ¬ificationContent, + notificationItem: notificationItemProxy, + mediaProvider: userSession.mediaProvider) + + deliverNotification() + } + } + + func handleTimeExpiration() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content + MXLog.info("\(tag) Extension time will expire") + deliverNotification() + } + + // MARK: - Private + + private func deliverNotification() { + MXLog.info("\(tag) Delivering notification") + contentHandler(notificationContent) + } + + private func discardNotification() { + MXLog.info("\(tag) Discarding notification") + + let content = UNMutableNotificationContent() + + if let unreadCount = notificationContent.unreadCount { + content.badge = NSNumber(value: unreadCount) + } + + contentHandler(content) + } + + private func preprocessNotification(_ itemProxy: NotificationItemProxyProtocol) async -> NotificationProcessingResult { + guard case let .timeline(event) = itemProxy.event else { + return .shouldDisplay + } + + switch try? event.eventType() { + case .messageLike(let content): + switch content { + case .poll, + .roomEncrypted, + .sticker: + return .shouldDisplay + case .roomMessage(let messageType, _): + switch messageType { + case .emote, .image, .audio, .video, .file, .notice, .text, .location: + return .shouldDisplay + case .other: + return .unsupportedShouldDiscard + } + case .roomRedaction(let redactedEventID, _): + guard let redactedEventID else { + MXLog.error("Unable to handle redact notification due to missing event ID") + return .processedShouldDiscard + } + + let deliveredNotifications = await UNUserNotificationCenter.current().deliveredNotifications() + + if let targetNotification = deliveredNotifications.first(where: { $0.request.content.eventID == redactedEventID }) { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [targetNotification.request.identifier]) + } + + return .processedShouldDiscard + case .callNotify(let notifyType): + return await handleCallNotification(notifyType: notifyType, + timestamp: event.timestamp(), + roomID: itemProxy.roomID, + roomDisplayName: itemProxy.roomDisplayName) + case .callAnswer, + .callInvite, + .callHangup, + .callCandidates, + .keyVerificationReady, + .keyVerificationStart, + .keyVerificationCancel, + .keyVerificationAccept, + .keyVerificationKey, + .keyVerificationMac, + .keyVerificationDone, + .reactionContent: + return .unsupportedShouldDiscard + } + case .state: + return .unsupportedShouldDiscard + case .none: + return .unsupportedShouldDiscard + } + } + + /// Handle incoming call notifications. + /// - Returns: A boolean indicating whether the notification was handled and should now be discarded. + private func handleCallNotification(notifyType: NotifyType, + timestamp: Timestamp, + roomID: String, + roomDisplayName: String) async -> NotificationProcessingResult { + // Handle incoming VoIP calls, show the native OS call screen + // https://developer.apple.com/documentation/callkit/sending-end-to-end-encrypted-voip-calls + // + // The way this works is the following: + // - the NSE receives the notification and decrypts it + // - checks if it's still time relevant (max 10 seconds old) and whether it should ring + // - otherwise it goes on to show it as a normal notification + // - if it should ring then it discards the notification but invokes `reportNewIncomingVoIPPushPayload` + // so that the main app can handle it + // - the main app picks this up in `PKPushRegistry.didReceiveIncomingPushWith` and + // `CXProvider.reportNewIncomingCall` to show the system UI and handle actions on it. + // N.B. this flow works properly only when background processing capabilities are enabled + guard notifyType == .ring else { + MXLog.info("Non-ringing call notification, handling as push notification") + return .shouldDisplay + } + + let timestamp = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) + guard abs(timestamp.timeIntervalSinceNow) < ElementCallServiceNotificationDiscardDelta else { + MXLog.info("Call notification is too old, handling as push notification") + return .shouldDisplay + } + + let payload = [ElementCallServiceNotificationKey.roomID.rawValue: roomID, + ElementCallServiceNotificationKey.roomDisplayName.rawValue: roomDisplayName] + + do { + try await CXProvider.reportNewIncomingVoIPPushPayload(payload) + MXLog.info("Call notification delegated to CallKit") + } catch { + MXLog.error("Failed reporting voip call with error: \(error). Handling as push notification") + return .shouldDisplay + } + + return .processedShouldDiscard + } + + private enum NotificationProcessingResult { + case shouldDisplay + case processedShouldDiscard + case unsupportedShouldDiscard + } +} diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index 73c8646c5..a90498ca6 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -5,8 +5,6 @@ // Please see LICENSE files in the repository root for full details. // -import CallKit -import Intents import MatrixRustSDK import UserNotifications @@ -34,20 +32,12 @@ private let settings: CommonSettingsProtocol = AppSettings() private let keychainController = KeychainController(service: .sessions, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) -private let eventStringBuilder = RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(mentionBuilder: PlainMentionBuilder()), - destination: .notification) - -private let notificationContentBuilder = NotificationContentBuilder(messageEventStringBuilder: eventStringBuilder, - settings: settings) - class NotificationServiceExtension: UNNotificationServiceExtension { - private var contentHandler: ((UNNotificationContent) -> Void)! - private var notificationContent: UNMutableNotificationContent! + private var notificationHandler: NotificationHandler? private let appHooks = AppHooks() - + deinit { - cleanUp() ExtensionLogger.logMemory(with: tag) MXLog.info("\(tag) deinit") } @@ -72,13 +62,16 @@ class NotificationServiceExtension: UNNotificationServiceExtension { } Target.nse.configure(logLevel: settings.logLevel, traceLogPacks: settings.traceLogPacks) - - self.contentHandler = contentHandler - notificationContent = mutableContent + + notificationHandler = NotificationHandler(settings: settings, + contentHandler: contentHandler, + notificationContent: mutableContent, + tag: tag) MXLog.info("\(tag) #########################################") ExtensionLogger.logMemory(with: tag) + MXLog.info("\(tag) Received payload: \(request.content.userInfo)") Task { @@ -92,9 +85,9 @@ class NotificationServiceExtension: UNNotificationServiceExtension { ExtensionLogger.logMemory(with: tag) MXLog.info("\(tag) Configured user session") - await processEvent(eventID, - roomID: roomID, - userSession: userSession) + await notificationHandler?.processEvent(eventID, + roomID: roomID, + userSession: userSession) } catch { MXLog.error("Failed creating user session with error: \(error)") } @@ -102,170 +95,12 @@ class NotificationServiceExtension: UNNotificationServiceExtension { } override func serviceExtensionTimeWillExpire() { - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - MXLog.warning("\(tag) Extension time will expire") - deliverNotification() + notificationHandler?.handleTimeExpiration() } // MARK: - Private - - private func processEvent(_ eventID: String, - roomID: String, - userSession: NSEUserSession) async { - MXLog.info("\(tag) Processing event: \(eventID) in room: \(roomID)") - - guard let notificationItemProxy = await userSession.notificationItemProxy(roomID: roomID, eventID: eventID) else { - MXLog.error("\(tag) Failed retrieving notification item") - discardNotification() - return - } - - switch await preprocessNotification(notificationItemProxy) { - case .processedShouldDiscard, .unsupportedShouldDiscard: - discardNotification() - case .shouldDisplay: - await notificationContentBuilder.process(notificationContent: ¬ificationContent, - notificationItem: notificationItemProxy, - mediaProvider: userSession.mediaProvider) - - deliverNotification() - } - } - private func deliverNotification() { - MXLog.info("\(tag) Displaying notification") - - contentHandler(notificationContent) - cleanUp() - } - - private func discardNotification() { - MXLog.info("\(tag) Discarding notification") - - let content = UNMutableNotificationContent() - - if let unreadCount = notificationContent.unreadCount { - content.badge = NSNumber(value: unreadCount) - } - - contentHandler(content) - cleanUp() - } - private var tag: String { "[NSE][\(Unmanaged.passUnretained(self).toOpaque())][\(Unmanaged.passUnretained(Thread.current).toOpaque())][\(ProcessInfo.processInfo.processIdentifier)]" } - - private func cleanUp() { - contentHandler = nil - notificationContent = nil - } - - private func preprocessNotification(_ itemProxy: NotificationItemProxyProtocol) async -> NotificationProcessingResult { - guard case let .timeline(event) = itemProxy.event else { - return .shouldDisplay - } - - switch try? event.eventType() { - case .messageLike(let content): - switch content { - case .poll, - .roomEncrypted, - .sticker: - return .shouldDisplay - case .roomMessage(let messageType, _): - switch messageType { - case .emote, .image, .audio, .video, .file, .notice, .text, .location: - return .shouldDisplay - case .other: - return .unsupportedShouldDiscard - } - case .roomRedaction(let redactedEventID, _): - guard let redactedEventID else { - MXLog.error("Unable to handle redact notification due to missing event ID") - return .processedShouldDiscard - } - - let deliveredNotifications = await UNUserNotificationCenter.current().deliveredNotifications() - - if let targetNotification = deliveredNotifications.first(where: { $0.request.content.eventID == redactedEventID }) { - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [targetNotification.request.identifier]) - } - - return .processedShouldDiscard - case .callNotify(let notifyType): - return await handleCallNotification(notifyType: notifyType, - timestamp: event.timestamp(), - roomID: itemProxy.roomID, - roomDisplayName: itemProxy.roomDisplayName) - case .callAnswer, - .callInvite, - .callHangup, - .callCandidates, - .keyVerificationReady, - .keyVerificationStart, - .keyVerificationCancel, - .keyVerificationAccept, - .keyVerificationKey, - .keyVerificationMac, - .keyVerificationDone, - .reactionContent: - return .unsupportedShouldDiscard - } - case .state: - return .unsupportedShouldDiscard - case .none: - return .unsupportedShouldDiscard - } - } - - /// Handle incoming call notifications. - /// - Returns: A boolean indicating whether the notification was handled and should now be discarded. - private func handleCallNotification(notifyType: NotifyType, - timestamp: Timestamp, - roomID: String, - roomDisplayName: String) async -> NotificationProcessingResult { - // Handle incoming VoIP calls, show the native OS call screen - // https://developer.apple.com/documentation/callkit/sending-end-to-end-encrypted-voip-calls - // - // The way this works is the following: - // - the NSE receives the notification and decrypts it - // - checks if it's still time relevant (max 10 seconds old) and whether it should ring - // - otherwise it goes on to show it as a normal notification - // - if it should ring then it discards the notification but invokes `reportNewIncomingVoIPPushPayload` - // so that the main app can handle it - // - the main app picks this up in `PKPushRegistry.didReceiveIncomingPushWith` and - // `CXProvider.reportNewIncomingCall` to show the system UI and handle actions on it. - // N.B. this flow works properly only when background processing capabilities are enabled - guard notifyType == .ring else { - MXLog.info("Non-ringing call notification, handling as push notification") - return .shouldDisplay - } - - let timestamp = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) - guard abs(timestamp.timeIntervalSinceNow) < ElementCallServiceNotificationDiscardDelta else { - MXLog.info("Call notification is too old, handling as push notification") - return .shouldDisplay - } - - let payload = [ElementCallServiceNotificationKey.roomID.rawValue: roomID, - ElementCallServiceNotificationKey.roomDisplayName.rawValue: roomDisplayName] - - do { - try await CXProvider.reportNewIncomingVoIPPushPayload(payload) - MXLog.info("Call notification delegated to CallKit") - } catch { - MXLog.error("Failed reporting voip call with error: \(error). Handling as push notification") - return .shouldDisplay - } - - return .processedShouldDiscard - } - - private enum NotificationProcessingResult { - case shouldDisplay - case processedShouldDiscard - case unsupportedShouldDiscard - } }