首页/移动开发/callkit-voip
C

callkit-voip

by @dpearson2699v1.0.0
0.0(0)

"Implement VoIP calling with CallKit and PushKit. Use when building incoming/outgoing call flows, registering for VoIP push notifications, configuring CXProvider and CXCallController, handling call actions, coordinating audio sessions, or creating Call Directory extensions for caller ID and call blo

CallKitVoIP IntegrationiOS DevelopmentSwiftReal-time CommunicationGitHub
安装方式
npx skills add dpearson2699/swift-ios-skills --skill callkit-voip
compare_arrows

Before / After 效果对比

1
使用前
1在没有 CallKit 和 PushKit 框架的情况下,VoIP 应用需要自行实现来电和去电的用户界面、音频管理和通知机制。这导致 VoIP 通话体验与系统原生电话功能脱节,用户可能错过重要来电,且在后台运行时受限。
2
3```swift
4// 假设通过自定义 UI 和通知实现 VoIP 来电
5// func showCustomVoIPIncomingCallUI() {
6//     // ... 显示自定义全屏视图
7//     // ... 播放自定义铃声
8//     // ... 处理接听/拒绝逻辑
9// }
10// func sendVoIPPushNotification() {
11//     // ... 发送普通的 APNs 通知,可能被系统静音或延迟
12// }
13print("手动实现 VoIP 通话体验与系统原生电话功能脱节。")
14```
使用后
1使用 CallKit 和 PushKit 框架,开发者可以为 VoIP 应用提供与系统原生电话功能无缝集成的通话体验。CallKit 允许应用使用系统级来电界面、管理通话生命周期和音频会话,而 PushKit 则确保 VoIP 推送通知能及时唤醒应用,提供可靠的来电体验。
2
3```swift
4import CallKit
5import PushKit
6
7class CallManager: NSObject, CXProviderDelegate {
8    let provider: CXProvider
9    let callController = CXCallController()
10
11    override init() {
12        let config = CXProviderConfiguration(localizedName: "我的VoIP应用")
13        config.supportsVideo = true
14        config.maximumCallGroups = 1
15        config.maximumCallsPerCallGroup = 1
16        provider = CXProvider(configuration: config)
17        super.init()
18        provider.setDelegate(self, queue: nil)
19    }
20
21    // MARK: - 模拟来电
22    func reportIncomingCall(uuid: UUID, handle: String) {
23        let update = CXCallUpdate()
24        update.remoteHandle = CXHandle(type: .generic, value: handle)
25        update.hasVideo = true
26
27        provider.reportNewIncomingCall(with: uuid, update: update) { error in
28            if let error = error {
29                print("报告来电失败: \(error.localizedDescription)")
30            } else {
31                print("成功报告来电: \(handle)")
32            }
33        }
34    }
35
36    // MARK: - 拨打去电
37    func startOutgoingCall(handle: String) {
38        let callUUID = UUID()
39        let callHandle = CXHandle(type: .generic, value: handle)
40        let startCallAction = CXStartCallAction(call: callUUID, handle: callHandle)
41        startCallAction.isVideo = true
42
43        let transaction = CXTransaction(action: startCallAction)
44        callController.request(transaction) { error in
45            if let error = error {
46                print("发起去电失败: \(error.localizedDescription)")
47            } else {
48                print("成功发起去电: \(handle)")
49            }
50        }
51    }
52
53    // MARK: - CXProviderDelegate
54    func providerDidReset(_ provider: CXProvider) {
55        print("CallKit Provider 已重置。")
56    }
57
58    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
59        // 实际的 VoIP 呼叫逻辑
60        print("执行发起呼叫操作: \(action.callUUID)")
61        action.fulfill()
62    }
63
64    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
65        // 实际的 VoIP 接听逻辑
66        print("执行接听呼叫操作: \(action.callUUID)")
67        action.fulfill()
68    }
69
70    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
71        // 实际的 VoIP 挂断逻辑
72        print("执行挂断呼叫操作: \(action.callUUID)")
73        action.fulfill()
74    }
75}
76
77// MARK: - PushKit (VoIP Push) 示例
78class PushRegistryDelegate: NSObject, PKPushRegistryDelegate {
79    func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, forType type: PKPushType, completion: @escaping () -> Void) {
80        if type == .voip {
81            // 处理 VoIP 推送,唤醒应用并报告来电
82            print("收到 VoIP 推送,处理来电...")
83            // let callManager = CallManager()
84            // callManager.reportIncomingCall(uuid: UUID(), handle: "123456789")
85        }
86        completion()
87    }
88}
89
90// 使用示例
91// let callManager = CallManager()
92// callManager.reportIncomingCall(uuid: UUID(), handle: "John Doe")
93// callManager.startOutgoingCall(handle: "Jane Smith")
94```

