プッシュ通知 - iOS

概要

iOSでは、VoIPアプリは以下を使用する必要があります プッシュキット 着信通知および コールキット システムコールのUIを表示するためです。これら2つのフレームワークは密接に連携しており、Appleは、すべてのVoIPプッシュが reportNewIncomingCall CallKit に対して。これに準拠しない場合、iOS はそのデバイスへのプッシュ通知配信を永久に停止します。

Vonage Client SDK は、両方のフレームワークと統合されています。アプリ側で以下の処理を行う必要があります:

  • VoIPプッシュの登録と、トークンのVonageへの転送
  • プッシュ通知のペイロードを受信し、処理のためにSDKに渡す
  • CallKit を使用した着信の表示
  • CallKitのコールバックのみを使用してオーディオセッションのライフサイクルを管理する
  • 呼び出しに応答できるよう、有効なSDKセッションを維持する

コンポーネントの役割

コンポーネント 責任
プッシュキット アプリを終了状態/サスペンド状態から復帰させ、プッシュ通知のペイロードを配信する
ボンテージSDK プッシュペイロードのデコード、WebRTCセッションの管理、通話シグナリングの処理を行う
コールキット システムコールのUIを提供し、オーディオセッションの起動を管理する
あなたのアプリ 上記を調整し、JWTおよびセッションのライフサイクルを管理する

プロジェクトの設定

1 - Xcodeの機能

ターゲットの以下の設定を有効にしてください。 署名と能力 タブ

Push Notifications             → enabled
Background Modes               → Voice over IP (checked)
                               → Background processing (checked)

権限ファイルには、以下の内容を含める必要があります:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>aps-environment</key>
    <string>development</string>
    <key>com.apple.developer.pushkit.unrestricted-voip</key>
    <true/>
</dict>
</plist>

警告だ: について aps-environment この値は、プロビジョニングに基づいてXcodeによって自動的に設定されます。手動で上書きしないでください。

制限付きVoIP利用権限

iOS 16以降、アプリは ~なし その com.apple.developer.pushkit.unrestricted-voip 利用権限は、1日あたりのPushKitプッシュ通知の数がごく少数に制限されています。システムの割り当て上限を超えた場合、プッシュ通知の配信は黙って制限され、着信があってもアプリは起動しません。

この権利は、Appleに対し、以下の方法を通じて申請する必要があります。 給付申請書. これが承認されるまでは、開発ビルドは正常に動作するかもしれませんが(デバッグビルドの方がリソース配分が多いため)、 本番ビルドの処理速度は制限されます.

この権限が、お使いの .entitlements App Storeに公開する前に、Appleによる審査と承認を受ける必要があります。

2 - プッシュ証明書

App ID には APNs 証明書が必要であり、これは .p12、そして、アプリが認証を行うVonageアプリケーションにアップロードされます。最新のアプリスコープのAPNs SSL証明書の有効期間は、 .voip トピックであるため、別途、従来の「VoIPサービス証明書」が 違う 必須。

CSRを生成する

で キーチェーンアクセス → 証明書アシスタント → 認証局から証明書をリクエストする, メールアドレスと「Common Name」を入力し、「CA Email」は空欄のままにして、選択してください ディスクに保存されました, そして .certSigningRequest.

証明書を作成する

  1. の中で Apple 開発者ポータル → 証明書、識別子、プロファイル, 必ず アプリID には プッシュ通知 機能が有効になっています。
  2. アンダー 証明書, を追加して Appleプッシュ通知サービスのSSL化(サンドボックスと本番環境) 証明書を作成し、App ID を選択して、CSR をアップロードしてください。
  3. 生成されたファイルをダウンロードする .cer.

をエクスポートする .p12

をダブルクリックして .cer ログインキーチェーンにインポートするには。 私の証明書見つける Apple Push Services: <your-bundle-id> — その下に秘密鍵が階層的に配置されている必要があります — その後、右クリック → 輸出 → 個人情報の交換 (.p12). 可能であれば、エクスポート用パスワードは空欄のままにしてください(以下のアップロードに関する注意事項を参照)。

