callkit-voip について
CallKit と PushKit を使った VoIP 通話を実装する iOS 開発者向けの Swift スキルです。ネイティブの通話 UI、プッシュ登録、音声制御、着信識別拡張を扱います。
主な機能
- 01着信・発信フローの実装
- 02VoIP プッシュ登録とトークン更新
- 03CallKit 音声セッションの調整
- 04着信識別・ブロック拡張の作成
- 05よくあるミスと実装項目の確認
活用シーン
- 01iOS アプリにネイティブ通話 UI を追加
- 02VoIP プッシュ受信後に着信を報告
- 03応答・終了と通話音声を管理
- 04着信識別と着信ブロックを実装
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill callkit-voipインストール後、Claude Code で/callkit-voipを入力して読み込まれたか確認します。
同梱ファイルreferences/callkit-patterns.md
詳細
CallKit + PushKit VoIP
CallKit と PushKit を使用して、ネイティブの iOS 通話 UI と連携する VoIP 通話機能を構築します。 着信/発信のフロー、VoIP プッシュの登録、オーディオセッションの調整、通話ディレクトリ拡張機能について説明します。 対象は Swift 6.2 / iOS 26 以降です。
内容
セットアップ
プロジェクトの設定
- Signing & Capabilities で Voice over IP バックグラウンドモードを有効にします
- Push Notifications 機能を追加します
- 通話ディレクトリ拡張機能を使用する場合は、Call Directory Extension ターゲットを追加します
主な型
| 型 | 役割 |
|---|---|
CXProvider | 通話をシステムに報告し、通話アクションを受け取ります |
CXCallController | 通話アクション(開始、終了、保留、ミュート)を要求します |
CXCallUpdate | 通話のメタデータ(発信者名、ビデオ、ハンドル)を記述します |
CXProviderDelegate | システムの通話アクションとオーディオセッションのイベントを処理します |
PKPushRegistry | VoIP プッシュ通知を登録して受信します |
プロバイダーの設定
アプリの起動時に CXProvider を 1 つ作成し、アプリの実行中はそのインスタンスを維持します。通話機能を記述した CXProviderConfiguration を設定します。
import CallKit
/// CXProvider dispatches all delegate calls to the queue passed to `setDelegate(_:queue:)`.
/// The `let` properties are initialized once and never mutated, making this type
/// safe to share across concurrency domains despite @unchecked Sendable.
final class CallManager: NSObject, @unchecked Sendable {
static let shared = CallManager()
let provider: CXProvider
let callController = CXCallController()
private override init() {
let config = CXProviderConfiguration()
config.localizedName = "My VoIP App"
config.supportsVideo = true
config.maximumCallsPerCallGroup = 1
config.maximumCallGroups = 2
config.supportedHandleTypes = [.phoneNumber, .emailAddress]
config.includesCallsInRecents = true
provider = CXProvider(configuration: config)
super.init()
provider.setDelegate(self, queue: nil)
}
}
着信フロー
VoIP プッシュを受信したら、すぐに着信を CallKit に報告します。 システムによってネイティブの通話 UI が表示されます。PushKit の完了ハンドラーが戻る前に通話を報告する必要があります。そうしないと、システムによってアプリが終了されます。
func reportIncomingCall(
uuid: UUID,
handle: String,
hasVideo: Bool
) async throws {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
update.hasVideo = hasVideo
update.localizedCallerName = "Jane Doe"
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
provider.reportNewIncomingCall(
with: uuid,
update: update
) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
応答アクションの処理
ユーザーが応答したときに処理するには、CXProviderDelegate を実装します。
extension CallManager: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
// End all calls, reset audio
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// Configure audio, connect to call server
configureAudioSession()
connectToCallServer(callUUID: action.callUUID)
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
disconnectFromCallServer(callUUID: action.callUUID)
action.fulfill()
}
}
発信フロー
CXCallController を使用して発信を要求します。システムはその要求を CXProviderDelegate 経由でルーティングします。
func startOutgoingCall(handle: String, hasVideo: Bool) {
let uuid = UUID()
let handle = CXHandle(type: .phoneNumber, value: handle)
let startAction = CXStartCallAction(call: uuid, handle: handle)
startAction.isVideo = hasVideo
let transaction = CXTransaction(action: startAction)
callController.request(transaction) { error in
if let error {
print("Failed to start call: \(error)")
}
}
}
発信のデリゲートメソッド
extension CallManager {
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
configureAudioSession()
// Begin connecting to server
provider.reportOutgoingCall(
with: action.callUUID,
startedConnectingAt: Date()
)
connectToServer(callUUID: action.callUUID) {
provider.reportOutgoingCall(
with: action.callUUID,
connectedAt: Date()
)
}
action.fulfill()
}
}
PushKit VoIP の登録
アプリを起動するたびに VoIP プッシュを登録します。トークンが変更されたら、サーバーに送信します。
import PushKit
final class PushManager: NSObject, PKPushRegistryDelegate {
let registry: PKPushRegistry
override init() {
registry = PKPushRegistry(queue: .main)
super.init()
registry.delegate = self
registry.desiredPushTypes = [.voIP]
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
let token = pushCredentials.token
.map { String(format: "%02x", $0) }
.joined()
// Send token to your server
sendTokenToServer(token)
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
let callUUID = UUID()
let handle = payload.dictionaryPayload["handle"] as? String ?? "Unknown"
Task {
do {
try await CallManager.shared.reportIncomingCall(
uuid: callUUID,
handle: handle,
hasVideo: false
)
} catch {
// Call was filtered by DND or block list
}
completion()
}
}
}
オーディオセッションの調整
CallKit はオーディオセッションの有効化と無効化を管理します。オーディオセッションを設定するのは、CallKit から指示されたときにしてください。それより前に設定してはいけません。
extension CallManager {
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
// Audio session is now active -- start audio engine / WebRTC
startAudioEngine()
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
// Audio session deactivated -- stop audio engine
stopAudioEngine()
}
func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
} catch {
print("Audio session configuration failed: \(error)")
}
}
}
通話ディレクトリ拡張機能
発信者 ID と着信拒否機能を提供するには、通話ディレクトリ拡張機能を作成します。
import CallKit
final class CallDirectoryHandler: CXCallDirectoryProvider {
override func beginRequest(
with context: CXCallDirectoryExtensionContext
) {
if context.isIncremental {
addOrRemoveIncrementalEntries(to: context)
} else {
addAllEntries(to: context)
}
context.completeRequest()
}
private func addAllEntries(
to context: CXCallDirectoryExtensionContext
) {
// Phone numbers must be in ascending order (E.164 format as Int64)
let blockedNumbers: [CXCallDirectoryPhoneNumber] = [
18005551234, 18005555678
]
for number in blockedNumbers {
context.addBlockingEntry(
withNextSequentialPhoneNumber: number
)
}
let identifiedNumbers: [(CXCallDirectoryPhoneNumber, String)] = [
(18005551111, "Local Pizza"),
(18005552222, "Dentist Office")
]
for (number, label) in identifiedNumbers {
context.addIdentificationEntry(
withNextSequentialPhoneNumber: number,
label: label
)
}
}
}
データの変更後、メインアプリから拡張機能を再読み込みします。
CXCallDirectoryManager.sharedInstance.reloadExtension(
withIdentifier: "com.example.app.CallDirectory"
) { error in
if let error { print("Reload failed: \(error)") }
}
よくある間違い
してはいけないこと:VoIP プッシュ受信時に通話を報告しない
PushKit デリゲートが VoIP プッシュを受信したにもかかわらず、
reportNewIncomingCall(with:update:completion:) を呼び出さない場合、iOS はアプリを終了し、プッシュの配信を完全に停止することもあります。
// WRONG -- no call reported
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
// Just process data, no call reported
processPayload(payload)
completion()
}
// CORRECT -- always report a call
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
let uuid = UUID()
provider.reportNewIncomingCall(
with: uuid, update: makeUpdate(from: payload)
) { _ in completion() }
}
してはいけないこと:CallKit がセッションを有効にする前にオーディオを開始する
provider(_:didActivate:) が呼び出される前にオーディオエンジンを開始すると、無音になったり、セッションがすぐに無効化されたりします。CallKit はシステムとのセッションの優先順位を管理します。
// WRONG
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
startAudioEngine() // Too early -- session not active yet
action.fulfill()
}
// CORRECT
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
prepareAudioEngine() // Prepare, but do not start
action.fulfill()
}
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
startAudioEngine() // Now it's safe
}
してはいけないこと:action.fulfill() または action.fail() の呼び出しを忘れる
アクションを fulfill または fail しないと、通話が中途半端な状態になり、タイムアウトハンドラーが実行されます。
// WRONG
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
// Forgot action.fulfill()
}
// CORRECT
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
connectToServer()
action.fulfill()
}
してはいけないこと:プッシュトークンの更新を無視する
VoIP プッシュトークンはいつでも変更される可能性があります。サーバーに古いトークンが保存されていると、プッシュは通知なく失敗し、着信が届かなくなります。
// WRONG -- only send token once at first registration
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
// Token saved locally but never updated on server
}
// CORRECT -- always update server
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
sendTokenToServer(token) // Always send to server
}
レビューチェックリスト
- 機能で VoIP バックグラウンドモードが有効になっている
- アプリ起動時に単一の
CXProviderインスタンスを作成し、保持している - 通話を報告する前に
CXProviderDelegateを設定している - すべての VoIP プッシュで
reportNewIncomingCallを呼び出している - すべてのプロバイダーデリゲートアクションで
action.fulfill()またはaction.fail()を呼び出している -
provider(_:didActivate:)コールバックの後にのみオーディオエンジンを起動している -
provider(_:didDeactivate:)コールバックでオーディオエンジンを停止している - オーディオセッションのカテゴリを
.playAndRecord、モードを.voiceChatに設定している -
didUpdate pushCredentialsコールバックのたびに VoIP プッシュトークンをサーバーに送信している - アプリ起動のたびに
PKPushRegistryを作成している(遅延作成ではない) - Call Directory の電話番号を昇順の E.164 形式で追加している
-
CXCallUpdateにlocalizedCallerNameとremoteHandleを設定している - 発信通話で
startedConnectingAtとconnectedAtのタイムスタンプを報告している
参考資料
- 拡張パターン(保留、ミュート、グループ通話、デリゲートのライフサイクル):
references/callkit-patterns.md - CallKit フレームワーク
- CXProvider
- CXCallController
- CXCallAction
- CXCallUpdate
- CXProviderConfiguration
- CXProviderDelegate
- PKPushRegistry
- PKPushRegistryDelegate
- CXCallDirectoryProvider
- VoIP 通話の発信と着信
- PushKit からの VoIP 通知への応答