description SKILL.md


name: callkit-voip description: "Implement VoIP calling with CallKit and PushKit. Use when building incoming/outgoing call flows, registering for VoIP push notifications, configuring CXProvider and CXCallController, handling call actions, coordinating audio sessions, or creating Call Directory extensions for caller ID and call blocking."

CallKit + PushKit VoIP

Build VoIP calling features that integrate with the native iOS call UI using CallKit and PushKit. Covers incoming/outgoing call flows, VoIP push registration, audio session coordination, and call directory extensions. Targets Swift 6.2 / iOS 26+.

Contents

Setup

Project Configuration

  1. Enable the Voice over IP background mode in Signing & Capabilities
  2. Add the Push Notifications capability
  3. For call directory extensions, add a Call Directory Extension target

Key Types

TypeRole
CXProviderReports calls to the system, receives call actions
CXCallControllerRequests call actions (start, end, hold, mute)
CXCallUpdateDescribes call metadata (caller name, video, handle)
CXProviderDelegateHandles system call actions and audio session events
PKPushRegistryRegisters for and receives VoIP push notifications

Provider Configuration

Create a single CXProvider at app launch and keep it alive for the app lifetime. Configure it with a CXProviderConfiguration that describes your calling capabilities.

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

Incoming Call Flow

When a VoIP push arrives, report the incoming call to CallKit immediately. The system displays the native call UI. You must report the call before the PushKit completion handler returns -- failure to do so causes the system to terminate your app.

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

Handling the Answer Action

Implement CXProviderDelegate to respond when the user answers:

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

Outgoing Call Flow

Use CXCallController to request an outgoing call. The system routes the request through your 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)")
        }
    }
}

Delegate Methods for Outgoing Calls

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 Registration

Register for VoIP pushes at every app launch. Send the token to your server whenever it changes.

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

Audio Session Coordination

CallKit manages audio session activation/deactivation. Configure your audio session when CallKit tells you to, not before.

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

Call Directory Extension

Create a Call Directory extension to provide caller ID and call blocking.

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

Reload the extension from the main app after data changes:

CXCallDirectoryManager.sharedInstance.reloadExtension(
    withIdentifier: "com.example.app.CallDirectory"
) { error in
    if let error { print("Reload failed: \(error)") }
}

Common Mistakes

DON'T: Fail to report a call on VoIP push receipt

If your PushKit delegate receives a VoIP push but does not call reportNewIncomingCall(with:update:completion:), iOS terminates your app and may stop delivering pushes entirely.

// 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() }
}

DON'T: Start audio before CallKit activates the session

Starting your audio engine before provider(_:didActivate:) causes silence or immediate deactivation. CallKit manages session priority with the system.

// 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
}

DON'T: Forget to call action.fulfill() or action.fail()

Failing to fulfill or fail an action leaves the call in a limbo state and triggers the timeout handler.

// WRONG
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
    connectToServer()
    // Forgot action.fulfill()
}

// CORRECT
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
    connectToServer()
    action.fulfill()
}

DON'T: Ignore push token refresh

The VoIP push token can change at any time. If your server has a stale token, pushes silently fail and incoming calls never arrive.

// 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
}

Review Checklist

  • VoIP background mode enabled in capabilities
  • Single CXProvider instance created at app launch and retained
  • CXProviderDelegate set before reporting any calls
  • Every VoIP push results in a reportNewIncomingCall call
  • action.fulfill() or action.fail() called for every provider delegate action
  • Audio engine started only after provider(_:didActivate:) callback
  • Audio engine stopped in provider(_:didDeactivate:) callback
  • Audio session category set to .playAndRecord with .voiceChat mode
  • VoIP push token sent to server on every didUpdate pushCredentials callback
  • PKPushRegistry created at every app launch (not lazily)
  • Call Directory phone numbers added in ascending E.164 order
  • CXCallUpdate populated with localizedCallerName and remoteHandle
  • Outgoing calls report startedConnectingAt and connectedAt timestamps

References

forum用户评价 (0)

发表评价

效果
易用性
文档
兼容性

暂无评价,来写第一条吧

统计数据

安装量0
评分0.0 / 5.0
版本1.0.0
更新日期2026年3月17日
对比案例1 组

用户评分

0.0(0)
5
0%
4
0%
3
0%
2
0%
1
0%

为此 Skill 评分

0.0

兼容平台

🔧Claude Code

时间线

创建2026年3月17日
最后更新2026年3月17日