From 570c7534c32a480de3f546dcb46bbceffdd14240 Mon Sep 17 00:00:00 2001 From: Sedat Qaja Date: Mon, 12 Oct 2026 11:42:53 +0200 Subject: [PATCH] feat(healthchat): ring calls via gateway VoIP push CallKit rejects the NSE hand-off without the filtering entitlement. --- .../Sources/Application/AppCoordinator.swift | 2 +- .../Application/Settings/AppSettings.swift | 26 ++++++++++ .../ElementCall/ElementCallService.swift | 30 +++++++++-- NSE/Sources/NotificationHandler.swift | 51 ++++++++++++++++++- .../Sources/ElementCallServiceTests.swift | 24 ++++++++- 5 files changed, 127 insertions(+), 6 deletions(-) diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index bfd0e38b5..e02d9cb2e 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -113,7 +113,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg userIndicatorController = UserIndicatorController() - elementCallService = ElementCallService() + elementCallService = ElementCallService(appSettings: appSettings) navigationRootCoordinator = NavigationRootCoordinator() diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 016657cd0..0a32e9622 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -30,6 +30,10 @@ nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable { var enableOnlySignedDeviceIsolationMode: Bool { get } var threadsEnabled: Bool { get } var hideQuietNotificationAlerts: Bool { get } + + var voipPusherAppID: String { get } + var pushKitDeviceToken: String? { get } + var voipPushGatewayNotifyURL: String? { get } } nonisolated enum AppBuildType { @@ -244,6 +248,28 @@ final nonisolated class AppSettings: @unchecked Sendable { pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify") } + // HealthChat: the NSE lacks the notification filtering entitlement, so it cannot hand ringing + // calls to CallKit directly. Instead it asks the push gateway for a PushKit (VoIP) push, the + // way the legacy element-ios app did. The app persists what the NSE needs for that request + // here, because the NSE neither receives PushKit credentials nor runs the app settings hook. + + /// The push gateway app ID that delivers PushKit pushes (`push_type: voip` in Sygnal). + var voipPusherAppID: String { + #if DEBUG + InfoPlistReader.main.baseBundleIdentifier + ".ios.voip.dev" + #else + InfoPlistReader.main.baseBundleIdentifier + ".ios.voip.prod" + #endif + } + + /// The device's PushKit token, base64 encoded like the APNs pushkey. + @UserPreference + var pushKitDeviceToken: String? + + /// `pushGatewayNotifyEndpoint` as configured in the app, for the NSE. + @UserPreference + var voipPushGatewayNotifyURL: String? + @UserPreference(defaultValue: true) var enableNotifications: Bool diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index 9218b7838..3304baee9 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -32,6 +32,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private let callController = CXCallController() private let callProvider: CXProviderProtocol private let timeProvider: TimeProvider + private let appSettings: AppSettings private weak var clientProxy: ClientProxyProtocol? { didSet { @@ -67,10 +68,11 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe private var declineListenerHandle: TaskHandle? - init(callProvider: CXProviderProtocol? = nil, timeProvider: TimeProvider? = nil) { + init(callProvider: CXProviderProtocol? = nil, timeProvider: TimeProvider? = nil, appSettings: AppSettings) { pushRegistry = PKPushRegistry(queue: nil) self.timeProvider = timeProvider ?? TimeProvider(clock: ContinuousClock(), now: Date.init) + self.appSettings = appSettings if let callProvider { self.callProvider = callProvider @@ -157,7 +159,16 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe // MARK: - PKPushRegistryDelegate - func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { } + func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { + // HealthChat: stored for the NSE, which requests a VoIP push through the push gateway + // for ringing calls as long as the notification filtering entitlement is missing. + appSettings.pushKitDeviceToken = pushCredentials.token.base64EncodedString() + appSettings.voipPushGatewayNotifyURL = appSettings.pushGatewayNotifyEndpoint.absoluteString + } + + func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { + appSettings.pushKitDeviceToken = nil + } func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { guard let roomID = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomID.rawValue] as? String else { @@ -189,7 +200,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe let callID = CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: rtcNotificationID, isVoiceCall: isVoiceCall) incomingCallID = callID - guard let expirationDate = (payload.dictionaryPayload[ElementCallServiceNotificationKey.expirationDate.rawValue] as? Date) else { + guard let expirationDate = Self.expirationDate(from: payload.dictionaryPayload) else { MXLog.error("Something went wrong, missing expiration timestamp for incoming voip call: \(payload)") assertionFailure("Incoming voip push is missing the expiration timestamp") clearIncomingCallState() @@ -353,6 +364,19 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe /// a call and immediately end it. `callerInfo` names the call after the room so the brief /// system UI and the Recents entry show the room rather than "Unknown", and the entry can be /// tapped to call back. + /// CallKit hands the NSE's payload over as-is, so the expiration arrives as a `Date`. A VoIP push + /// requested from the push gateway travels as JSON, where the same key is seconds since 1970. + private static func expirationDate(from payload: [AnyHashable: Any]) -> Date? { + switch payload[ElementCallServiceNotificationKey.expirationDate.rawValue] { + case let date as Date: + date + case let secondsSince1970 as TimeInterval: + Date(timeIntervalSince1970: secondsSince1970) + default: + nil + } + } + private func reportImmediatelyEndedCall(reason: CXCallEndedReason, callerInfo: (roomID: String, roomDisplayName: String?)? = nil, completion: @escaping () -> Void) { diff --git a/NSE/Sources/NotificationHandler.swift b/NSE/Sources/NotificationHandler.swift index b8de20285..4e80955b8 100644 --- a/NSE/Sources/NotificationHandler.swift +++ b/NSE/Sources/NotificationHandler.swift @@ -235,13 +235,62 @@ nonisolated class NotificationHandler { 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") + // HealthChat: without the notification filtering entitlement CallKit rejects the hand-off, + // so ask the push gateway for a PushKit push instead and keep the alert visible. + MXLog.error("Failed reporting voip call with error: \(error). Requesting a VoIP push instead") + await requestVoIPPush(roomID: roomID, + roomDisplayName: roomDisplayName, + rtcNotifyEventID: rtcNotifyEventID, + expirationDate: expirationDate, + isVoiceCall: callIntent == RtcCallIntent.audio) return .shouldDisplay } return .processedShouldDiscard } + /// Asks the push gateway to deliver a PushKit push with the call details, the way the legacy + /// element-ios app did. The app's `PKPushRegistryDelegate` then reports the call to CallKit. + private func requestVoIPPush(roomID: String, roomDisplayName: String, rtcNotifyEventID: String, expirationDate: Date, isVoiceCall: Bool) async { + guard let token = settings.pushKitDeviceToken, + let notifyURL = settings.voipPushGatewayNotifyURL.flatMap(URL.init(string:)) else { + MXLog.warning("No PushKit token or push gateway stored by the app, cannot request a VoIP push") + return + } + + // The gateway merges `default_payload` into the push verbatim, so the keys match what the app expects. + let defaultPayload: [String: Any] = [ElementCallServiceNotificationKey.roomID.rawValue: roomID, + ElementCallServiceNotificationKey.roomDisplayName.rawValue: roomDisplayName, + ElementCallServiceNotificationKey.rtcNotifyEventID.rawValue: rtcNotifyEventID, + ElementCallServiceNotificationKey.expirationDate.rawValue: expirationDate.timeIntervalSince1970, + ElementCallServiceNotificationKey.isVoiceCall.rawValue: isVoiceCall] + let body: [String: Any] = ["notification": ["event_id": rtcNotifyEventID, + "room_id": roomID, + "prio": "high", + "devices": [["app_id": settings.voipPusherAppID, + "pushkey": token, + "data": ["format": "event_id_only", + "default_payload": defaultPayload]]]]] + + var request = URLRequest(url: notifyURL, timeoutInterval: 5) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await URLSession.shared.data(for: request) + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 + let rejected = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["rejected"] as? [String] ?? [] + guard statusCode == 200, rejected.isEmpty else { + MXLog.error("Push gateway refused the VoIP push: status \(statusCode), rejected \(rejected.count) pushkey(s)") + return + } + MXLog.info("Requested a VoIP push for the call in \(roomID)") + } catch { + MXLog.error("Failed requesting a VoIP push: \(error)") + } + } + private enum NotificationProcessingResult { case shouldDisplay case processedShouldDiscard diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift index deadcfbf5..ff6a00801 100644 --- a/UnitTests/Sources/ElementCallServiceTests.swift +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -26,7 +26,9 @@ final class ElementCallServiceTests { let dateProvider: () -> Date = { self.currentDate } - service = ElementCallService(callProvider: callProvider, timeProvider: TimeProvider(clock: clock, now: dateProvider)) + service = ElementCallService(callProvider: callProvider, + timeProvider: TimeProvider(clock: clock, now: dateProvider), + appSettings: AppSettings.volatile()) } isolated deinit { @@ -57,6 +59,21 @@ final class ElementCallServiceTests { } } + /// HealthChat: a VoIP push requested from the push gateway carries the expiration as JSON seconds. + @Test + func incomingCallFromPushGatewayVoIPPush() async { + await waitForConfirmation { confirmation in + let pkPushPayloadMock = PKPushPayloadMock().updatingExpiration(secondsSince1970: currentDate.timeIntervalSince1970 + 30) + + service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: pkPushPayloadMock, for: .voIP) { + confirmation() + } + } + + #expect(callProvider.reportNewIncomingCallWithUpdateCompletionCalled) + #expect(!callProvider.reportCallWithEndedAtReasonCalled) + } + @Test func incomingVoiceCall() async { #expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) @@ -280,6 +297,11 @@ private nonisolated class PKPushPayloadMock: PKPushPayload { return self } + func updatingExpiration(secondsSince1970: TimeInterval) -> Self { + dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = secondsSince1970 + return self + } + func updateIsVoice(_ isVoice: Bool) -> Self { dict[ElementCallServiceNotificationKey.isVoiceCall.rawValue] = isVoice return self