Vonageダッシュボードからアップロードする

  1. にログインする。 Vonageダッシュボード.
  2. こちらへ Applications → あなたのアプリケーション (その application_id (アプリがJWTによる認証を行う相手)。
  3. を開く。 プッシュ通知を有効にする タブをクリックして、 .p12.
  4. パスワード入力欄は任意であり、ラベルが付けられています お勧めしません (Vonageはこれを秘密鍵と一緒に保存します)ため、パスワード不要の証明書が推奨されます。

参照 Vonage iOS プッシュ通知設定ガイド ご参考まで。

証明書の有効期限は1年ごとです。障害モードは 完全かつ静寂な — システム内のどこにもエラーは表示されません。有効期限を把握し、先回りして更新を行ってください。

警告だ: サンドボックスと本番環境 XcodeのデバッグビルドではサンドボックスAPNs環境が使用され、TestFlightおよびApp Store向けのビルドでは本番環境が使用されます。アップロード 個別の証明書 各環境ごとに、常に isSandbox コンパイル時にフラグを指定して #if DEBUG — 決してハードコーディングしてはいけません。

3 - フレームワーク

追加 CallKit 以下 フレームワーク、ライブラリ、組み込みコンテンツ 対象の「一般」タブで。必要に応じてインポートしてください:

import PushKit
import CallKit
import VonageClientSDKVoice
import AVFoundation

4 - SDKの初期化

初期化 VGVoiceClient アプリのライフサイクルの早い段階で(たとえば、AppDelegate やシングルトンマネージャー内など)一度だけ設定してください。プッシュ通知の登録やセッションの作成を行う前に、そのデリゲートを設定してください:

let config = VGClientInitConfig(loggingLevel: .info)

#if targetEnvironment(simulator)
config.enableWebsocketInvites = true
#endif

let client = VGVoiceClient(config)
client.delegate = self

アプリのライフサイクルの早い段階で、マイクの権限をリクエストしてください。理想的には application(_:didFinishLaunchingWithOptions:):

AVAudioSession.sharedInstance().requestRecordPermission { granted in
    print("Microphone permission: \(granted)")
}

ユーザーがマイクへのアクセスを拒否した場合、通話は接続されますが、相手側には無音状態として聞こえます。

PushKitとの連携

VoIPプッシュ通知への登録

を作成する。 PKPushRegistry そして、アプリ起動時、つまり呼び出しが到着する前に、希望するプッシュの種類を設定します。

 #if DEBUG
 let isSandbox = true
 #else
 let isSandbox = false
 #endif

 client.registerVoipToken(pushToken, withDeviceToken: nil, isSandbox: isSandbox) { error, deviceId in
     if let error = error {
         print("Failed to register push token: \(error.localizedDescription)")
         return
     }
     // Store deviceId for unregistration on logout
     print("Push token registered, deviceId: \(deviceId ?? "")")
 }
 PKPushRegistry *pushRegistry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()];
 pushRegistry.delegate = self;
 pushRegistry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP];

PKPushRegistryDelegate

3つのデリゲートメソッドを実装してください。3つ目は―― didReceiveIncomingPushWith — が最も重要である:

extension AppDelegate: PKPushRegistryDelegate {

    // Called when a new VoIP token is issued or rotated
    func pushRegistry(_ registry: PKPushRegistry,
                      didUpdate pushCredentials: PKPushCredentials,
                      for type: PKPushType) {
        guard type == .voIP else { return }
        CallManager.shared.voipTokenDidUpdate(pushCredentials.token)
    }

    // Called when the token is invalidated (e.g. app reinstall)
    func pushRegistry(_ registry: PKPushRegistry,
                      didInvalidatePushTokenFor type: PKPushType) {
        CallManager.shared.unregisterDeviceToken()
    }

