Xamarin.iOS 中的 CallKit

iOS 10 中的新 CallKit API 为 VOIP 应用提供了一种与 iPhone UI 集成的方式,并为最终用户提供熟悉的界面和体验。 使用此 API,用户可以查看 iOS 设备的锁定屏幕中的 VOIP 呼叫并与之交互,并使用手机应用的“收藏夹”和“最近访问”视图管理联系人。

关于 CallKit

根据 Apple 公司的说法,CallKit 是一个新框架,可以在 iOS 10 上将第三方 IP 语音 (VOIP) 应用提升为第一方体验。 CallKit API 使 VOIP 应用能够与 iPhone UI 集成,并为最终用户提供熟悉的界面和体验。 与内置的电话应用一样,用户可以从 iOS 设备的锁屏界面查看 VOIP 呼叫并与之交互,并使用电话应用的“收藏夹”和“最近通话”视图管理联系人

此外,CallKit API 还提供了创建应用扩展的功能,这些应用扩展可以将电话号码与姓名(呼叫方 ID)相关联,或告知系统何时应阻止某个号码(呼叫阻止)。

现有的 VOIP 应用体验

在讨论新的 CallKit API 及其功能之前,请使用名为 MonkeyCall 的虚构 VOIP 应用,来了解一下 iOS 9(及更低版本)中第三方 VOIP 应用的当前用户体验。 MonkeyCall 是一个简单的应用,允许用户使用现有的 iOS API 发送和接收 VOIP 呼叫。

目前,如果用户在 MonkeyCall 上接听传入呼叫并且其 iPhone 已锁定,则锁屏界面界面上收到的通知与任何其他类型的通知(例如来自“消息”或“邮件”应用的通知)无法区分。

如果用户想要接听呼叫,他们必须滑动 MonkeyCall 通知来打开应用并输入密码(或用户 Touch ID)来解锁手机,然后才能接听呼叫并开始对话。

如果手机已解锁,体验也同样很麻烦。 同样,传入的 MonkeyCall 呼叫会显示为从屏幕顶部滑入的标准通知横幅。 由于通知是临时性的,因此用户很容易错过它,迫使他们打开通知中心并找到要应答的特定通知,然后拨打电话或手动查找并启动 MonkeyCall 应用。

CallKit VOIP 应用体验

通过在 MonkeyCall 应用中实现新的 CallKit API,用户在 iOS 10 中的 VOIP 传入呼叫体验可以得到极大改善。 例如,在手机锁定的情况下,用户屏幕上方显示收到了 VOIP 呼叫。 通过实现 CallKit,呼叫将显示在 iPhone 的锁屏界面界面上,就像从内置电话应用接收呼叫一样,并且具有全屏、本机 UI 和标准的滑动接听功能。

同样,如果 iPhone 在收到 MonkeyCall VOIP 呼叫时解锁,则会呈现相同的全屏、本机 UI 以及内置电话应用的标准滑动接听和点击拒绝功能,并且 MonkeyCall 具有播放自定义铃声的选项。

CallKit 为 MonkeyCall 提供了附加功能,允许其 VOIP 呼叫与其他类型的呼叫交互、显示在内置的“最近通话”和“收藏夹”列表中、使用内置的“请勿打扰”和“阻止”功能、从 Siri 启动 MonkeyCall 呼叫,并为用户提供将 MonkeyCall 呼叫分配到联系人应用中的人员的功能。

以下部分将详细介绍 CallKit 体系结构、传入和传出呼叫流程以及 CallKit API。

CallKit 体系结构

在 iOS 10 中,Apple 已在所有系统服务中采用 CallKit,例如,系统 UI 可以通过 CallKit 知道在 CarPlay 上进行的呼叫。 在下面的示例中,由于 MonkeyCall 采用 CallKit,因此系统以与这些内置系统服务相同的方式了解该技术,并获得所有相同的功能:

The CallKit Service Stack

请仔细查看上图中的 MonkeyCall 应用。 该应用包含用于与其自己的网络通信的所有代码,并包含自己的用户界面。 它在 CallKit 中链接以与系统通信:

MonkeyCall App Architecture

应用使用 CallKit 中的两个主要接口:

  • CXProvider - 允许 MonkeyCall 应用向系统传达可能发生的任何带外通知。
  • CXCallController - 允许 MonkeyCall 应用向系统告知本地用户操作。

CXProvider

如上所述,CXProvider 允许应用向系统传达可能发生的任何带外通知。 这些通知不是因本地用户操作而发生,而是因外部事件(例如传入呼叫)而发生。

应用应使用 CXProvider 来实现以下目的:

  • 向系统报告传入呼叫。
  • 报告传出呼叫已连接到系统。
  • 向系统报告远程用户结束呼叫。

当应用想要与系统通信时,它将使用 CXCallUpdate 类;当系统需要与应用通信时,它将使用 CXAction 类:

Communicating with the system via a CXProvider

CXCallController

CXCallController 允许应用向系统告知本地用户操作,例如用户发起 VOIP 呼叫。 通过实现 CXCallController,应用可与系统中其他类型的呼叫交互。 例如,如果已经有一个正在进行的电话呼叫,则 CXCallController 可以允许 VOIP 应用将该呼叫置于通话保持状态并发起或接听 VOIP 呼叫。

应用应使用 CXCallController 来实现以下目的:

  • 向系统报告用户何时发起了传出呼叫。
  • 向系统报告用户何时接听了传入呼叫。
  • 向系统报告用户何时结束呼叫。

