見出し画像

CallKit を使った通話アプリのサンプルコード

より / YORIFUJI

こんにちは、@yorifujiです。最近はCallKitを使ったアプリを開発しています。

CallKit は iOS 10 以降で利用できるフレームワークです。CallKit を使うと標準の電話アプリのような着信・通話画面を表示することができます。着信時は左のような画面で、通話中は右のような画面です。

有名なアプリではLINEやDiscordなどもCallKitを利用しているようです。

SkyWay UG Tokyo #4

8月21日にSkyWay UGに参加しました。

そこで、CallKitとSkyWayを使った通話アプリの作り方の紹介を行いました。

このnoteは発表資料にコードの説明を追加したものです。

サンプルコード

以前は Apple 公式のサンプルコードが公開されていたのですが、何かの事情で公開をやめてしまったようです。

CallKit は通話アプリに利用されることが多いはずですが、実際に通話ができるサンプルコードはあまり見かけません。SkyWay は iOS 用のSDK も提供していて、わずかなコードで通話機能が実装できるので、CallKit と組み合わせたサンプルコード作成しました。

README.md に記載の通りに CocoaPods と SkyWay API Key をセットアップします。ビルドして2台の端末でアプリを立ち上げます。Call ボタンを押して PeerID を選択すると相手側端末に着信画面が表示されます。応答すると通話が始まります。

注意点として、CallKitはシミュレータ上では動作しません。

CallKit の主要な処理は CallCenter.swift に実装しています。

発信と着信の流れ

サンプルコードでは、相手に電話をかける部分を WebRTC(SkyWay) のデータチャンネルを使って行なっています。Call ボタンを押して選択した Peer に DataConnection を使って接続します。相手側は DataConnection の接続イベントが発生すると着信画面を表示します。CallKit の Native UI で応答すると、発信元のPeerに対してMediaStreamをセットしてcallします。発信元がanswerすると通話が成立します。

CallKit の初期化

private let controller = CXCallController()
private let provider: CXProvider

init(supportsVideo: Bool) {
    let providerConfiguration = CXProviderConfiguration(localizedName: "SkyWay(CallKit)")
    providerConfiguration.supportsVideo = supportsVideo
    provider = CXProvider(configuration: providerConfiguration)
}

func setup(_ delegate: CXProviderDelegate) {
    provider.setDelegate(delegate, queue: nil)
}

CallKit の重要なクラスのインスタンスを生成しています。CXCallController は通話の管理インタフェースを提供しているクラスです。

CXProviderConfiguration は Native UI の挙動を制御するためのプロパティがあります。

・着信時に表示されるアプリの名称、上記のコード例では "SkyWay(CallKit)"

・着信音の設定

・同時に応答できる通話の上限

・通話中に招待できる人数(グループ通話)の上限

例えば、同時に応答できる通話の上限を1にセットすると、通話中に別の着信があると、元の通話を切断して応答するか、着信を拒否するかの選択肢が出てきます。2以上にすると、それに加えて、保留の選択肢が増えます。 

provider に対して発信や着信の処理を呼び出すと CXProviderDelegate のメソッドが呼ばれます。

着信処理

着信画面を表示しているのは以下のコードです。

func IncomingCall() {
    uuid = UUID()
    let update = CXCallUpdate()
    update.remoteHandle = CXHandle(type: .generic, value: "太郎さん")
    provider.reportNewIncomingCall(with: uuid, update: update) { error in
        if let error = error {
            print("reportNewIncomingCall error: \(error.localizedDescription)")
        }
    }
}

CXProvider クラスの reportNewIncomingCall が着信画面を表示するAPIです。CXHandle のインスタンスに発信者の情報をセットします。value に電話番号やメールアドレスなどを指定すると、標準の連絡先アプリに登録されている情報を元に発信者名の表示や、画像が登録されていれば着信画面に表示されます。reportNewIncomingCall の先頭の引数に通話毎にユニークな uuid を渡しています。

発信処理

発信する側では以下の関数で CXTransaction に CXStartCallAction のインスタンスを登録してリクエストします。

func StartCall() {
    uuid = UUID()
    let handle = CXHandle(type: .generic, value: "花子さん")
    let startCallAction = CXStartCallAction(call: uuid, handle: handle)
    let transaction = CXTransaction(action: startCallAction)
    controller.request(transaction) { error in
        if let error = error {
            print("CXStartCallAction error: \(error.localizedDescription)")
        }
    }
}