    // Called when an inbound VoIP push is received
    func pushRegistry(_ registry: PKPushRegistry,
                      didReceiveIncomingPushWith payload: PKPushPayload,
                      for type: PKPushType,
                      completion: @escaping () -> Void) {
        guard type == .voIP else { completion(); return }
        CallManager.shared.handleIncomingPush(payload: payload, completion: completion)
    }
}

Vonageへのトークンの登録

登録はこちら client.registerVoipToken(_:isSandbox:) Vonageセッションが存在する場合に限り。返された値を保存する deviceId — ログアウト時に登録を解除するには、これが必要です。

 import PushKit

 let pushRegistry = PKPushRegistry(queue: .main)
 pushRegistry.delegate = self
 pushRegistry.desiredPushTypes = [.voIP]
#if DEBUG
BOOL isSandbox = YES;
#else
BOOL isSandbox = NO;
#endif

[client registerVoipToken:pushToken withDeviceToken:nil isSandbox:isSandbox callback:^(NSError * _Nullable error, NSString * _Nullable deviceId) {
    if (error != nil) {
        NSLog(@"Failed to register push token: %@", error);
        return;
    }
    // Store deviceId for unregistration on logout
    NSLog(@"Push token registered, deviceId: %@", deviceId);
}];

電話しないでください registerVoipToken セッションの復元ごとに。一度登録されると、トークンはローテーションされるか、ユーザーがログアウトするまで有効です。各 createSession デバイスエントリが蓄積され、プッシュの重複を引き起こす可能性があります。

セッション管理

Vonageのセッション(client.createSession(jwt)) は、SDK が通話招待を処理したり、通話操作を実行したりする前に必要です。VoIP プッシュが届いた時点でアプリが強制終了されていたり、セッションの有効期限が切れていたりする場合は、セッションを再作成する必要があります。この処理の速さが、ユーザーが通話に正常に応答できるかどうかを直接左右します。

最も一般的な製造上の不具合

やってはいけないこと: プッシュ通知を受信したら:認証サーバーを呼び出す → 新しいJWTを取得する → 呼び出す createSession. これにより、信号状態の悪いバックグラウンド実行ウィンドウ内で、シリアルネットワークのホップが2つ追加される。ユーザーはCallKitの画面を表示し、セッションの準備が整う前に「応答」をタップしてしまうため、通話は何の前触れもなく失敗してしまう。

実行すべきこと: ユーザーログイン時:JWT(およびリフレッシュトークン)をローカルに保存する。プッシュ通知受信時:呼び出す createSession 保存済みのJWTを直接使用します。ファストパスではバックエンドへの呼び出しは一切行われません。

JWTの戦略

優先順位の連鎖に従い、利用可能な経路の中で最も速いものを選択します:

優先順位 状態 アクション 一般的なレイテンシ
第1位 アクティブなセッションが存在します すぐに戻ってきてください — 作業は必要ありません 0 ms
第2位 保存されているJWTは有効です client.createSession(storedJWT) 100~400ミリ秒
第3位 JWTの有効期限が切れましたが、リフレッシュトークンは利用可能です エンドポイントを更新 → 新しいJWT → createSession 300~1000ミリ秒
4番目 認証情報なし 警告ログ:呼び出しに応答できません 0 ms

セッション作成

 do {
     let sessionId = try await client.createSession(jwt)
     print("Session created: \(sessionId)")
 } catch {
     print("Failed to create session: \(error.localizedDescription)")
 }
 [client createSession:jwt callback:^(NSError * _Nullable error, NSString * _Nullable sessionId) {
     if (error != nil || sessionId == nil) {
         NSLog(@"Failed to create session: %@", error);
         return;
     }
     NSLog(@"Session created: %@", sessionId);
 }];

プッシュ受信時のセッション復元