当应用想要将本地用户操作传达给系统时,它会使用 CXTransaction 类:

Reporting to the system using a CXCallController

实现 CallKit

以下部分将介绍如何在 Xamarin.iOS VOIP 应用中实现 CallKit。 为方便演示示例,本文档将使用虚构的 MonkeyCall VOIP 应用中的代码。 此处提供的代码代表了几个支持类,以下部分将详细介绍 CallKit 特定的部分。

ActiveCall 类

MonkeyCall 应用使用 ActiveCall 类来保存有关当前处于活动状态的 VOIP 呼叫的所有信息,如下所示:

using System;
using CoreFoundation;
using Foundation;

namespace MonkeyCall
{
    public class ActiveCall
    {
        #region Private Variables
        private bool isConnecting;
        private bool isConnected;
        private bool isOnhold;
        #endregion

        #region Computed Properties
        public NSUuid UUID { get; set; }
        public bool isOutgoing { get; set; }
        public string Handle { get; set; }
        public DateTime StartedConnectingOn { get; set;}
        public DateTime ConnectedOn { get; set;}
        public DateTime EndedOn { get; set; }

        public bool IsConnecting {
            get { return isConnecting; }
            set {
                isConnecting = value;
                if (isConnecting) StartedConnectingOn = DateTime.Now;
                RaiseStartingConnectionChanged ();
            }
        }

        public bool IsConnected {
            get { return isConnected; }
            set {
                isConnected = value;
                if (isConnected) {
                    ConnectedOn = DateTime.Now;
                } else {
                    EndedOn = DateTime.Now;
                }
                RaiseConnectedChanged ();
            }
        }

        public bool IsOnHold {
            get { return isOnhold; }
            set {
                isOnhold = value;
            }
        }
        #endregion

        #region Constructors
        public ActiveCall ()
        {
        }

        public ActiveCall (NSUuid uuid, string handle, bool outgoing)
        {
            // Initialize
            this.UUID = uuid;
            this.Handle = handle;
            this.isOutgoing = outgoing;
        }
        #endregion

        #region Public Methods
        public void StartCall (ActiveCallbackDelegate completionHandler)
        {
            // Simulate the call starting successfully
            completionHandler (true);

            // Simulate making a starting and completing a connection
            DispatchQueue.MainQueue.DispatchAfter (new DispatchTime(DispatchTime.Now, 3000), () => {
                // Note that the call is starting
                IsConnecting = true;

                // Simulate pause before connecting
                DispatchQueue.MainQueue.DispatchAfter (new DispatchTime (DispatchTime.Now, 1500), () => {
                    // Note that the call has connected
                    IsConnecting = false;
                    IsConnected = true;
                });
            });
        }

        public void AnswerCall (ActiveCallbackDelegate completionHandler)
        {
            // Simulate the call being answered
            IsConnected = true;
            completionHandler (true);
        }

        public void EndCall (ActiveCallbackDelegate completionHandler)
        {
            // Simulate the call ending
            IsConnected = false;
            completionHandler (true);
        }
        #endregion

        #region Events
        public delegate void ActiveCallbackDelegate (bool successful);
        public delegate void ActiveCallStateChangedDelegate (ActiveCall call);

        public event ActiveCallStateChangedDelegate StartingConnectionChanged;
        internal void RaiseStartingConnectionChanged ()
        {
            if (this.StartingConnectionChanged != null) this.StartingConnectionChanged (this);
        }

        public event ActiveCallStateChangedDelegate ConnectedChanged;
        internal void RaiseConnectedChanged ()
        {
            if (this.ConnectedChanged != null) this.ConnectedChanged (this);
        }
        #endregion
    }
}

ActiveCall 保存多个属性,用于定义呼叫状态以及呼叫状态更改时可引发的两个事件。 由于这只是一个示例,因此可以使用三个方法来模拟发起、接听和结束呼叫。

StartCallRequest 类

StartCallRequest 静态类提供了几个在处理传出呼叫时将使用的帮助器方法:

using System;
using Foundation;
using Intents;

namespace MonkeyCall
{
    public static class StartCallRequest
    {
        public static string URLScheme {
            get { return "monkeycall"; }
        }

        public static string ActivityType {
            get { return INIntentIdentifier.StartAudioCall.GetConstant ().ToString (); }
        }

        public static string CallHandleFromURL (NSUrl url)
        {
            // Is this a MonkeyCall handle?
            if (url.Scheme == URLScheme) {
                // Yes, return host
                return url.Host;
            } else {
                // Not handled
                return null;
            }
        }

        public static string CallHandleFromActivity (NSUserActivity activity)
        {
            // Is this a start call activity?
            if (activity.ActivityType == ActivityType) {
                // Yes, trap any errors
                try {
                    // Get first contact
                    var interaction = activity.GetInteraction ();
                    var startAudioCallIntent = interaction.Intent as INStartAudioCallIntent;
                    var contact = startAudioCallIntent.Contacts [0];

                    // Get the person handle
                    return contact.PersonHandle.Value;
                } catch {
                    // Error, report null
                    return null;
                }
            } else {
                // Not handled
                return null;
            }
        }
    }
}

AppDelegate 中使用 CallHandleFromURLCallHandleFromActivity 类来获取传出呼叫中被叫方的联系人句柄。 有关详细信息,请参阅下面的处理传出呼叫部分。

ActiveCallManager 类

ActiveCallManager 类处理 MonkeyCall 应用中所有打开的呼叫。

