2017.01.05

iOS 10の新しいVoIP機能を使ってみましょう


IMG_0003

こんにちは。次世代システム研究室 の L.J です。

ある日好きなアイドルから電話がかかってくることを想像したことありませんか。実際そのようなアプリは作れますが、どうしても着信画面が通知センターに通知することしかできずアプリ側から純正の電話のようには表示出来なかったです。どころが、iOS 10から新たにPushKitとCallKitとなる新たなフレームワークが提供されましたてこれらのフレームワークを利用することで、端末がロック中でも電話の着信画面を表示したり、保留の制御、他の電話への切り替えなどがアプリから自由にできるようになります。さらに、電話の履歴にも表示されますし、連絡帳からアプリを選択して電話をかけることもできます。今回はPushKitとCallKitを利用して簡単にVoIPアプリを作ってみようと思います。

(ちなみに、LineやSkypeなどアプリ内でVoIP機能を実装しているアプリもこれらのフレームワークを利用し純正の電話アプリとほぼ同じようなUXをアプリユーザーに提供しています。)

PushKitとCallKitを利用すると既存の電話とどのうような違いがあるかを以下に整理しました。

  • 電話の受信画面が純正の「電話」アプリと同じような画面を利用できる
    • 従来の場合通知センターに表示されるだけだった
  • 携帯がロック中のまま受信できる
  • 通話履歴に表示される
  • 通話履歴もしくは連絡帳から直接アプリを利用して電話をかけられる
  • 音声データやビデオデータをFaceTime並みに扱えられる(より綺麗な音声や動画)
冒頭で述べました通り、今回紹介するフレームワークは「PushKit」と「CallKit」です。

PushKitは従来のリモート通知ではできなかった VoIPの信号(主に着信)を受信するために新たなリモート通知方法を利用する際に使うフレームワークです。そして、CallKitは電話の着信や発信する際にシステム(iOS)とのやり取りを行うために利用するフレームワークです。これらのフレームワークを利用することで具体的に何ができるのかどう使うのかをこれから説明します。ただし、これらの全てを説明することは難しいので、中でも重要なメソッドを中心に説明いたします。

 

PushKit

新しいVoIPを利用するためにはまず新しいリモート通知フレームワークであるPushKitを利用する必要があります。PushKitはレファレンスの説明通りVoIP機能のために新たに追加されたリモート通知設定機能であります。これを利用すると既存よりバッテリー影響が少なくより良いユーザー経験を提供することができます。(とレファレンスの冒頭に書かれています)
使い方は簡単で大体以下の例通りになります。
    let voipRegistry: PKPushRegistry = PKPushRegistry(queue: DispatchQueue.main)

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

        self.voipRegistry.delegate = self
        self.voipRegistry.desiredPushTypes = [.voIP]

        ...
        return true
    }
AppDelegateに大体上のように実装するとプッシュ登録は完了になります。そして、登録完了するとOSからプッシュ用のトークンを発行しアプリ側にDelegateします。以下はトークンを受信する例になります。
    func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, forType type: PKPushType) {
        NSLog("didUpdatePushCredentials: \(credentials.token.hexEncodedString)")
    }
※「hexEncodedString」は「Data」のExtensionとしてカスタム実装したメソッドです。内容は以下のとおりです
    var hexEncodedString: String {
        return map { String(format: "%02hhx", $0) }.joined()
    }
「トークン」データはアプリにプッシュするためのデータですので、プッシュ処理を行うサーバーなどに知らせる必要があります。トークンを利用してどのように通知するかはこちらを参照してください。ちなみに、従来の方式とは違いますので、こちらも新たに実装する必要があります。以下は「curl」を利用しプッシュ通知する例です。(curlがhttp2プロトコルをサポートする必要があります。)
curl -E voip_services.pem\
     --header "apns-topic: 「APPID」.voip"\
     -d "{"UUID": "xxx", "handle": "xxxx", "hasVideo": 0}"\
     --http2 https://api.development.push.apple.com/3/device/「トークン」
  • 「APPID 」のどころはアプリのBundle Identifierを設定します。(Bundle Identifier + 「.voip」になることに注意してください)
  • 「トークン」のどころには上で取得したトークンを設定してください。
実際VoIPアプリに対してプッシュが行われると以下のように受け取れます。
    func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, forType type: PKPushType) {
        NSLog("didReceiveIncomingPushWithPayload: \(payload.dictionaryPayload)")
        guard type == .voIP else {
            return
        }
        // ここにIncoming処理を実装する
    }
「payload」には上でデータとして設定した内容(-d で設定したJSONデータ)が取得できますので、必要なデータをプッシュする側で設定し、アプリ側に渡すことができます。

最後にトークンが無効になった場合は以下のDelegateが呼ばれます。
    func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenForType type: PKPushType) {
        NSLog("didInvalidatePushTokenForType")
    }