func restoreSessionIfNeeded() {
    guard activeSessionId == nil else {
        print("Session already active")
        return
    }

    if let jwt = UserDefaults.standard.string(forKey: "vonage_jwt") {
        // Fast path — use stored JWT directly
        createSession(jwt: jwt)
    } else if let refreshToken = storedRefreshToken {
        // Slow path — exchange refresh token for a new JWT first
        refreshJWT(refreshToken) { [weak self] newJWT in
            self?.createSession(jwt: newJWT)
        }
    } else {
        print("No credentials available for session restoration")
    }
}

プッシュペイロードの処理

これは、統合作業の中で最もタイミングが重要な部分です。以下の2つの操作を handleIncomingPush、そしてそれらの間の順序関係は自明ではない。

並行処理パターン(必須)

呼び出す前に、セッションの復元を待ってはいけません processCallInvitePushData. ゲート処理を後段に配置したプッシュ処理 createSession 遅延 reportNewIncomingCall ネットワークの往復通信全体にわたって発生する場合。Appleはこれをプッシュ通知の処理に失敗したとみなします。PushKitは、そのデバイスへの今後のプッシュ通知の配信を、何の前触れもなく停止します。

実施

 client.processCallInvitePushData(payload.dictionaryPayload)
 [client processCallInvitePushData:payload.dictionaryPayload];

完全な実装パターン:

private var pendingPushCompletion: (() -> Void)?

func handleIncomingPush(payload: PKPushPayload, completion: @escaping () -> Void) {

    // 1. Validate: only handle Vonage incoming-call pushes
    let pushType = VGVoiceClient.vonagePushType(payload.dictionaryPayload)
    guard pushType == .incomingCall else {
        // Must still report to CallKit — failing to do so breaks future push delivery.
        let fakeUUID = UUID()
        callProvider.reportNewIncomingCall(with: fakeUUID, update: CXCallUpdate()) { _ in
            self.callProvider.reportCall(with: fakeUUID, endedAt: .now, reason: .failed)
            completion()
        }
        return
    }

    // 2. Store completion — will be called after reportNewIncomingCall
    pendingPushCompletion = completion

    // 3a. Restore session concurrently — non-blocking
    restoreSessionIfNeeded()

    // 3b. Process the push immediately — triggers didReceiveInviteForCall
    client.processCallInvitePushData(payload.dictionaryPayload)
}

警告だ: プッシュが不正な形式であったり、有効期限が切れていたり、重複していたりしても、あなたは マスト まだ電話する reportNewIncomingCall そして、すぐに通話を終了してください。この手順を省略すると、プッシュ配信が永久に機能しなくなります。

CallKitとの連携

CXProviderの設定

作成 CXProvider そして CXCallController 一度作成し、そのマネージャーの存続期間中はそのまま維持してください。呼び出しやプッシュのたびに再作成しないでください。

class CallManager: NSObject {

    static let shared = CallManager()

    private let callProvider: CXProvider
    private let callController = CXCallController()

    private override init() {
        let config = CXProviderConfiguration()
        config.supportsVideo            = false
        config.maximumCallGroups        = 1
        config.maximumCallsPerCallGroup = 1
        config.supportedHandleTypes     = [.generic]
        config.supportsDTMF             = true
        config.supportsHolding          = true

        callProvider = CXProvider(configuration: config)
        super.init()
        callProvider.setDelegate(self, queue: nil)
    }
}

着信の報告

発信元: didReceiveInviteForCall (「SDK デリゲート」のセクションを参照)。PushKit のコンプリートメソッドは、 reportNewIncomingCall コールバック — それ以前には一度もなかった。

 let update = CXCallUpdate()
 let handleType: CXHandle.HandleType = channelType == .phone ? .phoneNumber : .generic
 update.remoteHandle = CXHandle(type: handleType, value: caller)
 update.hasVideo = false

 provider.reportNewIncomingCall(with: callId, update: update) { error in
     if let error = error {
         print("Failed to report incoming call: \(error.localizedDescription)")
     }
     // Always call the PushKit completion inside this callback
     pushCompletion()
 }
 CXCallUpdate *update = [[CXCallUpdate alloc] init];
 CXHandleType handleType = (channelType == VGVoiceChannelTypePhone) ? CXHandleTypePhoneNumber : CXHandleTypeGeneric;
 update.remoteHandle = [[CXHandle alloc] initWithType:handleType value:caller];
 update.hasVideo = NO;

 [provider reportNewIncomingCallWithUUID:callId update:update completion:^(NSError * _Nullable error) {
     if (error != nil) {
         NSLog(@"Failed to report incoming call: %@", error);
     }
     // Always call the PushKit completion inside this callback
     pushCompletion();
 }];