using System;
using System.Collections.Generic;
using Foundation;
using CallKit;

namespace MonkeyCall
{
    public class ActiveCallManager
    {
        #region Private Variables
        private CXCallController CallController = new CXCallController ();
        #endregion

        #region Computed Properties
        public List<ActiveCall> Calls { get; set; }
        #endregion

        #region Constructors
        public ActiveCallManager ()
        {
            // Initialize
            this.Calls = new List<ActiveCall> ();
        }
        #endregion

        #region Private Methods
        private void SendTransactionRequest (CXTransaction transaction)
        {
            // Send request to call controller
            CallController.RequestTransaction (transaction, (error) => {
                // Was there an error?
                if (error == null) {
                    // No, report success
                    Console.WriteLine ("Transaction request sent successfully.");
                } else {
                    // Yes, report error
                    Console.WriteLine ("Error requesting transaction: {0}", error);
                }
            });
        }
        #endregion

        #region Public Methods
        public ActiveCall FindCall (NSUuid uuid)
        {
            // Scan for requested call
            foreach (ActiveCall call in Calls) {
                if (call.UUID.Equals(uuid)) return call;
            }

            // Not found
            return null;
        }

        public void StartCall (string contact)
        {
            // Build call action
            var handle = new CXHandle (CXHandleType.Generic, contact);
            var startCallAction = new CXStartCallAction (new NSUuid (), handle);

            // Create transaction
            var transaction = new CXTransaction (startCallAction);

            // Inform system of call request
            SendTransactionRequest (transaction);
        }

        public void EndCall (ActiveCall call)
        {
            // Build action
            var endCallAction = new CXEndCallAction (call.UUID);

            // Create transaction
            var transaction = new CXTransaction (endCallAction);

            // Inform system of call request
            SendTransactionRequest (transaction);
        }

        public void PlaceCallOnHold (ActiveCall call)
        {
            // Build action
            var holdCallAction = new CXSetHeldCallAction (call.UUID, true);

            // Create transaction
            var transaction = new CXTransaction (holdCallAction);

            // Inform system of call request
            SendTransactionRequest (transaction);
        }

        public void RemoveCallFromOnHold (ActiveCall call)
        {
            // Build action
            var holdCallAction = new CXSetHeldCallAction (call.UUID, false);

            // Create transaction
            var transaction = new CXTransaction (holdCallAction);

            // Inform system of call request
            SendTransactionRequest (transaction);
        }
        #endregion
    }
}

同样,由于这只是一种模拟,因此 ActiveCallManager 仅维护 ActiveCall 对象的集合,并具有用于通过其 UUID 属性查找给定呼叫的例程。 它还包括用于发起、结束和更改传出呼叫的保持状态的方法。 有关详细信息,请参阅下面的处理传出呼叫部分。

ProviderDelegate 类

如上所述,CXProvider 在应用和系统之间提供双向通信,以传达带外通知。 开发人员需要提供自定义 CXProviderDelegate 并将其附加到 CXProvider,以便应用处理带外 CallKit 事件。 MonkeyCall 使用以下 CXProviderDelegate

using System;
using Foundation;
using CallKit;
using UIKit;

namespace MonkeyCall
{
    public class ProviderDelegate : CXProviderDelegate
    {
        #region Computed Properties
        public ActiveCallManager CallManager { get; set;}
        public CXProviderConfiguration Configuration { get; set; }
        public CXProvider Provider { get; set; }
        #endregion

        #region Constructors
        public ProviderDelegate (ActiveCallManager callManager)
        {
            // Save connection to call manager
            CallManager = callManager;

            // Define handle types
            var handleTypes = new [] { (NSNumber)(int)CXHandleType.PhoneNumber };

            // Get Image Template
            var templateImage = UIImage.FromFile ("telephone_receiver.png");

            // Setup the initial configurations
            Configuration = new CXProviderConfiguration ("MonkeyCall") {
                MaximumCallsPerCallGroup = 1,
                SupportedHandleTypes = new NSSet<NSNumber> (handleTypes),
                IconTemplateImageData = templateImage.AsPNG(),
                RingtoneSound = "musicloop01.wav"
            };

            // Create a new provider
            Provider = new CXProvider (Configuration);

            // Attach this delegate
            Provider.SetDelegate (this, null);

        }
        #endregion

        #region Override Methods
        public override void DidReset (CXProvider provider)
        {
            // Remove all calls
            CallManager.Calls.Clear ();
        }

        public override void PerformStartCallAction (CXProvider provider, CXStartCallAction action)
        {
            // Create new call record
            var activeCall = new ActiveCall (action.CallUuid, action.CallHandle.Value, true);

            // Monitor state changes
            activeCall.StartingConnectionChanged += (call) => {
                if (call.isConnecting) {
                    // Inform system that the call is starting
                    Provider.ReportConnectingOutgoingCall (call.UUID, call.StartedConnectingOn.ToNSDate());
                }
            };

            activeCall.ConnectedChanged += (call) => {
                if (call.isConnected) {
                    // Inform system that the call has connected
                    provider.ReportConnectedOutgoingCall (call.UUID, call.ConnectedOn.ToNSDate ());
                }
            };

            // Start call
            activeCall.StartCall ((successful) => {
                // Was the call able to be started?
                if (successful) {
                    // Yes, inform the system
                    action.Fulfill ();

                    // Add call to manager
                    CallManager.Calls.Add (activeCall);
                } else {
                    // No, inform system
                    action.Fail ();
                }
            });
        }

