mcpField
C
callkit

callkit

github.com/dpearson2699開発1.2k3.1k 回インストール13 週間前に更新
リポジトリ

callkit について

iOS 開発者向けの CallKit 実装ガイドです。VoIP の通話フロー、PushKit 登録、オーディオ制御、着信拒否・識別拡張を扱い、報告要件や統合時の注意点も説明します。

主な機能

  1. 01着信・発信フロー
  2. 02VoIP プッシュの登録と処理
  3. 03オーディオセッションの調整
  4. 04着信識別拡張の管理
  5. 05統合時の確認項目

活用シーン

  1. 01iOS アプリにネイティブの着信 UI を追加
  2. 02PushKit の VoIP 通知とトークン更新を処理
  3. 03通話中のオーディオ開始・終了を制御

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill callkit

インストール後、Claude Code で/callkitを入力して読み込まれたか確認します。

同梱ファイルevals/evals.jsonreferences/callkit-patterns.md

詳細

CallKit

CallKit と PushKit を使用して、iOS ネイティブの通話 UI と連携する VoIP 通話機能を構築します。着信/発信のフロー、VoIP プッシュ登録、オーディオセッションの連携、通話ディレクトリ拡張機能を扱います。

内容

セットアップ

プロジェクトの設定
  1. Signing & Capabilities でバックグラウンドモードの Voice over IP を有効にします
  2. Push Notifications 機能を追加します
  3. 通話ディレクトリ拡張機能を使用する場合は、Call Directory Extension ターゲットを追加します
主な型
型役割
CXProviderシステムに通話を通知し、通話アクションを受け取ります
CXCallController通話アクション(開始、終了、保留、ミュート)を要求します
CXCallUpdate通話メタデータ(発信者名、ビデオ、ハンドル)を記述します
CXProviderDelegateシステムの通話アクションとオーディオセッションイベントを処理します
PKPushRegistryVoIP プッシュを登録し、受信します
PKVoIPPushMetadataVoIP プッシュを報告する必要があるかどうかを示す、iOS 26.4 以降のメタデータです

プロバイダーの設定

アプリの起動時に 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) {
        // Prepare audio, then fulfill only after the call is actually ready
        configureAudioSession()
        connectToCallServer(callUUID: action.callUUID) { success in
            if success {
                action.fulfill()
            } else {
                provider.reportCall(
                    with: action.callUUID,
                    endedAt: Date(),
                    reason: .failed
                )
                action.fail()
            }
        }
    }

    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 プッシュを登録し、トークンの変更をサーバーに送信します。iOS 13 SDK 以降でビルドされたアプリでは、報告が必要な VoIP 通話プッシュをすべて、PushKit の完了ハンドラーが呼び出される前に CallKit を使って報告する必要があります。このフレームワークを使ってビルドされたアプリでは、LiveCommunicationKit を使用します。iOS 26.4 以降では、PKVoIPPushMetadata.mustReport が判定基準となります。true の場合は完了前に報告し、false の場合は CallKit または LiveCommunicationKit による報告は不要です。完了前に必要な報告を行わないとアプリが終了されることがあり、失敗が繰り返されると VoIP プッシュの配信が停止される可能性があります。

経路報告の判定完了のタイミング
iOS 26.4 以降 mustReport == trueCallKit または LiveCommunicationKit で報告報告コールバックの後
iOS 26.4 以降 mustReport == falseCallKit/LiveCommunicationKit による報告は不要ローカル処理の後
古いデリゲートiOS 13 SDK 以降では、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)
    }

    @available(iOS 26.4, *)
    func pushRegistry(
        _ registry: PKPushRegistry,
        didReceiveIncomingVoIPPushWith payload: PKPushPayload,
        metadata: PKVoIPPushMetadata,
        withCompletionHandler completion: @escaping @Sendable () -> Void
    ) {
        guard metadata.mustReport else {
            completion()
            return
        }
        handleIncomingVoIPPush(payload, completion: completion)
    }

    // Keep the older callback for iOS 26.0-26.3 and older deployment targets.
    func pushRegistry(
        _ registry: PKPushRegistry,
        didReceiveIncomingPushWith payload: PKPushPayload,
        for type: PKPushType,
        completion: @escaping () -> Void
    ) {
        guard type == .voIP else {
            completion()
            return
        }

        handleIncomingVoIPPush(payload, completion: completion)
    }

    private func handleIncomingVoIPPush(
        _ payload: PKPushPayload,
        completion: @escaping () -> Void
    ) {
        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()
        }
    }
}

サーバー側の VoIP プッシュには短い有効期間を設定してください。apns-expiration は 0 または数秒のみに設定します。最初のプッシュでアプリを起動した後は、ハングアップや通話詳細の変更を、追加の VoIP プッシュではなくアプリとサーバー間の接続を通じて送信してください。

