Jeffsuke is not a pen.

🏊‍♂️🚴‍♂️🏃‍♂️💻📱

CallKitを用いたSystem Calling Screenの実装

背景

WWDC16でVoIPアプリでもiOSネイティブのUIを使えるようになりました。これまでは、Push通知からユーザーにアプリを開いてもらう必要がありましたが、サードパーティ製アプリでもネイティブアプリのUIを開けるようになったことで、より一貫した体験を提供でき通知にも気づきやすくなるメリットがあります。

個人プロジェクトでいろいろ試してみたので、まとめようと思います。やること自体はシンプルなのですが、私は証明書関連でつまずきました。

やることざっくりまとめ

  1. Amazon SNSを設定する
  2. アプリ側の初期設定。PKPushRegistryを用いて、VoIPプッシュ通知の初期設定を行う
  3. Amazon SNSから、VoIPプッシュ通知を送る
  4. CallKitを用いてネイティブ通話画面を表示

Amazon SNSを用いて、VoIPプッシュ通知を受け取る

Amazon SNSを設定する

ネイティブ通話画面を表示するためには、アプリをバックグラウンドで起動する必要があります。そのためにVoIPプッシュ通知を送る準備をする必要があります。

VoIP push notificationを用いると、アプリがバックグラウンドで起動されることが保証され、バックグラウンドで起動している場合でも処理を実行するための時間が確保されています。私はこの点に気づかず、通常のプッシュ通知やローカル通知で実装できないか試行錯誤していました。 より詳しい違いについては、VoIPプッシュ通知(PushKit)と標準プッシュ通知の違いについてがとても参考になります。

Parseや、Firebaseは実装時(2016年8月頃)には、VoIP プッシュ通知をサポートしていなかったため、AWSのプッシュ通知サービスを使うことにしました。

基本的に、通常のプッシュ通知をSNSから送る場合と、オプションと証明書が変わるだけで、手順は変わりません。

まずは、証明書の作成です。通常のプッシュ通知と同様にApple Developerウェブサイトから、証明書を作成することができます。Apple push notification service SSLではなく、VoIP Service Certificateを選択し、証明書を取得し、P12ファイルを書き出します。iOSプッシュ通知用証明書の更新方法が参考になります。

次に、AWS管理画面から、SNSを選択します。

f:id:jeffsuke:20160930165152p:plain

次に、Applicationを選択し、Create platform applicationからアプリケーションを作成します。

f:id:jeffsuke:20160930164451p:plain

表示された、プラットフォーム設定画面にて、必要な情報を入力します。

  • Application name: 任意のアプリ名
  • Push notification platform: Apple development もしくはApple production
  • Push certificate type: VoIP push certificate
  • Chose P12 file: Apple Developerウェブサイトで作成したVoIPプッシュ通知用P12ファイルを選択
  • Enter Password: P12ファイル作成時のパスワード

SNS側の設定は以上で完了です。*1

VoIPプッシュ通知を受け取るための、アプリ側での設定

次に、アプリ側のプロジェクト設定をします。プロジェクトファイルを選択し、VoIPプッシュ通知を受け取りたいターゲットの、Capabilityから、Background Modeを有効にします。Voice over IPと、Backgrond fetchを選択し、VoIPプッシュ通知を受け取り、任意の処理を実行できるようにします。*2

f:id:jeffsuke:20161001181637p:plain

次に、PKPushRegistryを用いて、デバイストークンを取得します。以下のように、PKPushRegistryインスタンスに、VoIPプッシュ通知を受け取るように指定します。デリゲートで指定したインスタンスが、VoIPプッシュ通知関連のコールバックを受け取ります。

#import <PushKit/PushKit.h>

@interface JSKPushKitManager()<PKPushRegistryDelegate>
@property (nonatomic, strong) PKPushRegistry* voipRegistry;
@end

@implementation JSKPushKitManager

-(void)setupPushKit {
    dispatch_queue_t mainQueue = dispatch_get_main_queue();
    self.voipRegistry = [[PKPushRegistry alloc] initWithQueue: mainQueue];
    self.voipRegistry.delegate = self;
    self.voipRegistry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP];
}

この、setupPushKitAppDelegate内で呼び、PKPushRegistryのコールバックを受け取れるようにします。

