Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Calls/UserInterface/CallKitCallUIAdaptee.swift
1 views
//
// Copyright 2016 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import AVFoundation
import CallKit
import SignalServiceKit
import SignalUI

/**
 * Connects user interface to the CallService using CallKit.
 *
 * User interface is routed to the CallManager which requests CXCallActions, and if the CXProvider accepts them,
 * their corresponding consequences are implemented in the CXProviderDelegate methods, e.g. using the CallService
 */
final class CallKitCallUIAdaptee: NSObject, CallUIAdaptee, @preconcurrency CXProviderDelegate {
    private let callManager: CallKitCallManager
    var callService: CallService { AppEnvironment.shared.callService }
    private let showNamesOnCallScreen: Bool
    private let provider: CXProvider
    private let audioActivity: AudioActivity

    // Instantiating more than one CXProvider can cause us to miss call transactions, so
    // we maintain the provider across Adaptees using a singleton pattern
    private static let providerReadyFlag: ReadyFlag = ReadyFlag(name: "CallKitCXProviderReady")
    private static var _sharedProvider: CXProvider?
    class func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
        let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)

        if let sharedProvider = self._sharedProvider {
            sharedProvider.configuration = configuration
            return sharedProvider
        } else {
            SwiftSingletons.register(self)
            let provider = CXProvider(configuration: configuration)
            _sharedProvider = provider
            return provider
        }
    }

    // The app's provider configuration, representing its CallKit capabilities
    class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
        let providerConfiguration = CXProviderConfiguration()

        providerConfiguration.supportsVideo = true

        // Default maximumCallGroups is 2. We previously overrode this value to be 1.
        //
        // The terminology can be confusing. Even though we don't currently support "group calls"
        // *every* call is in a call group. Our call groups all just happen to be "groups" with 1
        // call in them.
        //
        // maximumCallGroups limits how many different calls CallKit can know about at one time.
        // Exceeding this limit will cause CallKit to error when reporting an additional call.
        //
        // Generally for us, the number of call groups is 1 or 0, *however* when handling a rapid
        // sequence of offers and hangups, due to the async nature of CXTransactions, there can
        // be a brief moment where the old limit of 1 caused CallKit to fail the newly reported
        // call, even though we were properly requesting hangup of the old call before reporting the
        // new incoming call.
        //
        // Specifically after 10 or so rapid fire call/hangup/call/hangup, eventually an incoming
        // call would fail to report due to CXErrorCodeRequestTransactionErrorMaximumCallGroupsReached
        //
        // ...so that's why we no longer use the non-default value of 1, which I assume was only ever
        // set to 1 out of confusion.
        // providerConfiguration.maximumCallGroups = 1

        providerConfiguration.maximumCallsPerCallGroup = 1

        providerConfiguration.supportedHandleTypes = [.phoneNumber, .generic]

        let iconMaskImage = #imageLiteral(resourceName: "signal-logo-128")
        providerConfiguration.iconTemplateImageData = iconMaskImage.pngData()

        // We don't set the ringtoneSound property, so that we use either the
        // default iOS ringtone OR the custom ringtone associated with this user's
        // system contact.
        providerConfiguration.includesCallsInRecents = useSystemCallLog

        return providerConfiguration
    }

    init(showNamesOnCallScreen: Bool, useSystemCallLog: Bool) {
        AssertIsOnMainThread()

        Logger.debug("")

        self.callManager = CallKitCallManager(showNamesOnCallScreen: showNamesOnCallScreen)

        self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog)

        self.audioActivity = AudioActivity(audioDescription: "[CallKitCallUIAdaptee]", behavior: .call)
        self.showNamesOnCallScreen = showNamesOnCallScreen

        super.init()

        // We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings

        self.provider.setDelegate(self, queue: nil)
    }

    private func localizedCallerNameWithSneakyTransaction(for call: SignalCall) -> String {
        switch call.mode {
        case .individual(let call):
            if showNamesOnCallScreen {
                return SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.displayName(for: call.thread, transaction: tx) }
            }
            return OWSLocalizedString(
                "CALLKIT_ANONYMOUS_CONTACT_NAME",
                comment: "The generic name used for calls if CallKit privacy is enabled",
            )
        case .groupThread(let call):
            if showNamesOnCallScreen {
                let groupName = SSKEnvironment.shared.databaseStorageRef.read { tx -> String? in
                    let groupThread = TSGroupThread.fetch(forGroupId: call.groupId, tx: tx)
                    guard let groupThread else {
                        owsFailDebug("Missing group thread for active call.")
                        return nil
                    }
                    let contactManager = SSKEnvironment.shared.contactManagerRef
                    return contactManager.displayName(for: groupThread, transaction: tx)
                }
                if let groupName {
                    return groupName
                }
            }
            return OWSLocalizedString(
                "CALLKIT_ANONYMOUS_GROUP_NAME",
                comment: "The generic name used for group calls if CallKit privacy is enabled",
            )
        case .callLink(let call):
            if showNamesOnCallScreen {
                return call.callLinkState.localizedName
            }
            return CallLinkState.defaultLocalizedName
        }
    }

    // MARK: CallUIAdaptee

    @MainActor
    func startOutgoingCall(call: SignalCall) {
        Logger.info("")

        // Add the new outgoing call to the app's list of calls.
        // So we can find it in the provider delegate callbacks.
        Self.providerReadyFlag.runNowOrWhenDidBecomeReadySync {
            self.callManager.addCall(call)
            self.callManager.startOutgoingCall(call)
        }
    }

    @MainActor
    private func endCallOnceReported(_ call: SignalCall, reason: CXCallEndedReason) {
        Logger.info("CallKit: CXCallEndedReason reason: \(reason)")
        Self.providerReadyFlag.runNowOrWhenDidBecomeReadySync {
            switch call.commonState.systemState {
            case .notReported:
                // Do nothing. This call was never reported to CallKit, so we don't need to report it ending.
                // This happens for calls missed while offline.
                // (If CallKit ever adds a way to report *past* missed calls, this might be a place to do it.)
                break
            case .pending:
                // We've reported the call to CallKit, but CallKit hasn't confirmed it yet.
                // Try again soon, but give up if the call ends some other way and is destroyed.
                DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1), qos: .userInitiated) { [weak call] in
                    guard let call else {
                        return
                    }
                    self.endCallOnceReported(call, reason: reason)
                }
            case .reported:
                self.provider.reportCall(with: call.localId, endedAt: nil, reason: reason)
                self.callManager.removeCall(call)
            case .removed:
                Logger.warn("call \(call.localId) already ended, but is now ending a second time with reason code \(reason)")
            }
        }
    }

    // Called from CallService after call has ended to clean up any remaining CallKit call state.
    @MainActor
    func failCall(_ call: SignalCall, error: CallError) {
        Logger.info("")

        let reason: CXCallEndedReason
        switch error {
        case .timeout:
            reason = .unanswered
        default:
            reason = .failed
        }
        self.endCallOnceReported(call, reason: reason)
    }

    @MainActor
    func reportIncomingCall(_ call: SignalCall, completion: @escaping (Error?) -> Void) {
        Logger.info("")

        // Construct a CXCallUpdate describing the incoming call, including the caller.
        let update = CXCallUpdate()
        update.localizedCallerName = localizedCallerNameWithSneakyTransaction(for: call)
        update.remoteHandle = callManager.createCallHandleWithSneakyTransaction(for: call)
        update.hasVideo = { () -> Bool in
            switch call.mode {
            case .individual(let individualCall):
                return individualCall.offerMediaType == .video
            case .groupThread:
                return true
            case .callLink:
                owsFail("Can't ring Call Link calls.")
            }
        }()

        disableUnsupportedFeatures(callUpdate: update)

        // TODO: Add proper Sendable support to these types.
        let addCall = {
            self.callManager.addCall(call)
        }

        Self.providerReadyFlag.runNowOrWhenDidBecomeReadySync {
            call.commonState.markPendingReportToSystem()

            // Report the incoming call to the system
            self.provider.reportNewIncomingCall(with: call.localId, update: update) { error in
                /*
                 Only add incoming call to the app's list of calls if the call was allowed (i.e. there was no error)
                 since calls may be "denied" for various legitimate reasons. See CXErrorCodeIncomingCallError.
                 */
                AppEnvironment.shared.pushRegistrationManagerRef.didFinishReportingIncomingCall()

                guard error == nil else {
                    completion(error)
                    Logger.error("failed to report new incoming call, error: \(error!)")
                    return
                }

                completion(nil)

                addCall()
            }
        }
    }

    @MainActor
    func answerCall(_ call: SignalCall) {
        Logger.info("")

        Self.providerReadyFlag.runNowOrWhenDidBecomeReadySync {
            self.callManager.answer(call: call)
        }
    }

    private var ignoreFirstUnmuteAfterRemoteAnswer = false

    @MainActor
    func recipientAcceptedCall(_ call: CallMode) {
        Logger.info("")

        Self.providerReadyFlag.runNowOrWhenDidBecomeReadySync {
            self.provider.reportOutgoingCall(with: call.commonState.localId, connectedAt: nil)

            let update = CXCallUpdate()
            self.disableUnsupportedFeatures(callUpdate: update)

            self.provider.reportCall(with: call.commonState.localId, updated: update)

            // When we tell CallKit about the call, it tries
            // to unmute the call. We can work around this
            // by ignoring the next "unmute" request from
            // CallKit after the call is answered.
            self.ignoreFirstUnmuteAfterRemoteAnswer = call.isOutgoingAudioMuted

            // Enable audio for remotely accepted calls after the session is configured.
            SUIEnvironment.shared.audioSessionRef.isRTCAudioEnabled = true
        }
    }

    func localHangupCall(_ call: SignalCall) {
        AssertIsOnMainThread()
        Logger.info("")

        guard call.commonState.systemState == .reported else {
            callService.handleLocalHangupCall(call)
            return
        }

        Self.providerReadyFlag.runNowOrWhenDidBecomeReadySync {
            self.callManager.localHangup(call: call)
        }
    }

    func remoteDidHangupCall(_ call: SignalCall) {
        AssertIsOnMainThread()
        Logger.info("")
        endCallOnceReported(call, reason: .remoteEnded)
    }

    func remoteBusy(_ call: SignalCall) {
        AssertIsOnMainThread()
        Logger.info("")
        endCallOnceReported(call, reason: .unanswered)
    }

    func didAnswerElsewhere(call: SignalCall) {
        Logger.info("")
        endCallOnceReported(call, reason: .answeredElsewhere)
    }

    func didDeclineElsewhere(call: SignalCall) {
        AssertIsOnMainThread()
        Logger.info("")
        endCallOnceReported(call, reason: .declinedElsewhere)
    }

    func wasBusyElsewhere(call: SignalCall) {
        AssertIsOnMainThread()
        Logger.info("")
        // CallKit doesn't have a reason for "busy elsewhere", .declinedElsewhere is close enough.
        endCallOnceReported(call, reason: .declinedElsewhere)
    }

    func setIsMuted(call: SignalCall, isMuted: Bool) {
        AssertIsOnMainThread()
        Logger.info("")

        Self.providerReadyFlag.runNowOrWhenDidBecomeReadySync {
            self.callManager.setIsMuted(call: call, isMuted: isMuted)
        }
    }

    func setHasLocalVideo(call: SignalCall, hasLocalVideo: Bool) {
        AssertIsOnMainThread()
        Logger.debug("")
        callService.updateIsLocalVideoMuted(isLocalVideoMuted: !hasLocalVideo)

        // Update the CallKit UI.
        Self.providerReadyFlag.runNowOrWhenDidBecomeReadySync {
            let update = CXCallUpdate()
            update.hasVideo = hasLocalVideo
            self.provider.reportCall(with: call.localId, updated: update)
        }
    }

    // MARK: CXProviderDelegate

    @MainActor
    func providerDidBegin(_ provider: CXProvider) {
        Self.providerReadyFlag.setIsReady()
    }

    @MainActor
    func providerDidReset(_ provider: CXProvider) {
        Logger.info("")

        // End any ongoing calls if the provider resets, and remove them from the app's list of calls,
        // since they are no longer valid.
        callService.individualCallService.handleCallKitProviderReset()

        // Remove all calls from the app's list of calls.
        callManager.removeAllCalls()
    }

    @MainActor
    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        Logger.info("CallKit: CXStartCallAction")

        guard let call = callManager.callWithLocalId(action.callUUID) else {
            Logger.error("unable to find call")
            return
        }

        // We can't wait for long before fulfilling the CXAction, else CallKit will show a "Failed Call". We don't
        // actually need to wait for the outcome of the handleOutgoingCall promise, because it handles any errors by
        // manually failing the call.
        switch call.mode {
        case .individual:
            self.callService.individualCallService.handleOutgoingCall(call)
        case .groupThread, .callLink:
            break
        }

        action.fulfill()
        provider.reportOutgoingCall(with: call.localId, startedConnectingAt: nil)

        let update = CXCallUpdate()
        update.localizedCallerName = localizedCallerNameWithSneakyTransaction(for: call)
        provider.reportCall(with: call.localId, updated: update)

        switch call.mode {
        case .individual:
            break
        case .groupThread(let groupThreadCall):
            switch groupThreadCall.groupCallRingState {
            case .shouldRing where groupThreadCall.ringRestrictions.isEmpty, .ringing:
                // Let CallService call recipientAcceptedCall when someone joins.
                break
            case .ringingEnded:
                Logger.warn("ringing ended before we even reported the call to CallKit (maybe our peek info was out of date)")
                fallthrough
            case .doNotRing, .shouldRing:
                // Immediately consider ourselves connected.
                recipientAcceptedCall(call.mode)
            case .incomingRing, .incomingRingCancelled:
                owsFailDebug("should not happen for an outgoing call")
                // Recover by considering ourselves connected
                recipientAcceptedCall(call.mode)
            }
        case .callLink:
            recipientAcceptedCall(call.mode)
        }
    }

    @MainActor
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        Logger.info("CallKit: CXAnswerCallAction \(action.timeoutDate)")
        guard let call = callManager.callWithLocalId(action.callUUID) else {
            owsFailDebug("call as unexpectedly nil")
            action.fail()
            return
        }

        switch call.mode {
        case .callLink:
            owsFail("Can't answer Call Link calls.")
        case .groupThread(let groupThreadCall):
            // Explicitly unmute to request permissions, if needed.
            callService.updateIsLocalAudioMuted(isLocalAudioMuted: call.isOutgoingAudioMuted || groupThreadCall.shouldMuteAutomatically())
            // Explicitly start video to request permissions, if needed.
            // This has the added effect of putting the video mute button in the correct state
            // if the user has disabled camera permissions for the app.
            callService.updateIsLocalVideoMuted(isLocalVideoMuted: groupThreadCall.ringRtcCall.isOutgoingVideoMuted)
            callService.joinGroupCallIfNecessary(call, groupCall: groupThreadCall)
            action.fulfill()
        case .individual(let individualCall):
            // Explicitly start video to request permissions, if needed.
            // This has the added effect of putting the video mute button in the correct state
            // if the user has disabled camera permissions for the app.
            callService.updateIsLocalVideoMuted(isLocalVideoMuted: !individualCall.hasLocalVideo)
            if individualCall.state == .localRinging_Anticipatory {
                // We can't answer the call until RingRTC is ready
                individualCall.state = .accepting
                individualCall.deferredAnswerCompletion = {
                    action.fulfill()
                }
            } else {
                owsAssertDebug(individualCall.state == .localRinging_ReadyToAnswer)
                callService.individualCallService.handleAcceptCall(call)
                action.fulfill()
            }
        }
    }

    @MainActor
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        Logger.info("CallKit: CXEndCallAction")
        guard let call = callManager.callWithLocalId(action.callUUID) else {
            Logger.error("trying to end unknown call with localId: \(action.callUUID)")
            action.fail()
            return
        }

        callService.handleLocalHangupCall(call)

        // Signal to the system that the action has been successfully performed.
        action.fulfill()

        // Remove the ended call from the app's list of calls.
        self.callManager.removeCall(call)
    }

    @MainActor
    func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
        Logger.info("CallKit: CXSetHeldCallAction")
        guard let call = callManager.callWithLocalId(action.callUUID) else {
            action.fail()
            return
        }

        // Update the IndividualCall's underlying hold state.
        self.callService.individualCallService.setIsOnHold(call: call, isOnHold: action.isOnHold)

        // Signal to the system that the action has been successfully performed.
        action.fulfill()
    }

    @MainActor
    func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
        Logger.info("CallKit: CXSetMutedCallAction")
        guard nil != callManager.callWithLocalId(action.callUUID) else {
            Logger.info("Failing CXSetMutedCallAction for unknown (ended?) call: \(action.callUUID)")
            action.fail()
            return
        }

        defer { ignoreFirstUnmuteAfterRemoteAnswer = false }
        guard !ignoreFirstUnmuteAfterRemoteAnswer || action.isMuted else {
            action.fulfill()
            return
        }

        self.callService.updateIsLocalAudioMuted(isLocalAudioMuted: action.isMuted)
        action.fulfill()
    }

    func provider(_ provider: CXProvider, perform action: CXSetGroupCallAction) {
        AssertIsOnMainThread()

        Logger.warn("CallKit: CXSetGroupCallAction unimplemented")
    }

    func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) {
        AssertIsOnMainThread()

        Logger.warn("CallKit: CXPlayDTMFCallAction unimplemented")
    }

    func provider(_ provider: CXProvider, timedOutPerforming action: CXAction) {
        AssertIsOnMainThread()

        if let muteAction = action as? CXSetMutedCallAction {
            guard callManager.callWithLocalId(muteAction.callUUID) != nil else {
                // When a call is over, if it was muted, CallKit "helpfully" attempts to unmute the
                // call with "CXSetMutedCallAction", presumably to help us clean up state.
                //
                // That is, it calls func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction)
                //
                // We don't need this - we have our own mechanism for coalescing audio state, so
                // we acknowledge the action, but perform a no-op.
                //
                // However, regardless of fulfilling or failing the action, the action "times out"
                // on iOS13. CallKit similarly "auto unmutes" ended calls on iOS12, but on iOS12
                // it doesn't timeout.
                //
                // Presumably this is a regression in iOS13 - so we ignore it.
                // #RADAR FB7568405
                Logger.info("ignoring timeout for CXSetMutedCallAction for ended call: \(muteAction.callUUID)")
                return
            }
        }

        owsFailDebug("CallKit: Timed out while performing \(action)")
    }

    @MainActor
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        AssertIsOnMainThread()

        Logger.info("CallKit: didActivate AVAudioSession")

        _ = SUIEnvironment.shared.audioSessionRef.startAudioActivity(self.audioActivity)

        guard let call = self.callService.callServiceState.currentCall else {
            owsFailDebug("No current call for AudioSession")
            return
        }

        switch call.mode {
        case .individual(let individualCall) where individualCall.direction == .incoming:
            // Only enable audio upon activation for locally accepted calls.
            SUIEnvironment.shared.audioSessionRef.isRTCAudioEnabled = true
        case .individual, .groupThread, .callLink:
            break
        }
    }

    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        AssertIsOnMainThread()

        Logger.info("CallKit: didDeactivate AVAudioSession")

        SUIEnvironment.shared.audioSessionRef.isRTCAudioEnabled = false
        SUIEnvironment.shared.audioSessionRef.endAudioActivity(self.audioActivity)
    }

    // MARK: - Util

    private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
        // Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
        // until user returns to in-app call screen.
        callUpdate.supportsHolding = false

        // Not yet supported
        callUpdate.supportsGrouping = false
        callUpdate.supportsUngrouping = false

        // Is there any reason to support this?
        callUpdate.supportsDTMF = false
    }
}