このメソッドが呼ばれた場合は現在利用中のトークンを破棄し新たにトークンを取得する必要があります。

CallKit

PushKitからプッシュを受け取った際にVoIP受信画面を表示したり、VoIPで電話をかけるなどVoIPを直接コントロールする機能はCallKitが提供しています。まず受信画面表示から説明します。

VoIP設定

まずは作るVoIPアプリがどのような仕様なのかを設定する必要があり、「CXProvider」オブジェクトを生成する際に設定することができます。以下がその例になります。
    // VoIPアプリ名(アプリ名とは異なり電話履歴上や着信画面などに表示されます。)
    let appName = "JWStudioVoIPApp"
    let providerConfiguration = CXProviderConfiguration(localizedName: appName)

    // Videoチャット機能をサポートするのか
    providerConfiguration.supportsVideo = true
    // グループチェットの可能Call数
    providerConfiguration.maximumCallsPerCallGroup = 1
    // 扱うHandleタイプ
    providerConfiguration.supportedHandleTypes = [.emailAddress, .phoneNumber]

    // カスタムボタンに表示するイメージ
    if let iconMaskImage = UIImage(named: "IconMask") {
        providerConfiguration.iconTemplateImageData = UIImagePNGRepresentation(iconMaskImage)
    }
    // 受信音設定
    providerConfiguration.ringtoneSound = "..."
    self.provider = CXProvider(configuration: providerConfiguration)
    self.provider.setDelegate(self, queue: nil)

CXProviderDelegate

電話の状態が変わる場合システムからXCProviderDelegateを通じて知らせます。プログラマーはこのDelegateを実装することでそれぞれの状態をアプリに反映することが可能です。そして、その状態を正しく処理したことをシステムに知らせることで、システムもその状態をシステム全体に反映することになります。以下にその内容を具体的に説明します。

 

着信画面表示

VoIPのPush通知を受け取った際に「CXProvider」クラスの「reportNewIncomingCall(with : , update: , completion: )」メソッドを呼び出すことで着信画面を表示することができます。このメソッドは端末がロック中でも呼び出すことができますので、便利です。以下は実装の例です。
        let update = CXCallUpdate()
        update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
        update.hasVideo = hasVideo

        self.provider.reportNewIncomingCall(with: uuid, update: update) { (error) in
            if error == nil {
                let call = Call(uuid: uuid)
                call.handle = handle
                self.callManager.addCall(call)
            }
        }
※ handleやhasVideoはプッシュ通知を受け取った際にプッシュ送信先から受け取ったデータです。この例では「handle」には電話番号(String)が「hasVideo」にはビデオを利用するかどうか(Bool)のデータが保存されています。

ここで注意して欲しいのはcallの管理です。グループチャットはもちろん電話中でも他の電話からの着信などがありますので、それぞれのCallを管理する必要があります。

各Callは「reportNewIncomingCall」メソッドで指定したUUIDでDelegateされるため、管理する際にUUIDで管理するのが便利です。例の「callManager」が各callを管理するオブジェクトであります。

そして、この時電話帳にHandleで登録した内容と合致する(電話番号やメールアドレスなど)項目があればその項目の内容が着信画面に表示されます。例えばこのはちゃんの電話番号を登録しこのはちゃんの写真を設定しておきます。そして、同じ電話番号でアプリから着信画面を呼び出すとこのブログの冒頭に表示されるような着信画面が表示されます。

 

電話の着信

ユーザーが着信画面から電話を受け取ると「func provider(_ provider: CXProvider, perform action: CXAnswerCallAction)」のDelegateが呼び出されます。どのCallを受け取ったのかは「action」の「callUUID」パラメータから確認できます。以下は実装の例です。
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        guard let call = callManager.call(with: action.callUUID) else {
            action.fail()
            return
        }
        // TODO: Audio設定

        call.answer()
        action.fulfill()
    }
受け取ったCallがどのCallかをactionのcallUUIDを利用しcallManagerから探します。探しがcallの状態をanswer状態にして画面上などに表示できるようにします。最後にactionの「fulfill」メソッドを呼び出すことでシステムが電話を受け取った状態と認識できるようにします。(バナーに表示したりロック画面を電話の着信画面と表示させます。)

 

電話の発信

ユーザーがアプリから電話をかけると(アプリから電話をかける処理は後で説明します。)「func provider(_ provider: CXProvider, perform action: CXStartCallAction)」のDelegateが呼び出されます。こちらも同じくどのCallから切断要求が来たのかはactionの「callUUID」から確認できます。以下は実装の例です。
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        print("電話受信開始:\(action)")
        guard let call = callManager.call(with: action.callUUID) else {
            action.fail()
            return
        }
        // TODO: Audio設定
        call.startCall { success in
            if success {
                action.fulfill()
                self.callManager.addCall(call)
            } else {
                action.fail()
            }
        }
    }
