プッシュ通知 - 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.
証明書を作成する
- の中で Apple 開発者ポータル → 証明書、識別子、プロファイル, 必ず アプリID には プッシュ通知 機能が有効になっています。
- アンダー 証明書, を追加して Appleプッシュ通知サービスのSSL化(サンドボックスと本番環境) 証明書を作成し、App ID を選択して、CSR をアップロードしてください。
- 生成されたファイルをダウンロードする
.cer.
をエクスポートする .p12
をダブルクリックして .cer ログインキーチェーンにインポートするには。 私の証明書見つける Apple Push Services: <your-bundle-id> — その下に秘密鍵が階層的に配置されている必要があります — その後、右クリック → 輸出 → 個人情報の交換 (.p12). 可能であれば、エクスポート用パスワードは空欄のままにしてください(以下のアップロードに関する注意事項を参照)。
Vonageダッシュボードからアップロードする
- にログインする。 Vonageダッシュボード.
- こちらへ Applications → あなたのアプリケーション (その
application_id(アプリがJWTによる認証を行う相手)。 - を開く。 プッシュ通知を有効にする タブをクリックして、
.p12. - パスワード入力欄は任意であり、ラベルが付けられています お勧めしません (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」
- [ ]
isSandboxflag の用途#if DEBUG— 決してハードコーディングしない - [ ] ログイン時にJWTとリフレッシュトークンがローカルに保存される — セッションの復元には、バックエンドへの呼び出しを必要としないこと
- [ ]
reportNewIncomingCall例外なくすべてのVoIPプッシュを呼び出す — 無効なプッシュ、重複したプッシュ、またはVonage以外のプッシュを含む - [ ] PushKitの補完は内部でのみ呼び出される
reportNewIncomingCallのコールバック — これまで一度も、欠かしたことはない - [ ] セッションの復元とプッシュ処理は並行して行われる -
restoreSessionIfNeeded()そしてprocessCallInvitePushData()個別に発射する - [ ] トークンの登録は、ユーザーログイン時またはトークンのローテーション時のみ行われます — すべての…ではない
createSession - [ ]
unregisterDeviceTokens明示的なログアウトが呼び出された - [ ] すべての通話終端パスはCallKitに報告されます — 遠端による通話切断、キャンセル、タイムアウト、近端による拒否、通信障害
- [ ] 音声の有効化/無効化は、以下の場合のみ可能です
didActivate/didDeactivate - [ ] CallKitのコードは以下で保護されています
#if !targetEnvironment(simulator)-enableWebsocketInvites = trueシミュレータ用 - [ ] アプリを完全に終了させた状態で、実機にてテストを実施しました — PushKitのウェイクアップおよびセッション復元におけるレース条件の検証
- [ ] アプリをバックグラウンド(サスペンド)状態にした実機でテストしました
- [ ] プッシュ証明書の更新日の追跡 — 証明書の有効期限は1年です