-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [self setupPushKit];
}

デバイストークンを取得するために、JSKPushKitManagerに戻り、PKPushRegistryのデリゲートメソッドを実装します。

// トークンが更新されたときに呼ばれる。
-(void)pushRegistry:(PKPushRegistry *)registry didUpdatePushCredentials:(PKPushCredentials *)credentials forType:(NSString *)type {
    NSString *tokenString = [self tokenStringWithData:credentials.token];
    NSLog(@"Token String: %@", tokenString);
    // トークンをサーバーに送る処理
}

-(NSString *)tokenStringWithData:(NSData *)data {
    const unsigned *tokenBytes = [data bytes];
    NSString *hexToken = [NSString stringWithFormat:@"%08x%08x%08x%08x%08x%08x%08x%08x",
                          ntohl(tokenBytes[0]), ntohl(tokenBytes[1]), ntohl(tokenBytes[2]),
                          ntohl(tokenBytes[3]), ntohl(tokenBytes[4]), ntohl(tokenBytes[5]),
                          ntohl(tokenBytes[6]), ntohl(tokenBytes[7])];
    
    return hexToken;
}

今回は、テストのために、NSLog()で出力したデバイスにユニークなトークンを手動でSNSで登録しますが、通常はサーバーに送信し処理を実行するのが良いと思います。*3

VoIPプッシュ通知を受け取ると以下のデリゲートメソッドが呼ばれます。

// VoIPプッシュ通知を受け取ったとき
-(void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(NSString *)type {
    NSLog(@"Received a VoIP push notification.");
}

Amazon SNSからVoIPプッシュ通知を送る

取得したデバイストークンを、テストのためにSNSに登録します。SNS管理画面から、作成したApplicationを選択し、Create platform endpointからデバイストークンを追加します。デバイストークンが追加されると、各デバイスのEndpoint ARNが作成され、各端末に配信する際にこの値を指定します。

f:id:jeffsuke:20161001184519p:plain 

デバイストークン登録画面では、Device Tokenの他にUser Dataを入力することができます。ユーザーID等を登録することで、サーバーから任意の端末にプッシュ通知を送れるようになります。

さて、実際にVoIPプッシュ通知を送信してみましょう。送信したい端末にチェックを入れ、Publish to EndpoitからVoIPプッシュ通知を送ることができます。iOS側の設定が完了していれば、VoIPプッシュ通知が届いたログが見れるはずです。

CallKitを用いてネイティブ通話画面を表示

VoIPプッシュ通知が送れるようになれば、あとはネイティブ通話画面を表示するだけです。 登場するクラスは以下の通りです。

  • CXProvider:通話画面を呼び出すクラス
  • CXProviderConfiguration: アプリ固有の通話画面共通の設定
  • CXCallUpdate: 受け取った通話に関する情報

JSKSystemCallProviderというクラスに、通話に関する機能を実装していきます。

@interface JSKSystemCallProvider() <CXProviderDelegate>
@property (nonatomic, strong) CXProvider *provider;
@property (nonatomic, strong) CXProviderConfiguration *configuration;
@end

@implementation JSKSystemCallProvider

-(instancetype)init {
    self = [super init];
    if (self) {
        _configuration = [self newConfiguration];
        _provider = [[CXProvider alloc] initWithConfiguration:_configuration];
        [_provider setDelegate:self queue:nil];        
    }
    
    return self;
}

-(CXProviderConfiguration *)newConfiguration {
    CXProviderConfiguration *configuration = [[ CXProviderConfiguration alloc] initWithLocalizedName:@"Sample Phone Call"];
    configuration.supportedHandleTypes = [NSSet setWithObject:@(CXHandleTypePhoneNumber)];
    return configuration;
}

CXProviderCXProviderConfigurationを用いて初期化します。CXProviderConfigurationでは、着信音や、アプリ表示名、アプリアイコンの指定などができます。

通話開始

通話を開始するには、CXProvider- (void)reportNewIncomingCallWithUUID:(NSUUID *)UUID update:(CXCallUpdate *)update completion:(void (^)(NSError *_Nullable error))completion;を呼びます。

-(void)reportIncomingCallWithHandle:(NSString *)handle
                            success:(void (^)())success
                            failure:(void (^)(NSError * error))failure {
    CXCallUpdate *update = [self newCallUpdateWithHandle:handle];
    self.callId = [NSUUID UUID];

    [self.provider reportNewIncomingCallWithUUID:self.callId update:update completion:^(NSError * _Nullable error) {
        if (error) {
            if (failure) failure(error);
        } else {
            if (success) {
                success();
            }
        }
    }];
}

ユーザーが、通話開始ボタンを押すと、success()が呼ばれます。VoIP通話を開始する処理をBlocksとして渡し、通話を開始させます。

この時、CXCallUpdateインスタンスを用いて通話に関する情報を渡しています。このクラスでは、動画をサポートするか、グループコールをサポートするか等の情報を付与することが出来ます。

-(CXCallUpdate *)newCallUpdateWithHandle:(NSString *)handle {
    CXCallUpdate *update = [[CXCallUpdate alloc] init];
    update.remoteHandle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:handle];
    update.supportsGrouping = NO;  // グループコールサポートしない
    update.supportsHolding = NO;  // 通話のホールド不可
    return update;
}