callの「startCall」は自作メソッドであり、その内容は実際に電話をかけて(サーバーに相手へ電話をかける要求をする)その結果を返すメソッドです。成功した場合successにtrueを設定し返すメソッドです。こちらも成功した場合actionの「fulfill」メソッドを呼び出すことで電話が正しくかけられたことをシステムに知らせます。

 

電話の切断

ユーザーが電話の切断ボタンをタップすると(アプリからの切断は後で説明します。)「func provider(_ provider: CXProvider, perform action: CXEndCallAction)」のDelegateが呼び出されます。こちらも同じくどのCallから切断要求が来たのかはactionの「callUUID」から確認できます。以下は実装の例です。
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        print("provider perform end action:\(action)")
        guard let call = callManager.call(with: action.callUUID) else {
            action.fail()
            return
        }
        // TODO: Audio終了

        call.end()
        action.fulfill()
        callManager.removeCall(call)
    }
切断する際にも同じくactionの「fulfill」メソッドを呼び出すことで処理が終了できたことをOSに知らせます。

 

電話の保留

電話の相手を待たせる場合、保留状態にすることができます。保留にした場合は「func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction)」のDelegateが呼び出されます。こちらも同じくどのCallからHold要求が来たのかはactionの「callUUID」から確認できます。以下は実装の例です。
    func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
        guard let call = callManager.call(with: action.callUUID) else {
            action.fail()
            return
        }
        call.isOnHold = action.isOnHold

        if call.isOnHold {
            // TODO: Audio終了
        } else {
            // TODO: Audio開始
        }
        action.fulfill()
    }
こちらも同じくactionの「fulfill」メソッドを呼び出すことで処理が終了できたことをOSに知らせます。

 

CXCallController

システムに直接電話の状態をコントロール(電話をかける、切断、保留など)したい場合、このクラスからオブジェクトを生成しシステムに要求することができます。そして、要求する際には必ず「CXCallAction」を継承した幾つかの種類のクラスからオブジェクトを生成し、さらにそのActionからTransactionを生成します。そのTransactionをCXCallControllerオブジェクトに「request」をすることで実現できます。以下にて具体的に説明します。

 

電話を発信する

電話をかける場合は「CXStartCallAction」を生成することから始まります。まずは以下の実装の例を確認してください。
    let callController = CXCallController()
...
    func startCall(phoneNumber: String, video: Bool) {
        let handle = CXHandle(type: .phoneNumber, value: phoneNumber)
        let startCallAction = CXStartCallAction(call: UUID(), handle: handle)

        startCallAction.isVideo = video

        let transaction = CXTransaction()
        transaction.addAction(startCallAction)

        self.callController.request(transaction) { error in
            if error != nil {
                print ("Error:\(error!)")
            }
        }
    }
Handleは電話の相手の情報です。そして、すべてのAction(Call)はUUIDで管理されるため、Actionを生成する際に一緒に生成し渡します。そのActionからCXTransactionを生成しさらにCXTransactionオブジェクトをCXCallControllerオブジェクトにリクエスト(requestメソッド)を呼び出すことでシステムに電話をかける処理を要求することができます。この要求を出すと上に紹介しました電話をかけるDelegateが呼び出されることになります。

 

電話を切る

電話を切る場合は「CXEndCallAction」オブジェクトを生成し上と同じく処理をすることで実現できます。以下は実装の例です。
    func endCall(call: Call) {
        let endCallAction = CXEndCallAction(call: call.uuid)

        let transaction = CXTransaction()
        transaction.addAction(endCallAction)

        self.callController.request(transaction) { error in
            if error != nil {
                print ("Error:\(error!)")
            }
        }
    }
 

電話を保留する

保留処理を行う際には「CXSetHeldCallAction」オブジェクトを生成し上と同じく処理をすることで実現できます。以下は実装の例です。
    func holdCall(call: Call, onHold: Bool) {
        let setHeldCallAction = CXSetHeldCallAction(call: call.uuid, onHold: onHold)

        let transaction = CXTransaction()
        transaction.addAction(setHeldCallAction)

        self.callController.request(transaction) { error in
            if error != nil {
                print ("Error:\(error!)")
            }
        }
    }
以上、iOS 10で新しく追加されましたPushKitとCallKitを利用してVoIPアプリを簡単に実装してみました。今回はAudio関連の設定などは(TODOになっている箇所)説明してませんので、次回こちらも含めてサーバーサイドの処理も機会があれば紹介したいと思います。
PushKitとCallKitの機能を利用すると3rd Partyのアプリから純正の電話アプリに近いアプリを作ることができるようになりました。皆さんも自分のVoIPアプリを作ってみてはいかがでしょうか。

 

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧からご応募をお願いします。