diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 388a5a296..f245797c7 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -28,6 +28,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg private let appMediator: AppMediator private let appSettings: AppSettings private let appDelegate: AppDelegate + private let elementCallService: ElementCallServiceProtocol /// Common background task to continue long-running tasks in the background. private var backgroundTask: UIBackgroundTaskIdentifier? @@ -85,6 +86,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg self.appSettings = appSettings appRouteURLParser = AppRouteURLParser(appSettings: appSettings) + elementCallService = ElementCallService() + navigationRootCoordinator = NavigationRootCoordinator() Self.setupServiceLocator(appSettings: appSettings) @@ -131,6 +134,16 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg observeAppLockChanges() registerBackgroundAppRefresh() + + elementCallService.actions.sink { [weak self] action in + switch action { + case .answerCall(let roomID): + self?.handleAppRoute(.call(roomID: roomID)) + case .declineCall: + break + } + } + .store(in: &cancellables) } func start() { @@ -482,6 +495,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg navigationRootCoordinator: navigationRootCoordinator, appLockService: appLockFlowCoordinator.appLockService, bugReportService: ServiceLocator.shared.bugReportService, + elementCallService: elementCallService, roomTimelineControllerFactory: RoomTimelineControllerFactory(), appMediator: appMediator, appSettings: appSettings, diff --git a/ElementX/Sources/Application/Navigation/AppRoutes.swift b/ElementX/Sources/Application/Navigation/AppRoutes.swift index c7ea141d9..2e6aec080 100644 --- a/ElementX/Sources/Application/Navigation/AppRoutes.swift +++ b/ElementX/Sources/Application/Navigation/AppRoutes.swift @@ -44,6 +44,8 @@ enum AppRoute: Equatable { case childEventOnRoomAlias(eventID: String, alias: String) /// The profile of a matrix user (outside of a room). case userProfile(userID: String) + /// An Element Call running in a particular room + case call(roomID: String) /// An Element Call link generated outside of a chat room. case genericCallLink(url: URL) /// The settings screen. diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index 0ef5e6e31..aa7a8501f 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -168,7 +168,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias: break // These are converted to a room ID route one level above. - case .roomList, .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings: + case .roomList, .userProfile, .call, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings: break // These routes can't be handled. } } diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 08e750443..99ab53f15 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -29,6 +29,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private let navigationRootCoordinator: NavigationRootCoordinator private let navigationSplitCoordinator: NavigationSplitCoordinator private let bugReportService: BugReportServiceProtocol + private let elementCallService: ElementCallServiceProtocol private let appMediator: AppMediatorProtocol private let appSettings: AppSettings private let analytics: AnalyticsService @@ -69,6 +70,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { navigationRootCoordinator: NavigationRootCoordinator, appLockService: AppLockServiceProtocol, bugReportService: BugReportServiceProtocol, + elementCallService: ElementCallServiceProtocol, roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol, appMediator: AppMediatorProtocol, appSettings: AppSettings, @@ -79,6 +81,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { self.userSession = userSession self.navigationRootCoordinator = navigationRootCoordinator self.bugReportService = bugReportService + self.elementCallService = elementCallService self.roomTimelineControllerFactory = roomTimelineControllerFactory self.appMediator = appMediator self.appSettings = appSettings @@ -247,6 +250,10 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { case .userProfile(let userID): stateMachine.processEvent(.showUserProfileScreen(userID: userID), userInfo: .init(animated: animated)) + case .call(let roomID): + Task { + await presentCallScreen(roomID: roomID) + } case .genericCallLink(let url): navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated) case .settings, .chatBackupSettings: @@ -537,7 +544,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { // MARK: Calls private func presentCallScreen(roomProxy: RoomProxyProtocol) { - let callScreenCoordinator = CallScreenCoordinator(parameters: .init(roomProxy: roomProxy, + let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService, + roomProxy: roomProxy, callBaseURL: appSettings.elementCallBaseURL, clientID: InfoPlistReader.main.bundleIdentifier)) diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 9bc5bb417..96e7d482b 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4230,6 +4230,88 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol { setSuggestionTriggerClosure?(suggestionTrigger) } } +class ElementCallServiceMock: ElementCallServiceProtocol { + var actions: AnyPublisher { + get { return underlyingActions } + set(value) { underlyingActions = value } + } + var underlyingActions: AnyPublisher! + + //MARK: - setupCallSession + + var setupCallSessionTitleUnderlyingCallsCount = 0 + var setupCallSessionTitleCallsCount: Int { + get { + if Thread.isMainThread { + return setupCallSessionTitleUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = setupCallSessionTitleUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + setupCallSessionTitleUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + setupCallSessionTitleUnderlyingCallsCount = newValue + } + } + } + } + var setupCallSessionTitleCalled: Bool { + return setupCallSessionTitleCallsCount > 0 + } + var setupCallSessionTitleReceivedTitle: String? + var setupCallSessionTitleReceivedInvocations: [String] = [] + var setupCallSessionTitleClosure: ((String) async -> Void)? + + func setupCallSession(title: String) async { + setupCallSessionTitleCallsCount += 1 + setupCallSessionTitleReceivedTitle = title + setupCallSessionTitleReceivedInvocations.append(title) + await setupCallSessionTitleClosure?(title) + } + //MARK: - tearDownCallSession + + var tearDownCallSessionUnderlyingCallsCount = 0 + var tearDownCallSessionCallsCount: Int { + get { + if Thread.isMainThread { + return tearDownCallSessionUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = tearDownCallSessionUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + tearDownCallSessionUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + tearDownCallSessionUnderlyingCallsCount = newValue + } + } + } + } + var tearDownCallSessionCalled: Bool { + return tearDownCallSessionCallsCount > 0 + } + var tearDownCallSessionClosure: (() -> Void)? + + func tearDownCallSession() { + tearDownCallSessionCallsCount += 1 + tearDownCallSessionClosure?() + } +} class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol { var messagePublisher: PassthroughSubject { get { return underlyingMessagePublisher } @@ -9780,6 +9862,70 @@ class RoomProxyMock: RoomProxyProtocol { return elementCallWidgetDriverReturnValue } } + //MARK: - sendCallNotificationIfNeeeded + + var sendCallNotificationIfNeeededUnderlyingCallsCount = 0 + var sendCallNotificationIfNeeededCallsCount: Int { + get { + if Thread.isMainThread { + return sendCallNotificationIfNeeededUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = sendCallNotificationIfNeeededUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + sendCallNotificationIfNeeededUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + sendCallNotificationIfNeeededUnderlyingCallsCount = newValue + } + } + } + } + var sendCallNotificationIfNeeededCalled: Bool { + return sendCallNotificationIfNeeededCallsCount > 0 + } + + var sendCallNotificationIfNeeededUnderlyingReturnValue: Result! + var sendCallNotificationIfNeeededReturnValue: Result! { + get { + if Thread.isMainThread { + return sendCallNotificationIfNeeededUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = sendCallNotificationIfNeeededUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + sendCallNotificationIfNeeededUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + sendCallNotificationIfNeeededUnderlyingReturnValue = newValue + } + } + } + } + var sendCallNotificationIfNeeededClosure: (() async -> Result)? + + func sendCallNotificationIfNeeeded() async -> Result { + sendCallNotificationIfNeeededCallsCount += 1 + if let sendCallNotificationIfNeeededClosure = sendCallNotificationIfNeeededClosure { + return await sendCallNotificationIfNeeededClosure() + } else { + return sendCallNotificationIfNeeededReturnValue + } + } //MARK: - matrixToPermalink var matrixToPermalinkUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift index ae4fbbc4c..2d8cd2948 100644 --- a/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift +++ b/ElementX/Sources/Other/HTMLParsing/AttributedStringBuilder.swift @@ -143,6 +143,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol { attributedString.removeAttribute(.foregroundColor, range: .init(location: 0, length: attributedString.length)) } + // swiftlint:disable:next cyclomatic_complexity private func addLinksAndMentions(_ attributedString: NSMutableAttributedString) { let string = attributedString.string diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift b/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift index 58954e9f7..1568ccd4e 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift @@ -18,10 +18,9 @@ import Combine import SwiftUI struct CallScreenCoordinatorParameters { + let elementCallService: ElementCallServiceProtocol let roomProxy: RoomProxyProtocol - /// Which Element Call instance should be used let callBaseURL: URL - /// A way to identify the current client against Element Call let clientID: String } @@ -39,7 +38,8 @@ final class CallScreenCoordinator: CoordinatorProtocol { } init(parameters: CallScreenCoordinatorParameters) { - viewModel = CallScreenViewModel(roomProxy: parameters.roomProxy, + viewModel = CallScreenViewModel(elementCallService: parameters.elementCallService, + roomProxy: parameters.roomProxy, callBaseURL: parameters.callBaseURL, clientID: parameters.clientID) } diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift index ec13a0542..2fad46d06 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift @@ -14,7 +14,6 @@ // limitations under the License. // -import AVFoundation import CallKit import Combine import SwiftUI @@ -22,33 +21,31 @@ import SwiftUI typealias CallScreenViewModelType = StateStoreViewModel class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol { + private let elementCallService: ElementCallServiceProtocol private let roomProxy: RoomProxyProtocol private let widgetDriver: ElementCallWidgetDriverProtocol - private let callController = CXCallController() - // periphery: ignore - call kit magic do not remove - private let callProvider = CXProvider(configuration: .init()) - - private let callID = UUID() - private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } deinit { - tearDownVoIPSession(callID: callID) + elementCallService.tearDownCallSession() } /// Designated initialiser /// - Parameters: + /// - elementCallService: service responsible for setting up CallKit /// - roomProxy: The room in which the call should be created /// - callBaseURL: Which Element Call instance should be used /// - clientID: Something to identify the current client on the Element Call side - init(roomProxy: RoomProxyProtocol, + init(elementCallService: ElementCallServiceProtocol, + roomProxy: RoomProxyProtocol, callBaseURL: URL, clientID: String) { + self.elementCallService = elementCallService self.roomProxy = roomProxy widgetDriver = roomProxy.elementCallWidgetDriver() @@ -109,11 +106,9 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol return } - do { - try await setupVoIPSession(callID: callID) - } catch { - MXLog.error("Failed setting up VoIP session with error: \(error)") - } + await elementCallService.setupCallSession(title: roomProxy.roomTitle) + + let _ = await roomProxy.sendCallNotificationIfNeeeded() } } @@ -125,20 +120,6 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol } } - // MARK: - CXCallObserverDelegate - - // periphery: ignore - call kit magic do not remove - func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) { - MXLog.info("Call changed: \(call)") - } - - // MARK: - CXProviderDelegate - - // periphery: ignore - call kit magic do not remove - func providerDidReset(_ provider: CXProvider) { - MXLog.info("Call provider did reset: \(provider)") - } - // MARK: - Private private static let eventHandlerName = "elementx" @@ -160,34 +141,4 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol ); """ } - - private func setupVoIPSession(callID: UUID) async throws { - try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .videoChat, options: []) - try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation) - - let handle = CXHandle(type: .generic, value: roomProxy.roomTitle) - let startCallAction = CXStartCallAction(call: callID, handle: handle) - startCallAction.isVideo = true - - let transaction = CXTransaction(action: startCallAction) - - try await callController.request(transaction) - } - - private nonisolated func tearDownVoIPSession(callID: UUID?) { - guard let callID else { - return - } - - try? AVAudioSession.sharedInstance().setActive(false) - - let endCallAction = CXEndCallAction(call: callID) - let transaction = CXTransaction(action: endCallAction) - - callController.request(transaction) { error in - if let error { - MXLog.error("Failed transaction with error: \(error)") - } - } - } } diff --git a/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift index 0fc219b32..ef2846879 100644 --- a/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift +++ b/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift @@ -167,7 +167,8 @@ struct CallScreen_Previews: PreviewProvider { roomProxy.elementCallWidgetDriverReturnValue = widgetDriver - return CallScreenViewModel(roomProxy: roomProxy, + return CallScreenViewModel(elementCallService: ElementCallServiceMock(), + roomProxy: roomProxy, callBaseURL: "https://call.element.io", clientID: "io.element.elementx") }() diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift new file mode 100644 index 000000000..28bec8d46 --- /dev/null +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -0,0 +1,158 @@ +// +// Copyright 2024 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AVFoundation +import CallKit +import Combine +import Foundation +import PushKit + +class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDelegate, CXProviderDelegate { + private let pushRegistry: PKPushRegistry + + private let callController = CXCallController() + + private var callProvider: CXProvider? + private var ongoingCallID: UUID? + + private var incomingCallRoomID: String? + + private let actionsSubject: PassthroughSubject = .init() + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + override init() { + pushRegistry = PKPushRegistry(queue: nil) + + super.init() + + pushRegistry.delegate = self + pushRegistry.desiredPushTypes = [.voIP] + } + + func setupCallSession(title: String) async { + guard ongoingCallID == nil else { + return + } + + let callID = UUID() + ongoingCallID = callID + + let handle = CXHandle(type: .generic, value: title) + let startCallAction = CXStartCallAction(call: callID, handle: handle) + startCallAction.isVideo = true + + let transaction = CXTransaction(action: startCallAction) + + do { + try await callController.request(transaction) + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .videoChat, options: []) + try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation) + } catch { + MXLog.error("Failed setting up VoIP session with error: \(error)") + tearDownCallSession() + } + } + + func tearDownCallSession() { + guard let ongoingCallID else { + return + } + + try? AVAudioSession.sharedInstance().setActive(false) + + let endCallAction = CXEndCallAction(call: ongoingCallID) + let transaction = CXTransaction(action: endCallAction) + + callController.request(transaction) { error in + if let error { + MXLog.error("Failed transaction with error: \(error)") + } + } + } + + // MARK: - PKPushRegistryDelegate + + func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { } + + func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { + guard let roomID = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomID.rawValue] as? String else { + MXLog.error("Somethnig went wrong, missing room identifier for incoming voip call: \(payload)") + return + } + + let callID = UUID() + ongoingCallID = callID + + incomingCallRoomID = roomID + + let configuration = CXProviderConfiguration() + configuration.supportsVideo = true + configuration.includesCallsInRecents = false + + let update = CXCallUpdate() + update.hasVideo = true + + update.localizedCallerName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String + + if let senderDisplayName = payload.dictionaryPayload[ElementCallServiceNotificationKey.senderDisplayName.rawValue] as? String { + update.remoteHandle = .init(type: .generic, value: senderDisplayName) + } else if let senderID = payload.dictionaryPayload[ElementCallServiceNotificationKey.senderID.rawValue] as? String { + update.remoteHandle = .init(type: .generic, value: senderID) + } else { + MXLog.error("Something went wrong, both the user display name and ID are nil") + } + + let callProvider = CXProvider(configuration: configuration) + callProvider.setDelegate(self, queue: nil) + callProvider.reportNewIncomingCall(with: callID, update: update) { error in + if let error { + MXLog.error("Failed reporting new incoming call with error: \(error)") + } + + completion() + } + } + + // MARK: - CXProviderDelegate + + func providerDidReset(_ provider: CXProvider) { + MXLog.info("Call provider did reset: \(provider)") + } + + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { + if let incomingCallRoomID { + actionsSubject.send(.answerCall(roomID: incomingCallRoomID)) + self.incomingCallRoomID = nil + } else { + MXLog.error("Failed answering incoming call, missing room ID") + } + + action.fulfill() + } + + func provider(_ provider: CXProvider, perform action: CXEndCallAction) { + if let incomingCallRoomID { + actionsSubject.send(.declineCall(roomID: incomingCallRoomID)) + self.incomingCallRoomID = nil + } else { + MXLog.error("Failed declining incoming call, missing room ID") + } + + action.fulfill() + } +} diff --git a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift new file mode 100644 index 000000000..51af47edb --- /dev/null +++ b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift @@ -0,0 +1,40 @@ +// +// Copyright 2024 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine + +enum ElementCallServiceAction { + case answerCall(roomID: String) + case declineCall(roomID: String) +} + +enum ElementCallServiceNotificationKey: String { + case roomID + case roomDisplayName + case senderID + case senderDisplayName +} + +let ElementCallServiceNotificationDiscardDelta = 10.0 + +// sourcery: AutoMockable +protocol ElementCallServiceProtocol { + var actions: AnyPublisher { get } + + func setupCallSession(title: String) async + + func tearDownCallSession() +} diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index cf2eaec42..c7bc59fdd 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -521,6 +521,16 @@ class RoomProxy: RoomProxyProtocol { ElementCallWidgetDriver(room: room) } + func sendCallNotificationIfNeeeded() async -> Result { + do { + try await room.sendCallNotificationIfNeeded() + return .success(()) + } catch { + MXLog.error("Failed room call notification with error: \(error)") + return .failure(.sdkError(error)) + } + } + // MARK: - Permalinks func matrixToPermalink() async -> Result { diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 67b9f452f..94032b95e 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -129,6 +129,8 @@ protocol RoomProxyProtocol { func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol + func sendCallNotificationIfNeeeded() async -> Result + // MARK: - Permalinks func matrixToPermalink() async -> Result diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 48ce37f8f..acdb99907 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -505,6 +505,7 @@ class MockScreen: Identifiable { appLockService: AppLockService(keychainController: KeychainControllerMock(), appSettings: ServiceLocator.shared.settings), bugReportService: BugReportServiceMock(), + elementCallService: ElementCallServiceMock(), roomTimelineControllerFactory: RoomTimelineControllerFactoryMock(configuration: .init()), appMediator: AppMediatorMock.default, appSettings: appSettings, diff --git a/ElementX/SupportingFiles/Info.plist b/ElementX/SupportingFiles/Info.plist index 213dbcfad..f154a5033 100644 --- a/ElementX/SupportingFiles/Info.plist +++ b/ElementX/SupportingFiles/Info.plist @@ -80,8 +80,9 @@ UIBackgroundModes - fetch audio + fetch + processing voip UILaunchScreen diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index b91fe4a0f..98e3820f0 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -90,8 +90,9 @@ targets: NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location. NSFaceIDUsageDescription: Face ID is used to access your app. UIBackgroundModes: [ - fetch, audio, + fetch, + processing, voip ] BGTaskSchedulerPermittedIdentifiers: [ diff --git a/NSE/Sources/NotificationContentBuilder.swift b/NSE/Sources/NotificationContentBuilder.swift index cc9bf1af5..90d7083e7 100644 --- a/NSE/Sources/NotificationContentBuilder.swift +++ b/NSE/Sources/NotificationContentBuilder.swift @@ -46,6 +46,8 @@ struct NotificationContentBuilder { return try await processPollStartEvent(notificationItem: notificationItem, pollQuestion: question, mediaProvider: mediaProvider) case .callInvite: return try await processCallInviteEvent(notificationItem: notificationItem, mediaProvider: mediaProvider) + case .callNotify: + return try await processCallNotifyEvent(notificationItem: notificationItem, mediaProvider: mediaProvider) default: return processEmpty(notificationItem: notificationItem) } @@ -136,6 +138,12 @@ struct NotificationContentBuilder { notification.body = L10n.commonCallInvite return notification } + + private func processCallNotifyEvent(notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + let notification = try await processCommonRoomMessage(notificationItem: notificationItem, mediaProvider: mediaProvider) + notification.body = L10n.commonCallStarted + return notification + } private func processCommonRoomMessage(notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { var notification = baseMutableContent(for: notificationItem) diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index ea3af936b..802fc3200 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import CallKit import Intents import MatrixRustSDK import UserNotifications @@ -104,6 +105,10 @@ class NotificationServiceExtension: UNNotificationServiceExtension { return discard(unreadCount: unreadCount) } + guard await shouldHandleCallNotification(itemProxy) else { + return discard(unreadCount: unreadCount) + } + // After the first processing, update the modified content modifiedContent = try await notificationContentBuilder.content(for: itemProxy, mediaProvider: nil) @@ -173,4 +178,48 @@ class NotificationServiceExtension: UNNotificationServiceExtension { NSELogger.logMemory(with: tag) MXLog.info("\(tag) deinit") } + + private func shouldHandleCallNotification(_ itemProxy: NotificationItemProxyProtocol) async -> Bool { + // 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 case let .timeline(event) = itemProxy.event, + case let .messageLike(content) = try? event.eventType(), + case let .callNotify(notificationType) = content, + notificationType == .ring else { + return true + } + + let timestamp = Date(timeIntervalSince1970: TimeInterval(event.timestamp() / 1000)) + guard abs(timestamp.timeIntervalSinceNow) < ElementCallServiceNotificationDiscardDelta else { + MXLog.info("Call notification is too old, handling as push notification") + return true + } + + var payload = [ElementCallServiceNotificationKey.roomID.rawValue: itemProxy.roomID, + ElementCallServiceNotificationKey.roomDisplayName.rawValue: itemProxy.roomDisplayName, + ElementCallServiceNotificationKey.senderID.rawValue: itemProxy.senderID] + if let senderDisplayName = itemProxy.senderDisplayName { + payload[ElementCallServiceNotificationKey.senderDisplayName.rawValue] = senderDisplayName + } + + do { + try await CXProvider.reportNewIncomingVoIPPushPayload(payload) + } catch { + MXLog.error("Failed reporting voip call with error: \(error). Handling as push notification") + return true + } + + return false + } } diff --git a/NSE/SupportingFiles/target.yml b/NSE/SupportingFiles/target.yml index 83f0e1f69..9d100721b 100644 --- a/NSE/SupportingFiles/target.yml +++ b/NSE/SupportingFiles/target.yml @@ -104,4 +104,5 @@ targets: - path: ../../ElementX/Sources/Services/Notification/Proxy - path: ../../ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift - path: ../../ElementX/Sources/Services/UserSession/RestorationToken.swift + - path: ../../ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift - path: ../../ElementX/Sources/Application/AppSettings.swift