mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Add support for showing the native OS incoming call screen when starting new Element Calls
This commit is contained in:
committed by
Stefan Ceriu
parent
5860317fea
commit
8bfd802793
@@ -28,6 +28,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
private let appMediator: AppMediator
|
private let appMediator: AppMediator
|
||||||
private let appSettings: AppSettings
|
private let appSettings: AppSettings
|
||||||
private let appDelegate: AppDelegate
|
private let appDelegate: AppDelegate
|
||||||
|
private let elementCallService: ElementCallServiceProtocol
|
||||||
|
|
||||||
/// Common background task to continue long-running tasks in the background.
|
/// Common background task to continue long-running tasks in the background.
|
||||||
private var backgroundTask: UIBackgroundTaskIdentifier?
|
private var backgroundTask: UIBackgroundTaskIdentifier?
|
||||||
@@ -85,6 +86,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
self.appSettings = appSettings
|
self.appSettings = appSettings
|
||||||
appRouteURLParser = AppRouteURLParser(appSettings: appSettings)
|
appRouteURLParser = AppRouteURLParser(appSettings: appSettings)
|
||||||
|
|
||||||
|
elementCallService = ElementCallService()
|
||||||
|
|
||||||
navigationRootCoordinator = NavigationRootCoordinator()
|
navigationRootCoordinator = NavigationRootCoordinator()
|
||||||
|
|
||||||
Self.setupServiceLocator(appSettings: appSettings)
|
Self.setupServiceLocator(appSettings: appSettings)
|
||||||
@@ -131,6 +134,16 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
observeAppLockChanges()
|
observeAppLockChanges()
|
||||||
|
|
||||||
registerBackgroundAppRefresh()
|
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() {
|
func start() {
|
||||||
@@ -482,6 +495,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
navigationRootCoordinator: navigationRootCoordinator,
|
navigationRootCoordinator: navigationRootCoordinator,
|
||||||
appLockService: appLockFlowCoordinator.appLockService,
|
appLockService: appLockFlowCoordinator.appLockService,
|
||||||
bugReportService: ServiceLocator.shared.bugReportService,
|
bugReportService: ServiceLocator.shared.bugReportService,
|
||||||
|
elementCallService: elementCallService,
|
||||||
roomTimelineControllerFactory: RoomTimelineControllerFactory(),
|
roomTimelineControllerFactory: RoomTimelineControllerFactory(),
|
||||||
appMediator: appMediator,
|
appMediator: appMediator,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ enum AppRoute: Equatable {
|
|||||||
case childEventOnRoomAlias(eventID: String, alias: String)
|
case childEventOnRoomAlias(eventID: String, alias: String)
|
||||||
/// The profile of a matrix user (outside of a room).
|
/// The profile of a matrix user (outside of a room).
|
||||||
case userProfile(userID: String)
|
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.
|
/// An Element Call link generated outside of a chat room.
|
||||||
case genericCallLink(url: URL)
|
case genericCallLink(url: URL)
|
||||||
/// The settings screen.
|
/// The settings screen.
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
|
case .roomAlias, .childRoomAlias, .eventOnRoomAlias, .childEventOnRoomAlias:
|
||||||
break // These are converted to a room ID route one level above.
|
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.
|
break // These routes can't be handled.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
private let navigationRootCoordinator: NavigationRootCoordinator
|
private let navigationRootCoordinator: NavigationRootCoordinator
|
||||||
private let navigationSplitCoordinator: NavigationSplitCoordinator
|
private let navigationSplitCoordinator: NavigationSplitCoordinator
|
||||||
private let bugReportService: BugReportServiceProtocol
|
private let bugReportService: BugReportServiceProtocol
|
||||||
|
private let elementCallService: ElementCallServiceProtocol
|
||||||
private let appMediator: AppMediatorProtocol
|
private let appMediator: AppMediatorProtocol
|
||||||
private let appSettings: AppSettings
|
private let appSettings: AppSettings
|
||||||
private let analytics: AnalyticsService
|
private let analytics: AnalyticsService
|
||||||
@@ -69,6 +70,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
navigationRootCoordinator: NavigationRootCoordinator,
|
navigationRootCoordinator: NavigationRootCoordinator,
|
||||||
appLockService: AppLockServiceProtocol,
|
appLockService: AppLockServiceProtocol,
|
||||||
bugReportService: BugReportServiceProtocol,
|
bugReportService: BugReportServiceProtocol,
|
||||||
|
elementCallService: ElementCallServiceProtocol,
|
||||||
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
|
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
|
||||||
appMediator: AppMediatorProtocol,
|
appMediator: AppMediatorProtocol,
|
||||||
appSettings: AppSettings,
|
appSettings: AppSettings,
|
||||||
@@ -79,6 +81,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
self.userSession = userSession
|
self.userSession = userSession
|
||||||
self.navigationRootCoordinator = navigationRootCoordinator
|
self.navigationRootCoordinator = navigationRootCoordinator
|
||||||
self.bugReportService = bugReportService
|
self.bugReportService = bugReportService
|
||||||
|
self.elementCallService = elementCallService
|
||||||
self.roomTimelineControllerFactory = roomTimelineControllerFactory
|
self.roomTimelineControllerFactory = roomTimelineControllerFactory
|
||||||
self.appMediator = appMediator
|
self.appMediator = appMediator
|
||||||
self.appSettings = appSettings
|
self.appSettings = appSettings
|
||||||
@@ -247,6 +250,10 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
|
|
||||||
case .userProfile(let userID):
|
case .userProfile(let userID):
|
||||||
stateMachine.processEvent(.showUserProfileScreen(userID: userID), userInfo: .init(animated: animated))
|
stateMachine.processEvent(.showUserProfileScreen(userID: userID), userInfo: .init(animated: animated))
|
||||||
|
case .call(let roomID):
|
||||||
|
Task {
|
||||||
|
await presentCallScreen(roomID: roomID)
|
||||||
|
}
|
||||||
case .genericCallLink(let url):
|
case .genericCallLink(let url):
|
||||||
navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
|
navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
|
||||||
case .settings, .chatBackupSettings:
|
case .settings, .chatBackupSettings:
|
||||||
@@ -537,7 +544,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
// MARK: Calls
|
// MARK: Calls
|
||||||
|
|
||||||
private func presentCallScreen(roomProxy: RoomProxyProtocol) {
|
private func presentCallScreen(roomProxy: RoomProxyProtocol) {
|
||||||
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(roomProxy: roomProxy,
|
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService,
|
||||||
|
roomProxy: roomProxy,
|
||||||
callBaseURL: appSettings.elementCallBaseURL,
|
callBaseURL: appSettings.elementCallBaseURL,
|
||||||
clientID: InfoPlistReader.main.bundleIdentifier))
|
clientID: InfoPlistReader.main.bundleIdentifier))
|
||||||
|
|
||||||
|
|||||||
@@ -4230,6 +4230,88 @@ class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol {
|
|||||||
setSuggestionTriggerClosure?(suggestionTrigger)
|
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 {
|
class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol {
|
||||||
var messagePublisher: PassthroughSubject<String, Never> {
|
var messagePublisher: PassthroughSubject<String, Never> {
|
||||||
get { return underlyingMessagePublisher }
|
get { return underlyingMessagePublisher }
|
||||||
@@ -9780,6 +9862,70 @@ class RoomProxyMock: RoomProxyProtocol {
|
|||||||
return elementCallWidgetDriverReturnValue
|
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
|
//MARK: - matrixToPermalink
|
||||||
|
|
||||||
var matrixToPermalinkUnderlyingCallsCount = 0
|
var matrixToPermalinkUnderlyingCallsCount = 0
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ struct AttributedStringBuilder: AttributedStringBuilderProtocol {
|
|||||||
attributedString.removeAttribute(.foregroundColor, range: .init(location: 0, length: attributedString.length))
|
attributedString.removeAttribute(.foregroundColor, range: .init(location: 0, length: attributedString.length))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next cyclomatic_complexity
|
||||||
private func addLinksAndMentions(_ attributedString: NSMutableAttributedString) {
|
private func addLinksAndMentions(_ attributedString: NSMutableAttributedString) {
|
||||||
let string = attributedString.string
|
let string = attributedString.string
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ import Combine
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CallScreenCoordinatorParameters {
|
struct CallScreenCoordinatorParameters {
|
||||||
|
let elementCallService: ElementCallServiceProtocol
|
||||||
let roomProxy: RoomProxyProtocol
|
let roomProxy: RoomProxyProtocol
|
||||||
/// Which Element Call instance should be used
|
|
||||||
let callBaseURL: URL
|
let callBaseURL: URL
|
||||||
/// A way to identify the current client against Element Call
|
|
||||||
let clientID: String
|
let clientID: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +38,8 @@ final class CallScreenCoordinator: CoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(parameters: CallScreenCoordinatorParameters) {
|
init(parameters: CallScreenCoordinatorParameters) {
|
||||||
viewModel = CallScreenViewModel(roomProxy: parameters.roomProxy,
|
viewModel = CallScreenViewModel(elementCallService: parameters.elementCallService,
|
||||||
|
roomProxy: parameters.roomProxy,
|
||||||
callBaseURL: parameters.callBaseURL,
|
callBaseURL: parameters.callBaseURL,
|
||||||
clientID: parameters.clientID)
|
clientID: parameters.clientID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
import CallKit
|
import CallKit
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -22,33 +21,31 @@ import SwiftUI
|
|||||||
typealias CallScreenViewModelType = StateStoreViewModel<CallScreenViewState, CallScreenViewAction>
|
typealias CallScreenViewModelType = StateStoreViewModel<CallScreenViewState, CallScreenViewAction>
|
||||||
|
|
||||||
class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol {
|
class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol {
|
||||||
|
private let elementCallService: ElementCallServiceProtocol
|
||||||
private let roomProxy: RoomProxyProtocol
|
private let roomProxy: RoomProxyProtocol
|
||||||
|
|
||||||
private let widgetDriver: ElementCallWidgetDriverProtocol
|
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()
|
private let actionsSubject: PassthroughSubject<CallScreenViewModelAction, Never> = .init()
|
||||||
var actions: AnyPublisher<CallScreenViewModelAction, Never> {
|
var actions: AnyPublisher<CallScreenViewModelAction, Never> {
|
||||||
actionsSubject.eraseToAnyPublisher()
|
actionsSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
tearDownVoIPSession(callID: callID)
|
elementCallService.tearDownCallSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Designated initialiser
|
/// Designated initialiser
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
|
/// - elementCallService: service responsible for setting up CallKit
|
||||||
/// - roomProxy: The room in which the call should be created
|
/// - roomProxy: The room in which the call should be created
|
||||||
/// - callBaseURL: Which Element Call instance should be used
|
/// - callBaseURL: Which Element Call instance should be used
|
||||||
/// - clientID: Something to identify the current client on the Element Call side
|
/// - clientID: Something to identify the current client on the Element Call side
|
||||||
init(roomProxy: RoomProxyProtocol,
|
init(elementCallService: ElementCallServiceProtocol,
|
||||||
|
roomProxy: RoomProxyProtocol,
|
||||||
callBaseURL: URL,
|
callBaseURL: URL,
|
||||||
clientID: String) {
|
clientID: String) {
|
||||||
|
self.elementCallService = elementCallService
|
||||||
self.roomProxy = roomProxy
|
self.roomProxy = roomProxy
|
||||||
|
|
||||||
widgetDriver = roomProxy.elementCallWidgetDriver()
|
widgetDriver = roomProxy.elementCallWidgetDriver()
|
||||||
@@ -109,11 +106,9 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
await elementCallService.setupCallSession(title: roomProxy.roomTitle)
|
||||||
try await setupVoIPSession(callID: callID)
|
|
||||||
} catch {
|
let _ = await roomProxy.sendCallNotificationIfNeeeded()
|
||||||
MXLog.error("Failed setting up VoIP session with error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
// MARK: - Private
|
||||||
|
|
||||||
private static let eventHandlerName = "elementx"
|
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
|
roomProxy.elementCallWidgetDriverReturnValue = widgetDriver
|
||||||
|
|
||||||
return CallScreenViewModel(roomProxy: roomProxy,
|
return CallScreenViewModel(elementCallService: ElementCallServiceMock(),
|
||||||
|
roomProxy: roomProxy,
|
||||||
callBaseURL: "https://call.element.io",
|
callBaseURL: "https://call.element.io",
|
||||||
clientID: "io.element.elementx")
|
clientID: "io.element.elementx")
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -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)
|
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
|
// MARK: - Permalinks
|
||||||
|
|
||||||
func matrixToPermalink() async -> Result<URL, RoomProxyError> {
|
func matrixToPermalink() async -> Result<URL, RoomProxyError> {
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ protocol RoomProxyProtocol {
|
|||||||
|
|
||||||
func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol
|
func elementCallWidgetDriver() -> ElementCallWidgetDriverProtocol
|
||||||
|
|
||||||
|
func sendCallNotificationIfNeeeded() async -> Result<Void, RoomProxyError>
|
||||||
|
|
||||||
// MARK: - Permalinks
|
// MARK: - Permalinks
|
||||||
|
|
||||||
func matrixToPermalink() async -> Result<URL, RoomProxyError>
|
func matrixToPermalink() async -> Result<URL, RoomProxyError>
|
||||||
|
|||||||
@@ -505,6 +505,7 @@ class MockScreen: Identifiable {
|
|||||||
appLockService: AppLockService(keychainController: KeychainControllerMock(),
|
appLockService: AppLockService(keychainController: KeychainControllerMock(),
|
||||||
appSettings: ServiceLocator.shared.settings),
|
appSettings: ServiceLocator.shared.settings),
|
||||||
bugReportService: BugReportServiceMock(),
|
bugReportService: BugReportServiceMock(),
|
||||||
|
elementCallService: ElementCallServiceMock(),
|
||||||
roomTimelineControllerFactory: RoomTimelineControllerFactoryMock(configuration: .init()),
|
roomTimelineControllerFactory: RoomTimelineControllerFactoryMock(configuration: .init()),
|
||||||
appMediator: AppMediatorMock.default,
|
appMediator: AppMediatorMock.default,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
|
|||||||
@@ -80,8 +80,9 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
<string>voip</string>
|
<string>voip</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ targets:
|
|||||||
NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location.
|
NSLocationWhenInUseUsageDescription: Grant location access so that $(APP_DISPLAY_NAME) can share your location.
|
||||||
NSFaceIDUsageDescription: Face ID is used to access your app.
|
NSFaceIDUsageDescription: Face ID is used to access your app.
|
||||||
UIBackgroundModes: [
|
UIBackgroundModes: [
|
||||||
fetch,
|
|
||||||
audio,
|
audio,
|
||||||
|
fetch,
|
||||||
|
processing,
|
||||||
voip
|
voip
|
||||||
]
|
]
|
||||||
BGTaskSchedulerPermittedIdentifiers: [
|
BGTaskSchedulerPermittedIdentifiers: [
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ struct NotificationContentBuilder {
|
|||||||
return try await processPollStartEvent(notificationItem: notificationItem, pollQuestion: question, mediaProvider: mediaProvider)
|
return try await processPollStartEvent(notificationItem: notificationItem, pollQuestion: question, mediaProvider: mediaProvider)
|
||||||
case .callInvite:
|
case .callInvite:
|
||||||
return try await processCallInviteEvent(notificationItem: notificationItem, mediaProvider: mediaProvider)
|
return try await processCallInviteEvent(notificationItem: notificationItem, mediaProvider: mediaProvider)
|
||||||
|
case .callNotify:
|
||||||
|
return try await processCallNotifyEvent(notificationItem: notificationItem, mediaProvider: mediaProvider)
|
||||||
default:
|
default:
|
||||||
return processEmpty(notificationItem: notificationItem)
|
return processEmpty(notificationItem: notificationItem)
|
||||||
}
|
}
|
||||||
@@ -136,6 +138,12 @@ struct NotificationContentBuilder {
|
|||||||
notification.body = L10n.commonCallInvite
|
notification.body = L10n.commonCallInvite
|
||||||
return notification
|
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 {
|
private func processCommonRoomMessage(notificationItem: NotificationItemProxyProtocol, mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent {
|
||||||
var notification = baseMutableContent(for: notificationItem)
|
var notification = baseMutableContent(for: notificationItem)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CallKit
|
||||||
import Intents
|
import Intents
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
@@ -104,6 +105,10 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
|
|||||||
return discard(unreadCount: unreadCount)
|
return discard(unreadCount: unreadCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard await shouldHandleCallNotification(itemProxy) else {
|
||||||
|
return discard(unreadCount: unreadCount)
|
||||||
|
}
|
||||||
|
|
||||||
// After the first processing, update the modified content
|
// After the first processing, update the modified content
|
||||||
modifiedContent = try await notificationContentBuilder.content(for: itemProxy, mediaProvider: nil)
|
modifiedContent = try await notificationContentBuilder.content(for: itemProxy, mediaProvider: nil)
|
||||||
|
|
||||||
@@ -173,4 +178,48 @@ class NotificationServiceExtension: UNNotificationServiceExtension {
|
|||||||
NSELogger.logMemory(with: tag)
|
NSELogger.logMemory(with: tag)
|
||||||
MXLog.info("\(tag) deinit")
|
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/Notification/Proxy
|
||||||
- path: ../../ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift
|
- path: ../../ElementX/Sources/Services/Room/RoomSummary/RoomMessageEventStringBuilder.swift
|
||||||
- path: ../../ElementX/Sources/Services/UserSession/RestorationToken.swift
|
- path: ../../ElementX/Sources/Services/UserSession/RestorationToken.swift
|
||||||
|
- path: ../../ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift
|
||||||
- path: ../../ElementX/Sources/Application/AppSettings.swift
|
- path: ../../ElementX/Sources/Application/AppSettings.swift
|
||||||
|
|||||||
Reference in New Issue
Block a user