// // Copyright 2025 Element Creations Ltd. // Copyright 2022-2025 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 userSession: NSEUserSession private let settings: CommonSettingsProtocol private let contentHandler: (UNNotificationContent) -> Void private var notificationContent: UNMutableNotificationContent private let tag: String private let notificationContentBuilder: NotificationContentBuilder // periphery:ignore - required for instance retention in the rust codebase private var roomInfoObservationToken: TaskHandle? init(userSession: NSEUserSession, settings: CommonSettingsProtocol, contentHandler: @escaping (UNNotificationContent) -> Void, notificationContent: UNMutableNotificationContent, tag: String) { self.userSession = userSession 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, userSession: userSession) } func processEvent(_ eventID: String, roomID: String) async { MXLog.info("\(tag) Processing event: \(eventID) in room: \(roomID)") // Copy over the unread information to the notification badge notificationContent.badge = notificationContent.unreadCount as NSNumber? MXLog.info("\(tag) New badge value: \(notificationContent.badge?.stringValue ?? "nil")") 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() content.badge = notificationContent.unreadCount as NSNumber? MXLog.info("\(tag) New badge value: \(content.badge?.stringValue ?? "nil")") contentHandler(content) } private func preprocessNotification(_ itemProxy: NotificationItemProxyProtocol) async -> NotificationProcessingResult { if settings.hideQuietNotificationAlerts, !itemProxy.isNoisy { return .processedShouldDiscard } guard case let .timeline(event) = itemProxy.event else { return .shouldDisplay } switch try? event.content() { case .messageLike(let messageContent): switch messageContent { case .poll, .roomEncrypted, .sticker: return .shouldDisplay case .roomMessage(let messageType, _): switch messageType { case .emote, .image, .audio, .video, .file, .notice, .text, .location, .gallery: 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 .rtcNotification(let notificationType, let expirationTimestamp): return await handleCallNotification(notificationType: notificationType, rtcNotifyEventID: event.eventId(), timestamp: event.timestamp(), expirationTimestamp: expirationTimestamp, 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(notificationType: RtcNotificationType, rtcNotifyEventID: String, timestamp: Timestamp, expirationTimestamp: 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 notificationType == .ring else { MXLog.info("Non-ringing call notification, handling as push notification") return .shouldDisplay } // Check to see if a call is still ongoing if let room = userSession.roomForIdentifier(roomID) { // Try to get call details from the room info if !room.hasActiveRoomCall() { // If I don't have an active call wait a bit and make sure let expiringTask = ExpiringTaskRunner { await withCheckedContinuation { [weak self] continuation in self?.roomInfoObservationToken = room.subscribeToRoomInfoUpdates(listener: SDKListener { info in if info.hasRoomCall { MXLog.info("Received room info update and the room has an active call now.") continuation.resume() } else { MXLog.info("Received a room info update but the room still doesn't have an ongoing call.") } }) } } try? await expiringTask.run(timeout: .seconds(5)) // Wait 5 seconds or just use whatever is available guard room.hasActiveRoomCall() else { MXLog.info("The room no longer has an ongoing call, handling as push notification") return .shouldDisplay } } } else { // Otherwise fallback to the old timeout mechanism 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 expirationDate = Date(timeIntervalSince1970: TimeInterval(expirationTimestamp / 1000)) let payload = [ElementCallServiceNotificationKey.roomID.rawValue: roomID, ElementCallServiceNotificationKey.roomDisplayName.rawValue: roomDisplayName, ElementCallServiceNotificationKey.expirationDate.rawValue: expirationDate, ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue: rtcNotifyEventID] as [String: Any] 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 } }