        public override void PerformAnswerCallAction (CXProvider provider, CXAnswerCallAction action)
        {
            // Find requested call
            var call = CallManager.FindCall (action.CallUuid);

            // Found?
            if (call == null) {
                // No, inform system and exit
                action.Fail ();
                return;
            }

            // Attempt to answer call
            call.AnswerCall ((successful) => {
                // Was the call successfully answered?
                if (successful) {
                    // Yes, inform system
                    action.Fulfill ();
                } else {
                    // No, inform system
                    action.Fail ();
                }
            });
        }

        public override void PerformEndCallAction (CXProvider provider, CXEndCallAction action)
        {
            // Find requested call
            var call = CallManager.FindCall (action.CallUuid);

            // Found?
            if (call == null) {
                // No, inform system and exit
                action.Fail ();
                return;
            }

            // Attempt to answer call
            call.EndCall ((successful) => {
                // Was the call successfully answered?
                if (successful) {
                    // Remove call from manager's queue
                    CallManager.Calls.Remove (call);

                    // Yes, inform system
                    action.Fulfill ();
                } else {
                    // No, inform system
                    action.Fail ();
                }
            });
        }

        public override void PerformSetHeldCallAction (CXProvider provider, CXSetHeldCallAction action)
        {
            // Find requested call
            var call = CallManager.FindCall (action.CallUuid);

            // Found?
            if (call == null) {
                // No, inform system and exit
                action.Fail ();
                return;
            }

            // Update hold status
            call.isOnHold = action.OnHold;

            // Inform system of success
            action.Fulfill ();
        }

        public override void TimedOutPerformingAction (CXProvider provider, CXAction action)
        {
            // Inform user that the action has timed out
        }

        public override void DidActivateAudioSession (CXProvider provider, AVFoundation.AVAudioSession audioSession)
        {
            // Start the calls audio session here
        }

        public override void DidDeactivateAudioSession (CXProvider provider, AVFoundation.AVAudioSession audioSession)
        {
            // End the calls audio session and restart any non-call
            // related audio
        }
        #endregion

        #region Public Methods
        public void ReportIncomingCall (NSUuid uuid, string handle)
        {
            // Create update to describe the incoming call and caller
            var update = new CXCallUpdate ();
            update.RemoteHandle = new CXHandle (CXHandleType.Generic, handle);

            // Report incoming call to system
            Provider.ReportNewIncomingCall (uuid, update, (error) => {
                // Was the call accepted
                if (error == null) {
                    // Yes, report to call manager
                    CallManager.Calls.Add (new ActiveCall (uuid, handle, false));
                } else {
                    // Report error to user here
                    Console.WriteLine ("Error: {0}", error);
                }
            });
        }
        #endregion
    }
}

创建此委托的实例时,将为此实例传递 ActiveCallManager,用于处理任何呼叫活动。 接下来,它定义 CXProvider 要响应的句柄类型 (CXHandleType):

// Define handle types
var handleTypes = new [] { (NSNumber)(int)CXHandleType.PhoneNumber };

它获取模板图像,该图像将在呼叫过程中应用于应用的图标:

// Get Image Template
var templateImage = UIImage.FromFile ("telephone_receiver.png");

这些值捆绑在用于配置 CXProviderCXProviderConfiguration 中:

// Setup the initial configurations
Configuration = new CXProviderConfiguration ("MonkeyCall") {
    MaximumCallsPerCallGroup = 1,
    SupportedHandleTypes = new NSSet<NSNumber> (handleTypes),
    IconTemplateImageData = templateImage.AsPNG(),
    RingtoneSound = "musicloop01.wav"
};

然后,委托使用这些配置创建新的 CXProvider 并将其自身附加到该对象:

// Create a new provider
Provider = new CXProvider (Configuration);

// Attach this delegate
Provider.SetDelegate (this, null);

使用 CallKit 时,应用将不再创建和处理自己的音频会话,而是需要配置和使用系统为其创建和处理的音频会话。

如果这是一个真实应用,则 DidActivateAudioSession 方法将用于通过系统提供的预配置 AVAudioSession 来发起呼叫:

public override void DidActivateAudioSession (CXProvider provider, AVFoundation.AVAudioSession audioSession)
{
    // Start the call's audio session here...
}

它还会使用 DidDeactivateAudioSession 方法来完成并释放其与系统提供的音频会话的连接:

public override void DidDeactivateAudioSession (CXProvider provider, AVFoundation.AVAudioSession audioSession)
{
    // End the calls audio session and restart any non-call
    // releated audio
}

后续部分将详细介绍代码的其余部分。

AppDelegate 类

MonkeyCall 使用 AppDelegate 来保存将在整个应用中使用的 ActiveCallManagerCXProviderDelegate 实例:

using Foundation;
using UIKit;
using Intents;
using System;

namespace MonkeyCall
{
    [Register ("AppDelegate")]
    public class AppDelegate : UIApplicationDelegate
    {
        #region Constructors
        public override UIWindow Window { get; set; }
        public ActiveCallManager CallManager { get; set; }
        public ProviderDelegate CallProviderDelegate { get; set; }
        #endregion

        #region Override Methods
        public override bool FinishedLaunching (UIApplication application, NSDictionary launchOptions)
        {
            // Initialize the call handlers
            CallManager = new ActiveCallManager ();
            CallProviderDelegate = new ProviderDelegate (CallManager);

            return true;
        }

