mcpField
C
callkit-voip

callkit-voip

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

callkit-voip について

CallKit と PushKit を使った VoIP 通話を実装する iOS 開発者向けの Swift スキルです。ネイティブの通話 UI、プッシュ登録、音声制御、着信識別拡張を扱います。

主な機能

  1. 01着信・発信フローの実装
  2. 02VoIP プッシュ登録とトークン更新
  3. 03CallKit 音声セッションの調整
  4. 04着信識別・ブロック拡張の作成
  5. 05よくあるミスと実装項目の確認

活用シーン

  1. 01iOS アプリにネイティブ通話 UI を追加
  2. 02VoIP プッシュ受信後に着信を報告
  3. 03応答・終了と通話音声を管理
  4. 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 以降です。

内容

セットアップ

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

プロバイダーの設定

アプリの起動時に 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 のタイムスタンプを報告している

参考資料

コメント