iOSでは標準の電話アプリの通話も含めて CXCallController で管理されるため、インスタンスを経由して発信や終話の状態をOSに通知する必要があります。OSに通知すると通話が行われていると判断され、ステータスバーが緑色になります。CXStartCallAction の先頭の引数で uuid を指定しています。

通話の終了

func EndCall() {
    let action = CXEndCallAction(call: uuid)
    let transaction = CXTransaction(action: action)
    controller.request(transaction) { error in
        if let error = error {
            print("CXEndCallAction error: \(error.localizedDescription)")
        }
    }
}

通話を終了した時は、終了させる通話のuuidを指定した CXEndCallAction のインスタンスを生成してconrollerにrequestします。

CXProviderDelegate

発信や着信の応答、通話終了などのタイミングで CXProviderDelegate のメソッドが呼び出されます。サンプルでは必要最小限の関数を実装しています。

extension MediaConnectionViewController: CXProviderDelegate {
   func providerDidReset(_ provider: CXProvider) {
   }
   func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
       callCenter.Connecting()
       action.fulfill()
   }
   func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
       if let peer = self.dataConnection?.peer {
           self.call(targetPeerId: peer)
       }
       action.fulfill()
   }
   func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
       self.dataConnection?.close()
       self.mediaConnection?.close()
       action.fulfill()
   }
}

画面ロックと非ロック時の挙動について

サンプルコードでは30秒だけバッグクラウンドで動作するようにしています。端末をスリープもしくはロックした状態で着信に応答するとCallKitの挙動が微妙に異なりますので興味のある方は試してみてください。

その他、CallKit の基本的な使い方は下記の公式ドキュメントに記載があるので参考になるかとお思います。

PushKit を使った発着信

サンプルコードでは2台の端末を起動してから接続していましたが、本物の通話アプリで「事前にアプリを立ち上げておく」なんてことはやらないと思います。

PushKit を利用すると端末がスリープしていたりロックされている状態でプッシュ通知を受信すると、アプリがバックグラウンド起動します。

そのタイミングで reportNewIncomingCall を呼び出して着信画面を表示するのがセオリーのようです。以下のコードは通知を受け取ったら着信画面を表示する処理の例です。

extension AppDelegate: PKPushRegistryDelegate {
   func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
       let pkid = pushCredentials.token.map { String(format: "%02.2hhx", $0) }.joined()
       print("your device token:\(pkid)")
   }
   func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
       self.receiveIncomingPush(payload: payload, for: type)
   }
   func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) {
       self.receiveIncomingPush(payload: payload, for: type)
   }
   func receiveIncomingPush(payload: PKPushPayload, for type: PKPushType) {
       guard type == .voIP else {
           return
       }
       // payload.dictionaryPayload may have caller info
       // call reportNewIncomingCall() here.
   }
}

PushkKit の通知を送るためには標準的なプッシュ通知と同じように、APNs サーバに対してリクエストを投げる必要があります。これについてはいくつかやり方がありますのでここでは割愛します。

実装でつまずくところ

以下はCallKitの実装時につまずきやすいと感じたことです。

Background Mode で voip を指定する

CallKit の Native UI を利用するためにはビルドターゲットの設定で Background Mode を有効にして Voice over IP にチェックを入れる必要があります。これを忘れているとAPIを呼び出しても Native UI が表示されません。

ビデオ通話との相性は微妙

意外なことにCallKit のUIはビデオ通話との相性が良くないです。何故かというと、端末がロックした状態で着信に応答すると、CallKitの通話画面が表示されたままになります。この状態では、アプリ側の画面が表示されないためビデオが表示されません。ユーザーがロックを解除するとアプリの画面に切り替わります。

CallKit を使用したアプリは中国向けに配信できない

CallKit を使ったアプリを申請するときの注意点として、配信先から中国を除外する必要があります。

実際に私がとあるアプリをAppStoreConnectに申請した時に下記のようなリジェクトを食らいました。「中国本土」のチェックを外すと申請が通りました。ただし、レビューコメントを読むと、中国国内でCallKitが無効化されていれば審査は通るような記述があります(未確認です)。

最後に

コードの詳細は詳しく説明できていませんが、あまり実装量も多くないので雰囲気は掴めるかと思います。不明な点があればツイッターなどでご質問いただけるとお答えできるかもしれません。

私もまだまだ理解していない点がありますので積極的に情報共有してノウハウを増やせたらいいなと思います。

この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!
より / YORIFUJI
iOS/Swift/Firebase/機械学習/WebRTCの話題が多いです。最近はzennに記事書いてます。