From 57761ba5e963d6eda432b26329b352567d948af7 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 25 Jun 2024 12:30:05 +0300 Subject: [PATCH] Cleanup how we setup the CallKit provider and have it be used for outgoing calls as well - tear down ElementCall screens when ending the call from the CXCallController - make the call UI available in the task manager and lock screen --- .../Sources/Application/AppCoordinator.swift | 6 +- .../Application/Navigation/AppRoutes.swift | 4 +- .../UserSessionFlowCoordinator.swift | 16 +- .../Mocks/Generated/GeneratedMocks.swift | 32 ++-- .../CallScreen/CallScreenViewModel.swift | 4 +- .../ElementCall/ElementCallService.swift | 170 ++++++++++-------- .../ElementCallServiceProtocol.swift | 6 +- 7 files changed, 137 insertions(+), 101 deletions(-) diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 1d56181c26..fe3a6bfdb6 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -149,10 +149,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg .receive(on: DispatchQueue.main) .sink { [weak self] action in switch action { - case .answerCall(let roomID): + case .startCall(let roomID): self?.handleAppRoute(.call(roomID: roomID)) - case .declineCall: - break + case .endCall: + self?.handleAppRoute(.call(roomID: nil)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Application/Navigation/AppRoutes.swift b/ElementX/Sources/Application/Navigation/AppRoutes.swift index 2e6aec080b..64f2d77864 100644 --- a/ElementX/Sources/Application/Navigation/AppRoutes.swift +++ b/ElementX/Sources/Application/Navigation/AppRoutes.swift @@ -44,8 +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 running in a particular room. Nil if having to end the call + 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/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 8fceeae02b..18fedd00da 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -251,8 +251,12 @@ 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) + if let roomID { + Task { + await presentCallScreen(roomID: roomID) + } + } else { + dismissCallScreen() } case .genericCallLink(let url): navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated) @@ -569,6 +573,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { presentCallScreen(roomProxy: roomProxy) } + private func dismissCallScreen() { + guard navigationSplitCoordinator.sheetCoordinator is CallScreenCoordinator else { + return + } + + navigationSplitCoordinator.setSheetCoordinator(nil) + } + // MARK: Secure backup confirmation private func presentSecureBackupLogoutConfirmationScreen() { diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index e99c9bca98..b9be784f01 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -4697,15 +4697,15 @@ class ElementCallServiceMock: ElementCallServiceProtocol { //MARK: - setupCallSession - var setupCallSessionTitleUnderlyingCallsCount = 0 - var setupCallSessionTitleCallsCount: Int { + var setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount = 0 + var setupCallSessionRoomIDRoomDisplayNameCallsCount: Int { get { if Thread.isMainThread { - return setupCallSessionTitleUnderlyingCallsCount + return setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount } else { var returnValue: Int? = nil DispatchQueue.main.sync { - returnValue = setupCallSessionTitleUnderlyingCallsCount + returnValue = setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount } return returnValue! @@ -4713,28 +4713,28 @@ class ElementCallServiceMock: ElementCallServiceProtocol { } set { if Thread.isMainThread { - setupCallSessionTitleUnderlyingCallsCount = newValue + setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount = newValue } else { DispatchQueue.main.sync { - setupCallSessionTitleUnderlyingCallsCount = newValue + setupCallSessionRoomIDRoomDisplayNameUnderlyingCallsCount = newValue } } } } - var setupCallSessionTitleCalled: Bool { - return setupCallSessionTitleCallsCount > 0 + var setupCallSessionRoomIDRoomDisplayNameCalled: Bool { + return setupCallSessionRoomIDRoomDisplayNameCallsCount > 0 } - var setupCallSessionTitleReceivedTitle: String? - var setupCallSessionTitleReceivedInvocations: [String] = [] - var setupCallSessionTitleClosure: ((String) async -> Void)? + var setupCallSessionRoomIDRoomDisplayNameReceivedArguments: (roomID: String, roomDisplayName: String)? + var setupCallSessionRoomIDRoomDisplayNameReceivedInvocations: [(roomID: String, roomDisplayName: String)] = [] + var setupCallSessionRoomIDRoomDisplayNameClosure: ((String, String) async -> Void)? - func setupCallSession(title: String) async { - setupCallSessionTitleCallsCount += 1 - setupCallSessionTitleReceivedTitle = title + func setupCallSession(roomID: String, roomDisplayName: String) async { + setupCallSessionRoomIDRoomDisplayNameCallsCount += 1 + setupCallSessionRoomIDRoomDisplayNameReceivedArguments = (roomID: roomID, roomDisplayName: roomDisplayName) DispatchQueue.main.async { - self.setupCallSessionTitleReceivedInvocations.append(title) + self.setupCallSessionRoomIDRoomDisplayNameReceivedInvocations.append((roomID: roomID, roomDisplayName: roomDisplayName)) } - await setupCallSessionTitleClosure?(title) + await setupCallSessionRoomIDRoomDisplayNameClosure?(roomID, roomDisplayName) } //MARK: - tearDownCallSession diff --git a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift index c46c08af54..942acb0f6e 100644 --- a/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift +++ b/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift @@ -102,7 +102,7 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol return } - await elementCallService.setupCallSession(title: roomProxy.roomTitle) + await elementCallService.setupCallSession(roomID: roomProxy.id, roomDisplayName: roomProxy.roomTitle) let _ = await roomProxy.sendCallNotificationIfNeeeded() } @@ -136,7 +136,7 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol """ let result = await widgetDriver.sendMessage(hangUpMessage) - MXLog.error("Result yo: \(result)") + MXLog.info("Sent hangUp message with result: \(result)") } private static let eventHandlerName = "elementx" diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index 19576cfde5..465fc8e358 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -21,15 +21,27 @@ import Foundation import PushKit class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDelegate, CXProviderDelegate { - private let pushRegistry: PKPushRegistry + private struct CallID: Equatable { + let callKitID: UUID + let roomID: String + } + private let pushRegistry: PKPushRegistry private let callController = CXCallController() - - private var callProvider: CXProvider? - private var ongoingCallID: UUID? - - private var incomingCallRoomID: String? + private lazy var callProvider: CXProvider = { + let configuration = CXProviderConfiguration() + configuration.supportsVideo = true + configuration.includesCallsInRecents = true + // Provide image icon if available + configuration.iconTemplateImageData = nil + + // https://stackoverflow.com/a/46077628/730924 + configuration.supportedHandleTypes = [.generic] + + return CXProvider(configuration: configuration) + }() + private var ongoingCallID: CallID? private var endUnansweredCallTask: Task? private let actionsSubject: PassthroughSubject = .init() @@ -44,47 +56,30 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe pushRegistry.delegate = self pushRegistry.desiredPushTypes = [.voIP] + + callProvider.setDelegate(self, queue: DispatchQueue.main) } - func setupCallSession(title: String) async { - guard ongoingCallID == nil else { - return + func setupCallSession(roomID: String, roomDisplayName: String) async { + let callID = if let ongoingCallID { + ongoingCallID + } else { + CallID(callKitID: UUID(), roomID: roomID) } - - let callID = UUID() + ongoingCallID = callID - let handle = CXHandle(type: .generic, value: title) - let startCallAction = CXStartCallAction(call: callID, handle: handle) + let handle = CXHandle(type: .generic, value: roomDisplayName) + let startCallAction = CXStartCallAction(call: callID.callKitID, 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() - } + try? await callController.request(CXTransaction(action: startCallAction)) + try? AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .videoChat, options: []) + try? AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation) } 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)") - } - } + tearDownCallSession(sendEndCallAction: true) } // MARK: - PKPushRegistryDelegate @@ -97,31 +92,18 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe return } - let callID = UUID() + let callID = CallID(callKitID: UUID(), roomID: roomID) ongoingCallID = callID - incomingCallRoomID = roomID - - let configuration = CXProviderConfiguration() - configuration.supportsVideo = true - configuration.includesCallsInRecents = true - // Provide image icon if available - configuration.iconTemplateImageData = nil - - // https://stackoverflow.com/a/46077628/730924 - configuration.supportedHandleTypes = [.generic] + let roomDisplayName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String let update = CXCallUpdate() update.hasVideo = true - - update.localizedCallerName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String - + update.localizedCallerName = roomDisplayName // https://stackoverflow.com/a/41230020/730924 update.remoteHandle = .init(type: .generic, value: roomID) - let callProvider = CXProvider(configuration: configuration) - callProvider.setDelegate(self, queue: nil) - callProvider.reportNewIncomingCall(with: callID, update: update) { error in + callProvider.reportNewIncomingCall(with: callID.callKitID, update: update) { error in if let error { MXLog.error("Failed reporting new incoming call with error: \(error)") } @@ -129,14 +111,15 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe completion() } - endUnansweredCallTask = Task { [weak self, callProvider, callID] in + endUnansweredCallTask = Task { [weak self] in try? await Task.sleep(for: .seconds(15)) - guard !Task.isCancelled else { + + guard let self, !Task.isCancelled else { return } - if let ongoingCallID = self?.ongoingCallID, ongoingCallID == callID { - callProvider.reportCall(with: callID, endedAt: .now, reason: .unanswered) + if let ongoingCallID, ongoingCallID.callKitID == callID.callKitID { + callProvider.reportCall(with: ongoingCallID.callKitID, endedAt: .now, reason: .unanswered) } } } @@ -147,29 +130,70 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe MXLog.info("Call provider did reset: \(provider)") } + func provider(_ provider: CXProvider, perform action: CXStartCallAction) { + guard let ongoingCallID else { + MXLog.error("Failed starting call, missing ongoingCallID") + tearDownCallSession() + return + } + + provider.reportOutgoingCall(with: ongoingCallID.callKitID, connectedAt: .now) + + action.fulfill() + } + func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { - if let incomingCallRoomID { - Task { - // Dispatch to next run loop so it doesn't conflict with `setupCallSession` - actionsSubject.send(.answerCall(roomID: incomingCallRoomID)) - } - self.incomingCallRoomID = nil - endUnansweredCallTask?.cancel() - } else { - MXLog.error("Failed answering incoming call, missing room ID") + guard let ongoingCallID else { + MXLog.error("Failed answering incoming call, missing ongoingCallID") + tearDownCallSession() + return } + + // Dispatch to next run loop so it doesn't conflict with `setupCallSession` + actionsSubject.send(.startCall(roomID: ongoingCallID.roomID)) + + endUnansweredCallTask?.cancel() action.fulfill() } + func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { + // Forward this to the widget somehow + } + 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") + defer { + tearDownCallSession(sendEndCallAction: false) } - + + guard let ongoingCallID else { + MXLog.error("Failed declining incoming call, missing ongoingCallID") + return + } + + actionsSubject.send(.endCall(roomID: ongoingCallID.roomID)) + action.fulfill() } + + // MARK: - Private + + func tearDownCallSession(sendEndCallAction: Bool = true) { + guard let ongoingCallID else { + return + } + + try? AVAudioSession.sharedInstance().setActive(false) + + if sendEndCallAction { + let transaction = CXTransaction(action: CXEndCallAction(call: ongoingCallID.callKitID)) + callController.request(transaction) { error in + if let error { + MXLog.error("Failed transaction with error: \(error)") + } + } + } + + self.ongoingCallID = nil + } } diff --git a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift index c05d593f17..06793f42ef 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift @@ -17,8 +17,8 @@ import Combine enum ElementCallServiceAction { - case answerCall(roomID: String) - case declineCall(roomID: String) + case startCall(roomID: String) + case endCall(roomID: String) } enum ElementCallServiceNotificationKey: String { @@ -32,7 +32,7 @@ let ElementCallServiceNotificationDiscardDelta = 10.0 protocol ElementCallServiceProtocol { var actions: AnyPublisher { get } - func setupCallSession(title: String) async + func setupCallSession(roomID: String, roomDisplayName: String) async func tearDownCallSession() }