mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
feat(healthchat): ring calls via gateway VoIP push
CallKit rejects the NSE hand-off without the filtering entitlement.
This commit is contained in:
@@ -113,7 +113,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
|
|
||||||
userIndicatorController = UserIndicatorController()
|
userIndicatorController = UserIndicatorController()
|
||||||
|
|
||||||
elementCallService = ElementCallService()
|
elementCallService = ElementCallService(appSettings: appSettings)
|
||||||
|
|
||||||
navigationRootCoordinator = NavigationRootCoordinator()
|
navigationRootCoordinator = NavigationRootCoordinator()
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable {
|
|||||||
var enableOnlySignedDeviceIsolationMode: Bool { get }
|
var enableOnlySignedDeviceIsolationMode: Bool { get }
|
||||||
var threadsEnabled: Bool { get }
|
var threadsEnabled: Bool { get }
|
||||||
var hideQuietNotificationAlerts: Bool { get }
|
var hideQuietNotificationAlerts: Bool { get }
|
||||||
|
|
||||||
|
var voipPusherAppID: String { get }
|
||||||
|
var pushKitDeviceToken: String? { get }
|
||||||
|
var voipPushGatewayNotifyURL: String? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated enum AppBuildType {
|
nonisolated enum AppBuildType {
|
||||||
@@ -244,6 +248,28 @@ final nonisolated class AppSettings: @unchecked Sendable {
|
|||||||
pushGatewayBaseURL.appending(path: "_matrix/push/v1/notify")
|
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)
|
@UserPreference(defaultValue: true)
|
||||||
var enableNotifications: Bool
|
var enableNotifications: Bool
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
private let callController = CXCallController()
|
private let callController = CXCallController()
|
||||||
private let callProvider: CXProviderProtocol
|
private let callProvider: CXProviderProtocol
|
||||||
private let timeProvider: TimeProvider
|
private let timeProvider: TimeProvider
|
||||||
|
private let appSettings: AppSettings
|
||||||
|
|
||||||
private weak var clientProxy: ClientProxyProtocol? {
|
private weak var clientProxy: ClientProxyProtocol? {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -67,10 +68,11 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
|
|
||||||
private var declineListenerHandle: TaskHandle?
|
private var declineListenerHandle: TaskHandle?
|
||||||
|
|
||||||
init(callProvider: CXProviderProtocol? = nil, timeProvider: TimeProvider? = nil) {
|
init(callProvider: CXProviderProtocol? = nil, timeProvider: TimeProvider? = nil, appSettings: AppSettings) {
|
||||||
pushRegistry = PKPushRegistry(queue: nil)
|
pushRegistry = PKPushRegistry(queue: nil)
|
||||||
|
|
||||||
self.timeProvider = timeProvider ?? TimeProvider(clock: ContinuousClock(), now: Date.init)
|
self.timeProvider = timeProvider ?? TimeProvider(clock: ContinuousClock(), now: Date.init)
|
||||||
|
self.appSettings = appSettings
|
||||||
|
|
||||||
if let callProvider {
|
if let callProvider {
|
||||||
self.callProvider = callProvider
|
self.callProvider = callProvider
|
||||||
@@ -157,7 +159,16 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
|
|
||||||
// MARK: - PKPushRegistryDelegate
|
// 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) {
|
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
||||||
guard let roomID = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomID.rawValue] as? String else {
|
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)
|
let callID = CallID(callKitID: UUID(), roomID: roomID, rtcNotificationID: rtcNotificationID, isVoiceCall: isVoiceCall)
|
||||||
incomingCallID = callID
|
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)")
|
MXLog.error("Something went wrong, missing expiration timestamp for incoming voip call: \(payload)")
|
||||||
assertionFailure("Incoming voip push is missing the expiration timestamp")
|
assertionFailure("Incoming voip push is missing the expiration timestamp")
|
||||||
clearIncomingCallState()
|
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
|
/// 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
|
/// system UI and the Recents entry show the room rather than "Unknown", and the entry can be
|
||||||
/// tapped to call back.
|
/// 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,
|
private func reportImmediatelyEndedCall(reason: CXCallEndedReason,
|
||||||
callerInfo: (roomID: String, roomDisplayName: String?)? = nil,
|
callerInfo: (roomID: String, roomDisplayName: String?)? = nil,
|
||||||
completion: @escaping () -> Void) {
|
completion: @escaping () -> Void) {
|
||||||
|
|||||||
@@ -235,13 +235,62 @@ nonisolated class NotificationHandler {
|
|||||||
try await CXProvider.reportNewIncomingVoIPPushPayload(payload)
|
try await CXProvider.reportNewIncomingVoIPPushPayload(payload)
|
||||||
MXLog.info("Call notification delegated to CallKit")
|
MXLog.info("Call notification delegated to CallKit")
|
||||||
} catch {
|
} 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 .shouldDisplay
|
||||||
}
|
}
|
||||||
|
|
||||||
return .processedShouldDiscard
|
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 {
|
private enum NotificationProcessingResult {
|
||||||
case shouldDisplay
|
case shouldDisplay
|
||||||
case processedShouldDiscard
|
case processedShouldDiscard
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ final class ElementCallServiceTests {
|
|||||||
let dateProvider: () -> Date = {
|
let dateProvider: () -> Date = {
|
||||||
self.currentDate
|
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 {
|
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
|
@Test
|
||||||
func incomingVoiceCall() async {
|
func incomingVoiceCall() async {
|
||||||
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
|
||||||
@@ -280,6 +297,11 @@ private nonisolated class PKPushPayloadMock: PKPushPayload {
|
|||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updatingExpiration(secondsSince1970: TimeInterval) -> Self {
|
||||||
|
dict[ElementCallServiceNotificationKey.expirationDate.rawValue] = secondsSince1970
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
func updateIsVoice(_ isVoice: Bool) -> Self {
|
func updateIsVoice(_ isVoice: Bool) -> Self {
|
||||||
dict[ElementCallServiceNotificationKey.isVoiceCall.rawValue] = isVoice
|
dict[ElementCallServiceNotificationKey.isVoiceCall.rawValue] = isVoice
|
||||||
return self
|
return self
|
||||||
|
|||||||
Reference in New Issue
Block a user