通話の終了

通話を開始してから、アプリを開くことができるため、通話終了処理は、アプリから終了した場合と、ネイティブ通話画面から終了した場合と両方考慮する必要があります。

アプリから通話を終了する場合は、以下のようにCXProvider- (void)reportCallWithUUID:(NSUUID *)UUID endedAtDate:(nullable NSDate *)dateEnded reason:(CXCallEndedReason)endedReason;を用いて、ネイティブ通話画面を終了させます。

-(void)reportCallDidEnd {
    NSDate *callEndDate = [NSDate date];
    [self.provider reportCallWithUUID:self.callId endedAtDate:callEndDate reason:CXCallEndedReasonRemoteEnded];
}

ネイティブ通話画面から通話を終了する場合は、以下のデリゲートメソッドが呼ばれます。

-(void)provider:(CXProvider *)provider performEndCallAction:(CXEndCallAction *)action {
    // 通話終了の処理
    [action fulfill];
}

[action fulfill];を呼ぶことで、ネイティブ通話画面での表示も通話終了処理が完了した状態になります。

ネイティブ通話画面を使う上での注意点

画面ロック時と、非ロック時の挙動が違う。

画面ロック時は、ユーザー側の視点だと、通話開始ボタンを押すと、通話中画面になり通話が開始します。バックグラウンドでは、アプリが起動し通話に必要な処理を実行しています。画面非ロック時には、ユーザーが通話開始ボタンを押すとアプリが直接起動されます。つまり、通話開始によってロックが解除されるわけではないので、ユーザーがロック解除するまでアプリ画面を提供することができません。

そのため、現時点ではパーミッション取得のアラートが表示できないため、マイクとカメラのパーミッションを通話開始前に取得しておく必要があります。通話が開始しても、相手の声が聞こえても、自分の声が聞こえない状態になってしまします。*4

また、通話開始のために特定の画面遷移を持つアプリは注意が必要です。画面非ロック時には通話開始と同時にアプリが起動するため、一貫した体験のために他の画面を見せずに通話画面を見せる必要があります。

VoIPプッシュ通知受信時のライフサイクル

VoIPプッシュを受信すると、バックグラウンドでアプリが起動され、まず、

-(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions;

が呼ばれ、

-(void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(NSString *)type;

が呼ばれます。アプリ起動時に非同期で何らかの処理を実行している場合は注意が必要です。

まとめ

Amazon SNSによるVoIPプッシュ通知と、CallKitによるネイティブ通話画面の実装をまとめました。クライアントでVoIPプッシュ通知を受け取り、ネイティブ通話画面を表示し、バックグラウンドでアプリを起動し通話を開始することで、iOSの通話と同様の体験を提供することができます。

実装にあたり、Appleが公開しているサンプルプロジェクトが、とても参考になったので、見てみてください。

参考文献

*1:実際の運用には、サーバー側の実装ももちろん必要

*2:この設定に気づかず、VoIPプッシュ通知からバックグラウンドで起動しないなと、数時間ハマりました

*3:クライアントから直接AWSに登録することもできますが、おすすめはしません

*4:執筆時に、某有名アプリでこの挙動を確認して辛い気持ちになった