CXHandle 型 使用方法 .phoneNumber CXHandle 型の場合、 VGVoiceChannelType は .phone (E.164番号を持つPSTN発信者)。使用方法 .generic アプリ間通話の発信者について。これは、iOSがネイティブの通話UIや「最近の通話」で発信者を表示する方法に影響します。

CXProviderDelegate - ユーザーの操作

extension CallManager: CXProviderDelegate {

    // Called when CallKit resets (e.g. during system errors or when the
    // provider is invalidated). Clean up all active call state.
    func providerDidReset(_ provider: CXProvider) {
        activeCall = nil
    }

    // User tapped Answer
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        guard let callId = activeCallId else { action.fail(); return }

        client.answer(callId) { error in
            if error == nil {
                action.fulfill()
            } else {
                action.fail()
            }
        }
    }

    // User tapped End (covers both Reject while ringing and Hang up during call)
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        guard let callId = activeCallId else { action.fail(); return }

        let complete: (Error?) -> Void = { error in
            error == nil ? action.fulfill() : action.fail()
        }

        if isCallRinging {
            client.reject(callId, callback: complete)
        } else {
            client.hangup(callId, callback: complete)
        }
    }

    // Mute toggle
    func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
        guard let callId = activeCallId else { action.fail(); return }

        if action.isMuted {
            client.mute(callId) { _ in action.fulfill() }
        } else {
            client.unmute(callId) { _ in action.fulfill() }
        }
    }

    // Hold toggle
    func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
        guard let callId = activeCallId else { action.fail(); return }

        if action.isOnHold {
            client.enableEarmuff(callId) { _ in action.fulfill() }
        } else {
            client.disableEarmuff(callId) { _ in action.fulfill() }
        }
    }

    // DTMF
    func provider(_ provider: CXProvider, perform action: CXPlayDTMFCallAction) {
        guard let callId = activeCallId else { action.fail(); return }
        client.sendDTMF(callId, withDigits: action.digits) { _ in action.fulfill() }
    }
}

オーディオセッションの引き継ぎ

 // In CXProviderDelegate - provider(_:didActivate:)
 func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
     VGVoiceClient.enableAudio(audioSession)
 }

 // In CXProviderDelegate - provider(_:didDeactivate:)
 func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
     VGVoiceClient.disableAudio(audioSession)
 }
 // In CXProviderDelegate - provider:didActivateAudioSession:
 [VGVoiceClient enableAudio:audioSession];

 // In CXProviderDelegate - provider:didDeactivateAudioSession:
 [VGVoiceClient disableAudio:audioSession];

いつも Vonageの音声機能を、このアプリ内でのみ有効化および無効化します didActivate / didDeactivate. CallKitがセッションをアクティブ化する前にオーディオを有効にしたり、UIボタンのハンドラからオーディオを有効にしたりすると、Bluetooth、AirPods、およびCarPlayでオーディオルーティングに関する不具合が発生します。

SDK デリゲート

VGVoiceClientDelegate - 着信イベント

extension CallManager: VGVoiceClientDelegate {