        public override bool OpenUrl (UIApplication app, NSUrl url, NSDictionary options)
        {
            // Get handle from url
            var handle = StartCallRequest.CallHandleFromURL (url);

            // Found?
            if (handle == null) {
                // No, report to system
                Console.WriteLine ("Unable to get call handle from URL: {0}", url);
                return false;
            } else {
                // Yes, start call and inform system
                CallManager.StartCall (handle);
                return true;
            }
        }

        public override bool ContinueUserActivity (UIApplication application, NSUserActivity userActivity, UIApplicationRestorationHandler completionHandler)
        {
            var handle = StartCallRequest.CallHandleFromActivity (userActivity);

            // Found?
            if (handle == null) {
                // No, report to system
                Console.WriteLine ("Unable to get call handle from User Activity: {0}", userActivity);
                return false;
            } else {
                // Yes, start call and inform system
                CallManager.StartCall (handle);
                return true;
            }
        }

        ...
        #endregion
    }
}

当应用处理传出呼叫时,将使用 OpenUrlContinueUserActivity 重写方法。 有关详细信息,请参阅下面的处理传出呼叫部分。

处理传入呼叫

在典型的传入呼叫工作流中,VOIP 传入呼叫可能会经历多种状态和流程,例如:

  • 告知用户(和系统)有传入呼叫存在。
  • 当用户想要接听呼叫并初始化与其他用户的呼叫时接收通知。
  • 当用户想要结束当前呼叫时告知系统和通信网络。

以下部分将详细介绍应用如何使用 CallKit 来处理传入呼叫工作流,其中再次使用 MonkeyCall VOIP 应用作为示例。

告知用户有传入呼叫

当远程用户开始与本地用户进行 VOIP 对话时,会发生以下情况:

A remote user has started a VOIP conversation

  1. 应用从其通信网络收到有 VOIP 传入呼叫的通知。
  2. 应用使用 CXProvider 向系统发送 CXCallUpdate,告知其有呼叫。
  3. 系统将呼叫发布到系统 UI、系统服务和任何其他使用 CallKit 的 VOIP 应用。

例如,在 CXProviderDelegate 中:

public void ReportIncomingCall (NSUuid uuid, string handle)
{
    // Create update to describe the incoming call and caller
    var update = new CXCallUpdate ();
    update.RemoteHandle = new CXHandle (CXHandleType.Generic, handle);

    // Report incoming call to system
    Provider.ReportNewIncomingCall (uuid, update, (error) => {
        // Was the call accepted
        if (error == null) {
            // Yes, report to call manager
            CallManager.Calls.Add (new ActiveCall (uuid, handle, false));
        } else {
            // Report error to user here
            Console.WriteLine ("Error: {0}", error);
        }
    });
}

此代码创建新的 CXCallUpdate 实例并为其附加一个句柄以标识呼叫方。 接下来,它使用 CXProvider 类的 ReportNewIncomingCall 方法来告知系统有呼叫。 如果成功,则该呼叫将添加到应用的活动呼叫集合中,如果失败,则需要向用户报告错误。

用户接听传入呼叫

如果用户想要接听 VOIP 传入呼叫,则会发生以下情况:

The user answers the incoming VOIP call

  1. 系统 UI 向系统告知用户想要接听 VOIP 呼叫。
  2. 系统将 CXAnswerCallAction 发送到应用的 CXProvider,告知其接听意向。
  3. 应用向其通信网络告知用户正在接听呼叫,并且 VOIP 呼叫照常进行。

例如,在 CXProviderDelegate 中:

public override void PerformAnswerCallAction (CXProvider provider, CXAnswerCallAction action)
{
    // Find requested call
    var call = CallManager.FindCall (action.CallUuid);

    // Found?
    if (call == null) {
        // No, inform system and exit
        action.Fail ();
        return;
    }

    // Attempt to answer call
    call.AnswerCall ((successful) => {
        // Was the call successfully answered?
        if (successful) {
            // Yes, inform system
            action.Fulfill ();
        } else {
            // No, inform system
            action.Fail ();
        }
    });
}

此代码首先在其活动呼叫列表中搜索给定的呼叫。 如果找不到呼叫,则会告知系统,该方法会退出。 如果找到呼叫,则调用 ActiveCall 类的 AnswerCall 方法来发起呼叫,并向系统提供成功或失败信息。

用户结束传入呼叫

如果用户希望从应用的 UI 内部终止呼叫,则会发生以下情况:

The user terminates the call from within the app's UI

  1. 应用创建捆绑到 CXTransaction 中的 CXEndCallAction,CXTransaction 发送到系统以向其告知呼叫即将结束。
  2. 系统验证结束呼叫意向并通过 CXProviderCXEndCallAction 发送回应用。
  3. 然后,应用向其通信网络告知呼叫即将结束。

例如,在 CXProviderDelegate 中:

public override void PerformEndCallAction (CXProvider provider, CXEndCallAction action)
{
    // Find requested call
    var call = CallManager.FindCall (action.CallUuid);

    // Found?
    if (call == null) {
        // No, inform system and exit
        action.Fail ();
        return;
    }

    // Attempt to answer call
    call.EndCall ((successful) => {
        // Was the call successfully answered?
        if (successful) {
            // Remove call from manager's queue
            CallManager.Calls.Remove (call);

            // Yes, inform system
            action.Fulfill ();
        } else {
            // No, inform system
            action.Fail ();
        }
    });
}

