mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 13:37:11 +00:00
Add support for showing the native OS incoming call screen when starting new Element Calls
This commit is contained in:
parent
5860317fea
commit
8bfd802793
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
|
||||
|
@ -4230,6 +4230,88 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol {
|
||||
setSuggestionTriggerClosure?(suggestionTrigger)
|
||||
}
|
||||
}
|
||||
class ElementCallServiceMock: ElementCallServiceProtocol {
|
||||
var actions: AnyPublisher<ElementCallServiceAction, Never> {
|
||||
get { return underlyingActions }
|
||||
set(value) { underlyingActions = value }
|
||||
}
|
||||
var underlyingActions: AnyPublisher<ElementCallServiceAction, Never>!
|
||||
|
||||
//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<String, Never> {
|
||||
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<Void, RoomProxyError>!
|
||||
var sendCallNotificationIfNeeededReturnValue: Result<Void, RoomProxyError>! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return sendCallNotificationIfNeeededUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Result<Void, RoomProxyError>? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = sendCallNotificationIfNeeededUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
sendCallNotificationIfNeeededUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
sendCallNotificationIfNeeededUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var sendCallNotificationIfNeeededClosure: (() async -> Result<Void, RoomProxyError>)?
|
||||
|
||||
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError> {
|
||||
sendCallNotificationIfNeeededCallsCount += 1
|
||||
if let sendCallNotificationIfNeeededClosure = sendCallNotificationIfNeeededClosure {
|
||||
return await sendCallNotificationIfNeeededClosure()
|
||||
} else {
|
||||
return sendCallNotificationIfNeeededReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - matrixToPermalink
|
||||
|
||||
var matrixToPermalinkUnderlyingCallsCount = 0
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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<CallScreenViewState, CallScreenViewAction>
|
||||
|
||||
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<CallScreenViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<CallScreenViewModelAction, Never> {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}()
|
||||
|
158
ElementX/Sources/Services/ElementCall/ElementCallService.swift
Normal file
158
ElementX/Sources/Services/ElementCall/ElementCallService.swift
Normal file
@ -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<ElementCallServiceAction, Never> = .init()
|
||||
var actions: AnyPublisher<ElementCallServiceAction, Never> {
|
||||
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()
|
||||
}
|
||||
}
|
@ -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<ElementCallServiceAction, Never> { get }
|
||||
|
||||
func setupCallSession(title: String) async
|
||||
|
||||
func tearDownCallSession()
|
||||
}
|
@ -521,6 +521,16 @@ class RoomProxy: RoomProxyProtocol {
|
||||
ElementCallWidgetDriver(room: room)
|
||||
}
|
||||
|
||||
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError> {
|
||||
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<URL, RoomProxyError> {
|
||||
|
@ -129,6 +129,8 @@ protocol RoomProxyProtocol {
|
||||
|
||||
func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol
|
||||
|
||||
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError>
|
||||
|
||||
// MARK: - Permalinks
|
||||
|
||||
func matrixToPermalink() async -> Result<URL, RoomProxyError>
|
||||
|
@ -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,
|
||||
|
@ -80,8 +80,9 @@
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
<string>voip</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
|
@ -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: [
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user