    func voiceClient(_ client: VGVoiceClient,
                     didReceiveInviteForCall callId: VGCallId,
                     from caller: String,
                     with type: VGVoiceChannelType) {

        guard let callUUID = UUID(uuidString: callId) else { return }

        activeCallId   = callId
        activeCallUUID = callUUID
        isCallRinging  = true

        // Report to CallKit, then call the PushKit completion in the callback
        reportIncomingCall(callUUID: callUUID, caller: caller, type: type,
                           pushCompletion: pendingPushCompletion ?? {})
        pendingPushCompletion = nil
    }

    // Remote party hung up or the call timed out
    func voiceClient(_ client: VGVoiceClient,
                     didReceiveHangupForCall callId: VGCallId,
                     withQuality callQuality: VGRTCQuality,
                     reason: VGHangupReason) {

        guard let uuid = activeCallUUID else { return }

        let cxReason: CXCallEndedReason = switch reason {
            case .remoteReject:           .declinedElsewhere
            case .remoteHangup, .localHangup: .remoteEnded
            case .remoteNoAnswerTimeout:  .unanswered
            default:                      .failed
        }

        callProvider.reportCall(with: uuid, endedAt: .now, reason: cxReason)
        cleanUpCallState()
    }

    // Caller cancelled the invite before it was answered
    func voiceClient(_ client: VGVoiceClient,
                     didReceiveInviteCancelForCall callId: VGCallId,
                     with reason: VGVoiceInviteCancelReason) {

        guard let uuid = activeCallUUID else { return }

        let cxReason: CXCallEndedReason = switch reason {
            case .answeredElsewhere: .answeredElsewhere
            case .rejectedElsewhere: .declinedElsewhere
            case .remoteCancel:      .remoteEnded
            case .remoteTimeout:     .unanswered
            default:                 .failed
        }

        callProvider.reportCall(with: uuid, endedAt: .now, reason: cxReason)
        cleanUpCallState()
    }

    private func cleanUpCallState() {
        activeCallId   = nil
        activeCallUUID = nil
        isCallRinging  = false
    }
}

必ず電話してください callProvider.reportCall(with:endedAt:reason:) にとって すべての 終了経路 — 相手側の切断、相手側の中断、メディアタイムアウト、発信側の拒否。これらのいずれかが欠けていると、CallKit が不整合な状態となり、通話履歴や今後の通話レポートに不具合が生じる可能性があります。

発信

発信の場合、流れは逆になります。アプリがSDKを介して通話を開始し、その後、 CXCallController CallKitに通知するため。

// 1. Initiate the call via the SDK
func startCall(to callee: String) {
    let callContext = ["callee": callee]

    client.serverCall(callContext) { [weak self] error, callId in
        guard let callId,
              let callUUID = UUID(uuidString: callId) else { return }

        self?.activeCallId   = callId
        self?.activeCallUUID = callUUID

        // 2. Notify CallKit so the in-call UI appears
        let handle = CXHandle(type: .generic, value: callee)
        let action = CXStartCallAction(call: callUUID, handle: handle)
        let transaction = CXTransaction(action: action)

        self?.callController.request(transaction) { [weak self] error in
            guard error == nil else { return }
            self?.callProvider.reportOutgoingCall(with: callUUID, startedConnectingAt: .now)
        }
    }
}

// 3. CXStartCallAction — CallKit confirming the transaction
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
    action.fulfill()
}

// 4. When the remote side answers
func voiceClient(_ client: VGVoiceClient,
                 didReceiveLegStatusUpdateForCall callId: VGCallId,
                 withLegId legId: String,
                 andStatus status: VGLegStatus) {
    guard status == .answered, let uuid = activeCallUUID else { return }
    callProvider.reportOutgoingCall(with: uuid, connectedAt: .now)
}

エラー処理

SDKが起動します didReceiveSessionErrorWith(reason:) アクティブなセッション中に、基盤となるWebSocketの接続が切断された場合。常にセッションの自動復旧を試みる。