オーディオセッションの連携

オーディオの有効化タイミングは CallKit が管理します。メディアを開始するのは provider(_:didActivate:) の中だけにし、provider(_:didDeactivate:) やリセット処理では停止または破棄します。

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)")
        }
    }
}

通話ディレクトリ拡張機能とマネージャー

通話ごとの API 検索ではなく、事前読み込みした発信者 ID の表示/ブロックに Call Directory を使用します。 拡張機能は beginRequest(with:) でソート済みの一括データを読み込みます。メインアプリは CXCallDirectoryManager を使用して有効状態を確認し、無効の場合は「着信拒否設定と発信者ID」設定を開き、 データの変更後に再読み込みします。 CXCallDirectoryPhoneNumber は、書式設定済みの文字列ではなく、国番号と数字を昇順で保存します (例:18005551234)。

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
    ) {
        // Country code + digits, sorted in ascending order
        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
            )
        }
    }
}
メインアプリのマネージャー:状態、設定、再読み込み
let manager = CXCallDirectoryManager.sharedInstance
manager.getEnabledStatusForExtension(withIdentifier: extensionID) { status, _ in
    guard status == .enabled else {
        manager.openSettings { _ in } // Call Blocking & Identification
        return
    }
    manager.reloadExtension(withIdentifier: extensionID) { _ in }
}

拡張機能が有効だと決めつける前に getEnabledStatusForExtension(...) を確認し、無効の場合は 「着信拒否設定と発信者ID」のために openSettings(...) を使用し、データの変更後に reloadExtension(...) を呼び出します。APNs 認証キーのローテーションと通常のリモート通知の設定は push-notifications に振り分けてください。

よくある間違い

間違い修正方法
必須の VoIP プッシュをデータのみとして扱っているバージョン固有の報告ルールを適用し、完了前に報告してから PushKit の完了ハンドラーを呼び出します。
メディア/サーバーの準備が整う前に応答アクションを完了している接続中は保留し、準備が整った時点で完了するか、失敗させて .failed を報告します。
provider(_:didActivate:) より前にメディアを開始している必要に応じて事前に準備しますが、開始するのはアクティブ化後のみとし、非アクティブ化/リセット時に停止します。
アクションの処理経路で fulfill() または fail() が呼び出されない成功、キャンセル、タイムアウト、ネットワークエラーの各経路で、終了アクションを 1 つ実行するようにします。
トークンの更新を無視しているdidUpdate pushCredentials で受け取るすべてのトークンをサーバーに送信します。
Call Directory が通話ごとにネットワーク通信を行っているソート済みのエントリーを事前読み込みして、拡張機能を再読み込みします。

レビューチェックリスト

  • ケイパビリティで VoIP バックグラウンドモードが有効になっている
  • アプリの起動時に単一の CXProvider インスタンスを作成し、保持している
  • 通話を報告する前に CXProviderDelegate を設定している
  • iOS 26.4 以降の PushKit 経路では、mustReport が true の場合に報告し、false の場合は省略できる
  • iOS 13 SDK 以降では、PushKit の VoIP 通話プッシュを完了前に CallKit に報告している
  • VoIP APNs リクエストの apns-expiration が 0 または数秒以内になっている
  • 最初のプッシュ後の切断や詳細の更新には、アプリサーバーへの接続を使用している
  • プロバイダーデリゲートのすべてのアクションで action.fulfill() または action.fail() を呼び出している
  • CXAnswerCallAction を完了するのは、通話サーバー/メディアへの接続が準備できた後のみ
  • オーディオエンジンを開始するのは provider(_:didActivate:) コールバック後のみ
  • provider(_:didDeactivate:) コールバック内でオーディオエンジンを停止している
  • オーディオセッションのカテゴリを .playAndRecord、モードを .voiceChat に設定している
  • didUpdate pushCredentials コールバックのたびに VoIP プッシュトークンをサーバーに送信している
  • PKPushRegistry をアプリ起動のたびに作成している(遅延作成ではない)
  • Call Directory のデータは事前読み込みしており、着信ごとに取得していない
  • CXCallDirectoryPhoneNumber が国番号と数字として説明されている
  • CXCallDirectoryManager に状態確認、再読み込み、設定を開く API が記載されている
  • CXCallUpdate に localizedCallerName と remoteHandle を設定している
  • 発信通話で startedConnectingAt と connectedAt のタイムスタンプを報告している
  • iOS 26 の通話翻訳では、ミュート中も上り音声をアクティブに保っている
  • 暗号化されたメタデータのフィルタリングについて、通知サービス拡張のエンタイトルメントに言及している

参考資料

コメント