此代码首先在其活动呼叫列表中搜索给定的呼叫。 如果找不到呼叫,则会告知系统,该方法会退出。 如果找到呼叫,则调用 ActiveCall 类的 EndCall 方法来结束呼叫,并向系统提供成功或失败信息。 如果成功,则从活动呼叫集合中删除该呼叫。

管理多个呼叫

大多数 VOIP 应用可以同时处理多个呼叫。 例如,如果当前有正在进行的 VOIP 呼叫,并且应用收到有新传入呼叫的通知,则用户可以暂停或挂断第一个呼叫以接听第二个呼叫。

在上述情况下,系统将向应用发送一个 CXTransaction,其中包含多个操作的列表(例如 CXEndCallActionCXAnswerCallAction)。 所有这些操作都需要单独完成,以便系统可以相应地更新 UI。

处理传出呼叫

如果用户点击“最近呼叫”列表(在电话应用中)中的某个条目(例如,来自属于该应用的呼叫),系统将向其发送“发起呼叫意向”

Receiving a Start Call Intent

  1. 应用将根据从系统收到的发起呼叫意向创建一个“发起呼叫操作”
  2. 应用将使用 CXCallController 向系统请求发起呼叫操作。
  3. 如果系统接受该操作,它将通过 XCProvider 委托返回给应用。
  4. 应用通过其通信网络发起传出呼叫。

有关意向的详细信息,请参阅意向和意向 UI 扩展文档。

传出呼叫生命周期

使用 CallKit 和传出呼叫时,应用需要向系统告知以下生命周期事件:

  1. 正在发起 - 告知系统即将发起传出呼叫
  2. 已发起 - 告知系统已发起传出呼叫
  3. 正在连接 - 告知系统传出呼叫正在连接
  4. 已连接 - 告知传出呼叫已连接,双方现在可以呼叫

例如,以下代码将发起传出呼叫:

private CXCallController CallController = new CXCallController ();
...

private void SendTransactionRequest (CXTransaction transaction)
{
    // Send request to call controller
    CallController.RequestTransaction (transaction, (error) => {
        // Was there an error?
        if (error == null) {
            // No, report success
            Console.WriteLine ("Transaction request sent successfully.");
        } else {
            // Yes, report error
            Console.WriteLine ("Error requesting transaction: {0}", error);
        }
    });
}

public void StartCall (string contact)
{
    // Build call action
    var handle = new CXHandle (CXHandleType.Generic, contact);
    var startCallAction = new CXStartCallAction (new NSUuid (), handle);

    // Create transaction
    var transaction = new CXTransaction (startCallAction);

    // Inform system of call request
    SendTransactionRequest (transaction);
}

它创建一个 CXHandle 并使用它来配置一个捆绑到 CXTransaction 中的 CXStartCallAction,而该 CXTransaction 将通过 CXCallController 类的 RequestTransaction 方法发送到系统。 通过调用 RequestTransaction 方法,系统可以在新呼叫发起之前暂停任何现有呼叫,无论其源如何(“电话”应用、FaceTime、VOIP 等)。

发起传出 VOIP 呼叫的请求可以来自多个不同的源,例如 Siri、联系人卡片上的条目(在“联系人”应用中)或来自“最近呼叫”列表(在“电话”应用中)。 在这种情况下,将在 NSUserActivity 中向应用发送“发起呼叫意向”,并且 AppDelegate 需要处理它:

public override bool ContinueUserActivity (UIApplication application, NSUserActivity userActivity, UIApplicationRestorationHandler completionHandler)
{
    var handle = StartCallRequest.CallHandleFromActivity (userActivity);

    // Found?
    if (handle == null) {
        // No, report to system
        Console.WriteLine ("Unable to get call handle from User Activity: {0}", userActivity);
        return false;
    } else {
        // Yes, start call and inform system
        CallManager.StartCall (handle);
        return true;
    }
}

此处,帮助器类 StartCallRequestCallHandleFromActivity 方法用于获取被叫方的句柄(请参阅上面的 StartCallRequest 类)。

ProviderDelegate 类PerformStartCallAction 方法用于最终发起实际的传出呼叫并向系统告知其生命周期:

public override void PerformStartCallAction (CXProvider provider, CXStartCallAction action)
{
    // Create new call record
    var activeCall = new ActiveCall (action.CallUuid, action.CallHandle.Value, true);

    // Monitor state changes
    activeCall.StartingConnectionChanged += (call) => {
        if (call.IsConnecting) {
            // Inform system that the call is starting
            Provider.ReportConnectingOutgoingCall (call.UUID, call.StartedConnectingOn.ToNSDate());
        }
    };

    activeCall.ConnectedChanged += (call) => {
        if (call.IsConnected) {
            // Inform system that the call has connected
            Provider.ReportConnectedOutgoingCall (call.UUID, call.ConnectedOn.ToNSDate ());
        }
    };

    // Start call
    activeCall.StartCall ((successful) => {
        // Was the call able to be started?
        if (successful) {
            // Yes, inform the system
            action.Fulfill ();

            // Add call to manager
            CallManager.Calls.Add (activeCall);
        } else {
            // No, inform system
            action.Fail ();
        }
    });
}

它创建 ActiveCall 类的实例(用于保存有关正在进行的呼叫的信息)并填充被叫方。 StartingConnectionChangedConnectedChanged 事件用于监视和报告传出呼叫生命周期。 呼叫已发起,并且已向系统告知操作已完成。

结束传出呼叫

当用户完成传出呼叫并希望结束该呼叫时,可以使用以下代码:

