2024-05-17 15:13:57 +03:00
|
|
|
//
|
2024-09-06 16:34:30 +03:00
|
|
|
// Copyright 2024 New Vector Ltd.
|
2024-05-17 15:13:57 +03:00
|
|
|
//
|
2025-01-06 11:27:37 +01:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
|
|
// Please see LICENSE files in the repository root for full details.
|
2024-05-17 15:13:57 +03:00
|
|
|
//
|
|
|
|
|
|
|
|
import AVFoundation
|
|
|
|
import CallKit
|
|
|
|
import Combine
|
|
|
|
import Foundation
|
|
|
|
import PushKit
|
2024-06-27 14:07:44 +03:00
|
|
|
import UIKit
|
2024-05-17 15:13:57 +03:00
|
|
|
|
|
|
|
class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDelegate, CXProviderDelegate {
|
2024-06-27 14:07:44 +03:00
|
|
|
private struct CallID: Equatable {
|
|
|
|
let callKitID: UUID
|
|
|
|
let roomID: String
|
|
|
|
}
|
2024-05-17 15:13:57 +03:00
|
|
|
|
2024-06-27 14:07:44 +03:00
|
|
|
private let pushRegistry: PKPushRegistry
|
2024-05-17 15:13:57 +03:00
|
|
|
private let callController = CXCallController()
|
2024-06-27 14:07:44 +03:00
|
|
|
private let callProvider: CXProvider = {
|
|
|
|
let configuration = CXProviderConfiguration()
|
|
|
|
configuration.supportsVideo = true
|
|
|
|
configuration.includesCallsInRecents = true
|
|
|
|
|
|
|
|
if let callKitIcon = UIImage(named: "images/app-logo") {
|
|
|
|
configuration.iconTemplateImageData = callKitIcon.pngData()
|
|
|
|
}
|
|
|
|
|
|
|
|
// https://stackoverflow.com/a/46077628/730924
|
|
|
|
configuration.supportedHandleTypes = [.generic]
|
|
|
|
|
|
|
|
return CXProvider(configuration: configuration)
|
|
|
|
}()
|
2024-05-17 15:13:57 +03:00
|
|
|
|
2024-11-05 17:48:07 +00:00
|
|
|
private weak var clientProxy: ClientProxyProtocol? {
|
|
|
|
didSet {
|
|
|
|
// There's a race condition where a call starts when the app has been killed and the
|
|
|
|
// observation set in `incomingCallID` occurs *before* the user session is restored.
|
|
|
|
// So observe when the client proxy is set to fix this (the method guards for the call).
|
|
|
|
Task { await observeIncomingCallRoomInfo() }
|
|
|
|
}
|
|
|
|
}
|
2024-07-16 17:09:16 +03:00
|
|
|
|
2024-11-05 17:48:07 +00:00
|
|
|
private var incomingCallRoomInfoCancellable: AnyCancellable?
|
2024-07-16 17:09:16 +03:00
|
|
|
private var incomingCallID: CallID? {
|
|
|
|
didSet {
|
2024-11-05 17:48:07 +00:00
|
|
|
Task { await observeIncomingCallRoomInfo() }
|
2024-07-16 17:09:16 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-06-24 18:51:43 +02:00
|
|
|
private var endUnansweredCallTask: Task<Void, Never>?
|
|
|
|
|
2024-08-21 17:48:57 +01:00
|
|
|
private var ongoingCallID: CallID? {
|
|
|
|
didSet { ongoingCallRoomIDSubject.send(ongoingCallID?.roomID) }
|
|
|
|
}
|
2024-06-27 14:07:44 +03:00
|
|
|
|
2024-08-21 17:48:57 +01:00
|
|
|
let ongoingCallRoomIDSubject = CurrentValueSubject<String?, Never>(nil)
|
|
|
|
var ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never> {
|
|
|
|
ongoingCallRoomIDSubject.asCurrentValuePublisher()
|
|
|
|
}
|
2024-08-16 11:25:36 +01:00
|
|
|
|
2024-05-17 15:13:57 +03:00
|
|
|
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]
|
2024-06-27 14:07:44 +03:00
|
|
|
|
|
|
|
callProvider.setDelegate(self, queue: nil)
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|
|
|
|
|
2024-07-16 17:09:16 +03:00
|
|
|
func setClientProxy(_ clientProxy: any ClientProxyProtocol) {
|
|
|
|
self.clientProxy = clientProxy
|
|
|
|
}
|
|
|
|
|
2024-06-27 14:07:44 +03:00
|
|
|
func setupCallSession(roomID: String, roomDisplayName: String) async {
|
|
|
|
// Drop any ongoing calls when starting a new one
|
|
|
|
if ongoingCallID != nil {
|
|
|
|
tearDownCallSession()
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|
2024-06-27 14:07:44 +03:00
|
|
|
|
|
|
|
// If this starting from a ring reuse those identifiers
|
|
|
|
// Make sure the roomID matches
|
|
|
|
let callID = if let incomingCallID, incomingCallID.roomID == roomID {
|
|
|
|
incomingCallID
|
|
|
|
} else {
|
|
|
|
CallID(callKitID: UUID(), roomID: roomID)
|
|
|
|
}
|
|
|
|
|
|
|
|
incomingCallID = nil
|
2024-05-17 15:13:57 +03:00
|
|
|
ongoingCallID = callID
|
|
|
|
|
2024-11-07 16:57:17 +02:00
|
|
|
// Don't bother starting another CallKit session as it won't work properly
|
|
|
|
// https://developer.apple.com/forums//thread/767949?answerId=812951022#812951022
|
|
|
|
|
|
|
|
// let handle = CXHandle(type: .generic, value: roomDisplayName)
|
|
|
|
// let startCallAction = CXStartCallAction(call: callID.callKitID, handle: handle)
|
|
|
|
// startCallAction.isVideo = true
|
|
|
|
|
|
|
|
// do {
|
|
|
|
// try await callController.request(CXTransaction(action: startCallAction))
|
|
|
|
// } catch {
|
|
|
|
// MXLog.error("Failed requesting start call action with error: \(error)")
|
|
|
|
// }
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
func tearDownCallSession() {
|
2024-06-27 14:07:44 +03:00
|
|
|
tearDownCallSession(sendEndCallAction: true)
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|
2024-06-27 14:33:16 +03:00
|
|
|
|
2024-08-06 13:23:29 +03:00
|
|
|
func setAudioEnabled(_ enabled: Bool, roomID: String) {
|
2024-06-27 14:33:16 +03:00
|
|
|
guard let ongoingCallID else {
|
|
|
|
MXLog.error("Failed toggling call microphone, no calls running")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
guard ongoingCallID.roomID == roomID else {
|
|
|
|
MXLog.error("Failed toggling call microphone, rooms don't match: \(ongoingCallID.roomID) != \(roomID)")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-08-06 13:23:29 +03:00
|
|
|
let transaction = CXTransaction(action: CXSetMutedCallAction(call: ongoingCallID.callKitID, muted: !enabled))
|
2024-06-27 14:33:16 +03:00
|
|
|
callController.request(transaction) { error in
|
|
|
|
if let error {
|
|
|
|
MXLog.error("Failed toggling call microphone with error: \(error)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-05-17 15:13:57 +03:00
|
|
|
|
|
|
|
// 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 {
|
2024-05-27 12:49:24 +03:00
|
|
|
MXLog.error("Something went wrong, missing room identifier for incoming voip call: \(payload)")
|
2024-05-17 15:13:57 +03:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-08-08 14:02:14 +03:00
|
|
|
guard ongoingCallID?.roomID != roomID else {
|
|
|
|
MXLog.warning("Call already ongoing for room \(roomID), ignoring incoming push")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-06-27 14:07:44 +03:00
|
|
|
let callID = CallID(callKitID: UUID(), roomID: roomID)
|
|
|
|
incomingCallID = callID
|
2024-05-17 15:13:57 +03:00
|
|
|
|
2024-06-27 14:07:44 +03:00
|
|
|
let roomDisplayName = payload.dictionaryPayload[ElementCallServiceNotificationKey.roomDisplayName.rawValue] as? String
|
2024-05-17 15:13:57 +03:00
|
|
|
|
|
|
|
let update = CXCallUpdate()
|
|
|
|
update.hasVideo = true
|
2024-06-27 14:07:44 +03:00
|
|
|
update.localizedCallerName = roomDisplayName
|
2024-05-27 12:49:24 +03:00
|
|
|
// https://stackoverflow.com/a/41230020/730924
|
|
|
|
update.remoteHandle = .init(type: .generic, value: roomID)
|
2024-05-17 15:13:57 +03:00
|
|
|
|
2024-11-06 13:00:11 +02:00
|
|
|
callProvider.reportNewIncomingCall(with: callID.callKitID, update: update) { [weak self] error in
|
2024-05-17 15:13:57 +03:00
|
|
|
if let error {
|
|
|
|
MXLog.error("Failed reporting new incoming call with error: \(error)")
|
|
|
|
}
|
|
|
|
|
2024-11-06 13:00:11 +02:00
|
|
|
self?.actionsSubject.send(.receivedIncomingCallRequest)
|
|
|
|
|
2024-05-17 15:13:57 +03:00
|
|
|
completion()
|
|
|
|
}
|
2024-05-27 12:49:47 +03:00
|
|
|
|
2024-06-27 14:07:44 +03:00
|
|
|
endUnansweredCallTask = Task { [weak self] in
|
2024-12-04 09:31:38 +01:00
|
|
|
try? await Task.sleep(for: .seconds(90))
|
2024-06-27 14:07:44 +03:00
|
|
|
|
|
|
|
guard let self, !Task.isCancelled else {
|
2024-06-24 18:51:43 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-06-27 14:07:44 +03:00
|
|
|
if let incomingCallID, incomingCallID.callKitID == callID.callKitID {
|
|
|
|
callProvider.reportCall(with: incomingCallID.callKitID, endedAt: nil, reason: .unanswered)
|
2024-05-27 12:49:47 +03:00
|
|
|
}
|
|
|
|
}
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: - CXProviderDelegate
|
|
|
|
|
2024-08-08 13:54:45 +03:00
|
|
|
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
|
|
|
MXLog.info("Call provider did activate audio session")
|
|
|
|
}
|
|
|
|
|
|
|
|
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
|
|
|
MXLog.info("Call provider did deactivate audio session")
|
|
|
|
}
|
|
|
|
|
2024-05-17 15:13:57 +03:00
|
|
|
func providerDidReset(_ provider: CXProvider) {
|
|
|
|
MXLog.info("Call provider did reset: \(provider)")
|
|
|
|
}
|
|
|
|
|
|
|
|
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
2024-08-08 14:07:54 +03:00
|
|
|
guard let incomingCallID else {
|
2024-06-27 14:07:44 +03:00
|
|
|
MXLog.error("Failed answering incoming call, missing incomingCallID")
|
2024-08-08 14:07:54 +03:00
|
|
|
return
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|
|
|
|
|
2024-08-08 14:07:54 +03:00
|
|
|
// Fixes broken videos on EC web when a CallKit session is established.
|
|
|
|
//
|
|
|
|
// Reporting an ongoing call through `reportNewIncomingCall` + `CXAnswerCallAction`
|
|
|
|
// or `reportOutgoingCall:connectedAt:` will give exclusive access for media to the
|
|
|
|
// ongoing process, which is different than the WKWebKit is running on, making EC
|
|
|
|
// unable to aquire media streams.
|
|
|
|
// Reporting the call as ended imediately after answering it works around that
|
|
|
|
// as EC gets access to media again and EX builds the right UI in `setupCallSession`
|
|
|
|
//
|
2024-11-07 16:57:17 +02:00
|
|
|
// https://developer.apple.com/forums//thread/767949?answerId=812951022#812951022
|
|
|
|
//
|
2024-08-08 14:07:54 +03:00
|
|
|
// https://github.com/element-hq/element-x-ios/issues/3041
|
|
|
|
// https://forums.developer.apple.com/forums/thread/685268
|
|
|
|
// https://stackoverflow.com/questions/71483732/webrtc-running-from-wkwebview-avaudiosession-development-roadblock
|
|
|
|
|
|
|
|
// First fullfill the action
|
2024-05-17 15:13:57 +03:00
|
|
|
action.fulfill()
|
2024-08-08 14:07:54 +03:00
|
|
|
|
|
|
|
// And delay ending the call so that the app has enough time
|
|
|
|
// to get deeplinked into
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
|
|
// Then end the and call rely on `setupCallSession` to create a new one
|
|
|
|
provider.reportCall(with: incomingCallID.callKitID, endedAt: nil, reason: .remoteEnded)
|
|
|
|
|
|
|
|
self.actionsSubject.send(.startCall(roomID: incomingCallID.roomID))
|
|
|
|
self.endUnansweredCallTask?.cancel()
|
|
|
|
}
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|
|
|
|
|
2024-06-27 14:07:44 +03:00
|
|
|
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
2024-08-06 13:23:29 +03:00
|
|
|
if let ongoingCallID {
|
|
|
|
actionsSubject.send(.setAudioEnabled(!action.isMuted, roomID: ongoingCallID.roomID))
|
|
|
|
} else {
|
|
|
|
MXLog.error("Failed muting/unmuting call, missing ongoingCallID")
|
|
|
|
}
|
|
|
|
|
|
|
|
action.fulfill()
|
2024-06-27 14:07:44 +03:00
|
|
|
}
|
|
|
|
|
2024-05-17 15:13:57 +03:00
|
|
|
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
2024-07-22 16:36:36 +03:00
|
|
|
#if targetEnvironment(simulator)
|
|
|
|
// This gets called for no reason on simulators, where CallKit
|
2024-10-29 15:21:17 +02:00
|
|
|
// isn't even supported, ignore it.
|
|
|
|
#else
|
2024-06-27 14:07:44 +03:00
|
|
|
if let ongoingCallID {
|
|
|
|
actionsSubject.send(.endCall(roomID: ongoingCallID.roomID))
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|
2024-06-27 14:07:44 +03:00
|
|
|
|
|
|
|
tearDownCallSession(sendEndCallAction: false)
|
|
|
|
|
2024-05-17 15:13:57 +03:00
|
|
|
action.fulfill()
|
2024-10-29 15:21:17 +02:00
|
|
|
#endif
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|
2024-06-27 14:07:44 +03:00
|
|
|
|
|
|
|
// MARK: - Private
|
|
|
|
|
2024-11-05 17:48:07 +00:00
|
|
|
private func tearDownCallSession(sendEndCallAction: Bool = true) {
|
2024-06-27 14:07:44 +03:00
|
|
|
if sendEndCallAction, let ongoingCallID {
|
|
|
|
let transaction = CXTransaction(action: CXEndCallAction(call: ongoingCallID.callKitID))
|
|
|
|
callController.request(transaction) { error in
|
|
|
|
if let error {
|
|
|
|
MXLog.error("Failed transaction with error: \(error)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
ongoingCallID = nil
|
|
|
|
}
|
2024-07-16 17:09:16 +03:00
|
|
|
|
2024-11-05 17:48:07 +00:00
|
|
|
private func observeIncomingCallRoomInfo() async {
|
|
|
|
incomingCallRoomInfoCancellable = nil
|
2024-07-16 17:09:16 +03:00
|
|
|
|
2024-11-05 17:48:07 +00:00
|
|
|
guard let incomingCallID else {
|
|
|
|
MXLog.info("No incoming call to observe for.")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let clientProxy else {
|
|
|
|
MXLog.warning("A ClientProxy is needed to fetch the room.")
|
2024-07-16 17:09:16 +03:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-08-20 16:13:27 +03:00
|
|
|
guard case let .joined(roomProxy) = await clientProxy.roomForIdentifier(incomingCallID.roomID) else {
|
2024-11-05 17:48:07 +00:00
|
|
|
MXLog.warning("Failed to fetch a joined room for the incoming call.")
|
2024-07-16 17:09:16 +03:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
roomProxy.subscribeToRoomInfoUpdates()
|
|
|
|
|
2024-11-05 17:48:07 +00:00
|
|
|
incomingCallRoomInfoCancellable = roomProxy
|
2024-10-28 12:29:31 +00:00
|
|
|
.infoPublisher
|
|
|
|
.compactMap { ($0.hasRoomCall, $0.activeRoomCallParticipants) }
|
2024-08-08 14:10:04 +03:00
|
|
|
.removeDuplicates { $0 == $1 }
|
2024-12-06 16:58:14 +02:00
|
|
|
.drop { hasRoomCall, _ in
|
2024-11-05 17:48:07 +00:00
|
|
|
// Filter all updates before hasRoomCall becomes `true`. Then we can correctly
|
|
|
|
// detect its change to `false` to stop ringing when the caller hangs up.
|
|
|
|
!hasRoomCall
|
2024-12-06 16:58:14 +02:00
|
|
|
}
|
2024-08-08 14:10:04 +03:00
|
|
|
.sink { [weak self] hasOngoingCall, activeRoomCallParticipants in
|
2024-07-16 17:09:16 +03:00
|
|
|
guard let self else { return }
|
|
|
|
|
2024-08-08 14:10:04 +03:00
|
|
|
let participants: [String] = activeRoomCallParticipants
|
|
|
|
|
2024-07-16 17:09:16 +03:00
|
|
|
if !hasOngoingCall {
|
2024-08-08 14:10:04 +03:00
|
|
|
MXLog.info("Call cancelled by remote")
|
|
|
|
|
2024-11-05 17:48:07 +00:00
|
|
|
incomingCallRoomInfoCancellable = nil
|
2024-07-16 17:09:16 +03:00
|
|
|
endUnansweredCallTask?.cancel()
|
|
|
|
callProvider.reportCall(with: incomingCallID.callKitID, endedAt: nil, reason: .remoteEnded)
|
2024-08-08 14:10:04 +03:00
|
|
|
} else if participants.contains(roomProxy.ownUserID) {
|
2024-11-05 17:48:07 +00:00
|
|
|
MXLog.info("Call answered elsewhere")
|
2024-08-08 14:10:04 +03:00
|
|
|
|
2024-11-05 17:48:07 +00:00
|
|
|
incomingCallRoomInfoCancellable = nil
|
2024-08-08 14:10:04 +03:00
|
|
|
endUnansweredCallTask?.cancel()
|
|
|
|
callProvider.reportCall(with: incomingCallID.callKitID, endedAt: nil, reason: .answeredElsewhere)
|
2024-07-16 17:09:16 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-05-17 15:13:57 +03:00
|
|
|
}
|