func client(_ client: VGBaseClient,
            didReceiveSessionErrorWith reason: VGSessionErrorReason) {
    print("Session error: \(reason)")

    switch reason {
    case .tokenExpired:
        // The stored JWT is now invalid — skip it, use refresh token
        refreshAndRestoreSession()

    case .pingTimeout, .transportClosed:
        // Transient network issue — try the stored JWT first
        restoreSessionIfNeeded()

    default:
        restoreSessionIfNeeded()
    }
}

private func refreshAndRestoreSession() {
    guard let refreshToken = storedRefreshToken else {
        clearSession()
        return
    }
    refreshJWT(refreshToken) { [weak self] result in
        switch result {
        case .success(let newJWT):
            self?.createSession(jwt: newJWT)
        case .failure:
            self?.clearSession()
        }
    }
}

シミュレータの制限事項

PushKit および CallKit は、iOS シミュレータでは利用できません。この SDK では、開発用に WebSocket 招待モードが用意されています:

let config = VGClientInitConfig(loggingLevel: .info)

#if targetEnvironment(simulator)
config.enableWebsocketInvites = true   // receive invites over WebSocket — no push needed
#endif

let client = VGVoiceClient(config)
func answerCall(callId: String) {
#if targetEnvironment(simulator)
    client.answer(callId) { error in
        print("Answer: \(error?.localizedDescription ?? "OK")")
    }
#else
    guard let uuid = activeCallUUID else { return }
    let action = CXAnswerCallAction(call: uuid)
    callController.request(CXTransaction(action: action)) { _ in }
#endif
}

警告だ: リリース前には、必ず実機でプッシュ通知の全処理経路をテストしてください。シミュレータでは、アプリ終了時の起動、実際のバックグラウンド使用量制限下でのセッション復元、およびCallKitのロック画面UIを検証することはできません。

落とし穴とアンチパターン

1 - PushKitのコンプリート呼び出しの前に reportNewIncomingCall

してはいけないこと:

func pushRegistry(_ registry: PKPushRegistry,
                  didReceiveIncomingPushWith payload: PKPushPayload,
                  for type: PKPushType,
                  completion: @escaping () -> Void) {
    client.processCallInvitePushData(payload.dictionaryPayload)
    completion() // WRONG — called before reportNewIncomingCall
}

完了処理を保存し、その内部でのみ呼び出す reportNewIncomingCallの非同期コールバック。

2 - スキップ reportNewIncomingCall 無効なプッシュの場合

してはいけないこと:

guard pushType == .incomingCall else {
    completion() // WRONG — no call reported to CallKit
    return
}

手順:ダミーの呼び出しを報告して直ちに終了し、その後、完了処理を呼び出します:

guard pushType == .incomingCall else {
    let fakeUUID = UUID()
    callProvider.reportNewIncomingCall(with: fakeUUID, update: CXCallUpdate()) { _ in
        self.callProvider.reportCall(with: fakeUUID, endedAt: .now, reason: .failed)
        completion()
    }
    return
}

3 - プッシュの処理前にセッションの復元を待機中

してはいけないこと:

restoreSessionIfNeeded { // Waits for network...
    self.client.processCallInvitePushData(payload) // Too late
}

実行方法: 両方を同時に発射する:

restoreSessionIfNeeded()                          // Non-blocking
client.processCallInvitePushData(payload.dictionaryPayload) // Immediate

4 - プッシュ通知受信時にバックエンドからJWTを取得する

してはいけないこと:

func handleIncomingPush(payload: PKPushPayload, completion: @escaping () -> Void) {
    fetchJWTFromServer { jwt in        // 500ms - 3s network call
        self.client.createSession(jwt) // Another network call
        self.client.processCallInvitePushData(payload.dictionaryPayload)
    }
}

推奨:ログイン時にJWTを保存しておく。バックエンドへの呼び出しを一切行わずに、プッシュ通知の受信時にそのJWTを直接使用する。

5 - ハードコーディング isSandbox: true

してはいけないこと:

client.registerVoipToken(token, isSandbox: true)  // Breaks TestFlight

行うこと:

#if DEBUG
let isSandbox = true
#else
let isSandbox = false
#endif
client.registerVoipToken(token, isSandbox: isSandbox)

6 - セッションの復元ごとにプッシュトークンを再登録する

してはいけないこと:

func createSession(jwt: String) {
    client.createSession(jwt) { _, _ in
        self.registerVoipToken(self.storedToken) // Accumulates device entries
    }
}

行うべきこと:ユーザーのログイン時、または以下の場合にのみ登録を行う didUpdate pushCredentials ファイア(トークンのローテーション)。

7 - ログアウト時の登録解除を忘れる

もしスキップすると unregisterDeviceTokens(byDeviceId:) ログアウト後も、そのデバイスでは当該ユーザー向けのVoIPプッシュ通知が無期限に受信され続けます。別のユーザーがログインした後も同様です。

8 - CallKit にすべての通話終了理由がマッピングされていない

すべての通話終端経路は、必ず callProvider.reportCall(with:endedAt:reason:) 正しい CXCallEndedReason. パスが欠落していると、システムコールログが破損します。

9 - CallKitのコールバック以外でのオーディオ機能の有効化

してはいけないこと:

func answerButtonTapped() {
    VGVoiceClient.enableAudio(AVAudioSession.sharedInstance()) // WRONG
    client.answer(callId) { _ in }
}

行うべきこと:オーディオの処理は、CallKit デリゲート内でのみ行う:

func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
    VGVoiceClient.enableAudio(audioSession)
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
    VGVoiceClient.disableAudio(audioSession)
}

10 - 有効期限が切れたプッシュ証明書

VoIP証明書の有効期限は1年です。有効期限が切れると、何の警告もなく完全に機能しなくなります。新しい証明書をアップロードしたら、直ちに更新手続きを行い、プッシュ配信が正常に行われているかVerifyしてください。

制作チェックリスト

発送前に、このチェックリストをご確認ください:

  • [ ] Xcodeの機能設定が完了しました — プッシュ通知が有効になっています。バックグラウンドモード:「Voice over IP」と「バックグラウンド処理」にチェックが入っています。
  • [ ] 両方の環境向けにVoIPプッシュ証明書がアップロードされました — デバッグ用は「Sandbox」、TestFlight/App Store用は「Production」
  • [ ] isSandbox flag の用途 #if DEBUG — 決してハードコーディングしない
  • [ ] ログイン時にJWTとリフレッシュトークンがローカルに保存される — セッションの復元には、バックエンドへの呼び出しを必要としないこと
  • [ ] reportNewIncomingCall 例外なくすべてのVoIPプッシュを呼び出す — 無効なプッシュ、重複したプッシュ、またはVonage以外のプッシュを含む
  • [ ] PushKitの補完は内部でのみ呼び出される reportNewIncomingCallのコールバック — これまで一度も、欠かしたことはない
  • [ ] セッションの復元とプッシュ処理は並行して行われる - restoreSessionIfNeeded() そして processCallInvitePushData() 個別に発射する
  • [ ] トークンの登録は、ユーザーログイン時またはトークンのローテーション時のみ行われます — すべての…ではない createSession
  • [ ] unregisterDeviceTokens 明示的なログアウトが呼び出された
  • [ ] すべての通話終端パスはCallKitに報告されます — 遠端による通話切断、キャンセル、タイムアウト、近端による拒否、通信障害
  • [ ] 音声の有効化/無効化は、以下の場合のみ可能です didActivate / didDeactivate
  • [ ] CallKitのコードは以下で保護されています #if !targetEnvironment(simulator) - enableWebsocketInvites = true シミュレータ用
  • [ ] アプリを完全に終了させた状態で、実機にてテストを実施しました — PushKitのウェイクアップおよびセッション復元におけるレース条件の検証
  • [ ] アプリをバックグラウンド(サスペンド)状態にした実機でテストしました
  • [ ] プッシュ証明書の更新日の追跡 — 証明書の有効期限は1年です

さらに読む