private CXCallController CallController = new CXCallController ();
...

private void SendTransactionRequest (CXTransaction transaction)
{
    // Send request to call controller
    CallController.RequestTransaction (transaction, (error) => {
        // Was there an error?
        if (error == null) {
            // No, report success
            Console.WriteLine ("Transaction request sent successfully.");
        } else {
            // Yes, report error
            Console.WriteLine ("Error requesting transaction: {0}", error);
        }
    });
}

public void EndCall (ActiveCall call)
{
    // Build action
    var endCallAction = new CXEndCallAction (call.UUID);

    // Create transaction
    var transaction = new CXTransaction (endCallAction);

    // Inform system of call request
    SendTransactionRequest (transaction);
}

如果使用要结束的呼叫的 UUID 创建了一个 CXEndCallAction,请将其捆绑在通过 CXCallController 类的 RequestTransaction 方法发送到系统的 CXTransaction 中。

其他 CallKit 详细信息

本部分将介绍开发人员在使用 CallKit 时需要考虑的其他一些详细信息,例如:

  • 提供程序配置
  • 操作错误
  • 系统限制
  • VOIP 音频

提供程序配置

提供程序配置允许 iOS 10 VOIP 应用在使用 CallKit 时自定义用户体验(在本机来电 UI 中)。

应用可以进行以下类型的自定义:

  • 显示本地化名称。
  • 启用视频呼叫支持。
  • 通过呈现自己的模板图像图标来自定义来电 UI 上的按钮。 用户与自定义按钮的交互将直接发送到应用进行处理。

操作错误

使用 CallKit 的 iOS 10 VOIP 应用需要正常处理失败的操作,并始终向用户告知操作状态。

考虑以下示例:

  1. 应用已收到发起呼叫操作,并且已开始执行使用其通信网络初始化新 VOIP 呼叫的过程。
  2. 由于网络通信功能受限或无网络通信功能,此连接会失败。
  3. 应用必须将“失败”消息发送回发起呼叫操作 (Action.Fail()),以向系统告知失败状态
  4. 这样,系统就可以向用户告知呼叫状态。 例如,显示呼叫失败 UI。

此外,iOS 10 VOIP 应用需要响应在给定时间内无法处理预期操作时可能发生的超时错误。 CallKit 提供的每个操作类型都有一个关联的最大超时值。 这些超时值可以确保以快速响应的方式处理用户请求的任何 CallKit 操作,从而保持操作系统的流畅性和响应能力。

还应重写提供程序委托 (CXProviderDelegate) 上的多个方法,以正常处理这种超时情况。

系统限制

根据运行 iOS 10 VOIP 应用的 iOS 设备的当前状态,可能会强制实施某些系统限制。

例如,对于以下情况,系统可以限制传入的 VOIP 呼叫:

  1. 呼叫方列在用户的“被阻止呼叫方”列表中。
  2. 用户的 iOS 设备处于“请勿打扰”模式。

如果 VOIP 呼叫因以下任一情况而受到限制,请使用以下代码来处理:

public class ProviderDelegate : CXProviderDelegate
{
...

    public void ReportIncomingCall (NSUuid uuid, string handle)
    {
        // Create update to describe the incoming call and caller
        var update = new CXCallUpdate ();
        update.RemoteHandle = new CXHandle (CXHandleType.Generic, handle);

        // Report incoming call to system
        Provider.ReportNewIncomingCall (uuid, update, (error) => {
            // Was the call accepted
            if (error == null) {
                // Yes, report to call manager
                CallManager.Calls.Add (new ActiveCall (uuid, handle, false));
            } else {
                // Report error to user here
                if (error.Code == (int)CXErrorCodeIncomingCallError.CallUuidAlreadyExists) {
                    // Handle duplicate call ID
                } else if (error.Code == (int)CXErrorCodeIncomingCallError.FilteredByBlockList) {
                    // Handle call from blocked user
                } else if (error.Code == (int)CXErrorCodeIncomingCallError.FilteredByDoNotDisturb) {
                    // Handle call while in do-not-disturb mode
                } else {
                    // Handle unknown error
                }
            }
        });
    }

}

VOIP 音频

在处理实时 VOIP 呼叫期间 iOS 10 VOIP 应用所需的音频资源方面,CallKit 提供了多项优势。 最大的优势之一是应用的音频会话在 iOS 10 中运行时具有更高的优先级。 这与内置电话和 FaceTime 应用的优先级相同,并且此增强的优先级将防止其他正在运行的应用中断 VOIP 应用的音频会话。

此外,CallKit 还可以访问其他音频路由提示,这些提示可以增强性能,并在实时呼叫期间根据用户首选项和设备状态智能地将 VOIP 音频路由到特定输出设备。 例如,根据附加的设备(例如蓝牙耳机)、实时 CarPlay 连接或辅助功能设置进行路由。

在使用 CallKit 的典型 VOIP 呼叫的生命周期中,应用需要配置 CallKit 提供的音频流。 请看以下示例:

The Start Call Action Sequence

  1. 应用接收发起呼叫操作以接听传入呼叫。
  2. 在应用完成此操作之前,它会提供其 AVAudioSession 所需的配置。
  3. 应用向系统告知操作已完成。
  4. 在呼叫连接之前,CallKit 将提供与应用请求的配置相匹配的高优先级 AVAudioSession。 应用将通过其 CXProviderDelegateDidActivateAudioSession 方法收到通知。

使用呼叫目录扩展

使用 CallKit 时,呼叫目录扩展提供了一种方式,用于为 iOS 设备上的“联系人”应用中的联系人添加被阻止电话号码,以及识别特定于给定 VOIP 应用的号码

实现呼叫目录扩展

若要在 Xamarin.iOS 应用中实现呼叫目录扩展,请执行以下操作:

  1. 在 Visual Studio for Mac 中打开应用的解决方案。

  2. 右键单击“解决方案资源管理器”中的“解决方案名称”,然后选择“添加”>“添加新项目”

  3. 选择“iOS”>“扩展”>“呼叫目录扩展”,然后单击“下一步”按钮

    Creating a new Call Directory Extension

  4. 输入扩展的名称,然后单击“下一步”按钮

    Entering a name for the extension

  5. 根据需要调整“项目名称”和/或“解决方案名称”,然后单击“创建”按钮

    Creating the project

这会将 CallDirectoryHandler.cs 类添加到项目中,如下所示:

using System;

using Foundation;
using CallKit;

namespace MonkeyCallDirExtension
{
    [Register ("CallDirectoryHandler")]
    public class CallDirectoryHandler : CXCallDirectoryProvider, ICXCallDirectoryExtensionContextDelegate
    {
        #region Constructors
        protected CallDirectoryHandler (IntPtr handle) : base (handle)
        {
            // Note: this .ctor should not contain any initialization logic.
        }
        #endregion

        #region Override Methods
        public override void BeginRequest (CXCallDirectoryExtensionContext context)
        {
            context.Delegate = this;

            if (!AddBlockingPhoneNumbers (context)) {
                Console.WriteLine ("Unable to add blocking phone numbers");
                var error = new NSError (new NSString ("CallDirectoryHandler"), 1, null);
                context.CancelRequest (error);
                return;
            }

            if (!AddIdentificationPhoneNumbers (context)) {
                Console.WriteLine ("Unable to add identification phone numbers");
                var error = new NSError (new NSString ("CallDirectoryHandler"), 2, null);
                context.CancelRequest (error);
                return;
            }

            context.CompleteRequest (null);
        }
        #endregion

        #region Private Methods
        private bool AddBlockingPhoneNumbers (CXCallDirectoryExtensionContext context)
        {
            // Retrieve phone numbers to block from data store. For optimal performance and memory usage when there are many phone numbers,
            // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded.
            //
            // Numbers must be provided in numerically ascending order.

            long [] phoneNumbers = { 14085555555, 18005555555 };

            foreach (var phoneNumber in phoneNumbers)
                context.AddBlockingEntry (phoneNumber);

            return true;
        }

        private bool AddIdentificationPhoneNumbers (CXCallDirectoryExtensionContext context)
        {
            // Retrieve phone numbers to identify and their identification labels from data store. For optimal performance and memory usage when there are many phone numbers,
            // consider only loading a subset of numbers at a given time and using autorelease pool(s) to release objects allocated during each batch of numbers which are loaded.
            //
            // Numbers must be provided in numerically ascending order.

            long [] phoneNumbers = { 18775555555, 18885555555 };
            string [] labels = { "Telemarketer", "Local business" };

            for (var i = 0; i < phoneNumbers.Length; i++) {
                long phoneNumber = phoneNumbers [i];
                string label = labels [i];
                context.AddIdentificationEntry (phoneNumber, label);
            }

            return true;
        }
        #endregion

        #region Public Methods
        public void RequestFailed (CXCallDirectoryExtensionContext extensionContext, NSError error)
        {
            // An error occurred while adding blocking or identification entries, check the NSError for details.
            // For Call Directory error codes, see the CXErrorCodeCallDirectoryManagerError enum.
            //
            // This may be used to store the error details in a location accessible by the extension's containing app, so that the
            // app may be notified about errors which occurred while loading data even if the request to load data was initiated by
            // the user in Settings instead of via the app itself.
        }
        #endregion
    }
}

需要修改呼叫目录处理程序中的 BeginRequest 方法以提供所需的功能。 在上面的示例中,它尝试在 VOIP 应用的联系人数据库中设置被阻止和可用号码的列表。 如果任一请求因任何原因而失败,则创建一个 NSError 来描述失败状态并将其传递给 CXCallDirectoryExtensionContext 类的 CancelRequest 方法。

若要设置被阻止号码,请使用 CXCallDirectoryExtensionContext 类的 AddBlockingEntry 方法。 提供给该方法的号码必须按数字升序排列。 为了在有许多电话号码时获得最佳性能和内存使用效率,请考虑在给定的时间仅加载一部分号码,并使用自动释放池来释放在加载每批号码期间分配的对象。

若要向“联系人”应用告知 VOIP 应用已知的联系人号码,请使用 CXCallDirectoryExtensionContext 类的 AddIdentificationEntry 方法并提供号码和标识标签。 同样,提供给该方法的号码必须按数字升序排列。 为了在有许多电话号码时获得最佳性能和内存使用效率,请考虑在给定的时间仅加载一部分号码,并使用自动释放池来释放在加载每批号码期间分配的对象。

总结

本文介绍了 Apple 在 iOS 10 中发布的新 CallKit API 以及如何在 Xamarin.iOS VOIP 应用中实现它。 其中介绍了 CallKit 如何允许应用集成到 iOS 系统中,如何提供与内置应用(例如电话)等同的功能,以及如何通过 Siri 交互以及通过“联系人”应用,提高应用在整个 iOS 的锁屏界面和主屏幕等位置的可见性。