Skip to main content
CallKit integration allows your LiveKit app to provide native iOS calling experiences with system-level call management.

Overview

CallKit provides:
  • Native incoming call UI
  • Lock screen call notifications
  • Integration with Phone app and call history
  • System audio routing
  • Bluetooth and CarPlay support
CallKit is only available on iOS (not on tvOS or visionOS).

Important: Audio Engine Coordination

When integrating with CallKit, proper timing and coordination between AVAudioSession and the SDK’s audio engine is crucial.

Why This Matters

CallKit controls when the audio session can be activated:
  • Audio session must be activated only within CallKit’s provider(_:didActivate:) callback
  • Audio session must be deactivated in provider(_:didDeactivate:) callback
  • Starting the audio engine outside this window will cause errors

Setup Steps

1. Disable Automatic Audio Management

Disable the SDK’s automatic AVAudioSession configuration and prevent the audio engine from starting outside CallKit’s window:
// As early as possible, before connecting to a Room
AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
try AudioManager.shared.setEngineAvailability(.none)
Call this before connecting to a room to prevent the audio engine from starting prematurely.

2. Implement CXProviderDelegate

Coordinate audio engine availability with CallKit in your CXProviderDelegate implementation:
import CallKit
import LiveKit

class CallManager: NSObject, CXProviderDelegate {
    let provider: CXProvider
    let callController = CXCallController()
    
    override init() {
        let configuration = CXProviderConfiguration(localizedName: "My App")
        configuration.supportsVideo = true
        configuration.maximumCallGroups = 1
        configuration.maximumCallsPerCallGroup = 1
        configuration.supportedHandleTypes = [.generic]
        
        provider = CXProvider(configuration: configuration)
        
        super.init()
        
        provider.setDelegate(self, queue: nil)
    }
    
    // MARK: - CXProviderDelegate
    
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        // CallKit has activated the audio session
        // Now it's safe to configure it and enable the audio engine
        do {
            try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers])
            try AudioManager.shared.setEngineAvailability(.default)
        } catch {
            print("Failed to configure audio session: \(error)")
        }
    }
    
    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        // CallKit has deactivated the audio session
        // Disable the audio engine
        do {
            try AudioManager.shared.setEngineAvailability(.none)
        } catch {
            print("Failed to disable audio engine: \(error)")
        }
    }
    
    func providerDidReset(_ provider: CXProvider) {
        // Handle provider reset (e.g., cleanup)
        print("Provider did reset")
    }
}

3. Report Incoming Call

Report incoming calls to CallKit:
func reportIncomingCall(uuid: UUID, roomName: String, completion: @escaping (Error?) -> Void) {
    let update = CXCallUpdate()
    update.remoteHandle = CXHandle(type: .generic, value: roomName)
    update.hasVideo = true
    update.supportsHolding = false
    update.supportsGrouping = false
    update.supportsUngrouping = false
    
    provider.reportNewIncomingCall(with: uuid, update: update) { error in
        completion(error)
    }
}

4. Handle Call Actions

Implement call action handlers:
extension CallManager {
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        // User answered the call
        Task {
            do {
                // Connect to LiveKit room
                try await connectToRoom()
                action.fulfill()
            } catch {
                action.fail()
            }
        }
    }
    
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        // User ended the call
        Task {
            do {
                // Disconnect from LiveKit room
                try await disconnectFromRoom()
                action.fulfill()
            } catch {
                action.fail()
            }
        }
    }
    
    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        // User started an outgoing call
        Task {
            do {
                // Connect to LiveKit room
                try await connectToRoom()
                action.fulfill()
            } catch {
                action.fail()
            }
        }
    }
    
    func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
        // User toggled mute
        Task {
            do {
                try await room.localParticipant.setMicrophone(enabled: !action.isMuted)
                action.fulfill()
            } catch {
                action.fail()
            }
        }
    }
}

5. Start Outgoing Call

Initiate outgoing calls:
func startCall(uuid: UUID, roomName: String) {
    let handle = CXHandle(type: .generic, value: roomName)
    let startCallAction = CXStartCallAction(call: uuid, handle: handle)
    startCallAction.isVideo = true
    
    let transaction = CXTransaction(action: startCallAction)
    
    callController.request(transaction) { error in
        if let error = error {
            print("Failed to start call: \(error)")
        }
    }
}

6. End Call

End active calls:
func endCall(uuid: UUID) {
    let endCallAction = CXEndCallAction(call: uuid)
    let transaction = CXTransaction(action: endCallAction)
    
    callController.request(transaction) { error in
        if let error = error {
            print("Failed to end call: \(error)")
        }
    }
}

Complete Example

Here’s a complete integration example:
import UIKit
import CallKit
import LiveKit

class CallManager: NSObject, CXProviderDelegate {
    static let shared = CallManager()
    
    let provider: CXProvider
    let callController = CXCallController()
    var room: Room?
    var currentCallUUID: UUID?
    
    override init() {
        // Configure CallKit provider
        let configuration = CXProviderConfiguration(localizedName: "My App")
        configuration.supportsVideo = true
        configuration.maximumCallGroups = 1
        configuration.maximumCallsPerCallGroup = 1
        configuration.supportedHandleTypes = [.generic]
        configuration.iconTemplateImageData = UIImage(named: "AppIcon")?.pngData()
        
        provider = CXProvider(configuration: configuration)
        
        super.init()
        
        provider.setDelegate(self, queue: nil)
        
        // Disable automatic audio management
        AudioManager.shared.audioSession.isAutomaticConfigurationEnabled = false
        try? AudioManager.shared.setEngineAvailability(.none)
    }
    
    // MARK: - Call Management
    
    func startCall(to roomName: String, url: String, token: String) {
        let uuid = UUID()
        currentCallUUID = uuid
        
        let handle = CXHandle(type: .generic, value: roomName)
        let startCallAction = CXStartCallAction(call: uuid, handle: handle)
        startCallAction.isVideo = true
        
        let transaction = CXTransaction(action: startCallAction)
        
        callController.request(transaction) { error in
            if let error = error {
                print("Failed to request start call transaction: \(error)")
            }
        }
    }
    
    func endCall() {
        guard let uuid = currentCallUUID else { return }
        
        let endCallAction = CXEndCallAction(call: uuid)
        let transaction = CXTransaction(action: endCallAction)
        
        callController.request(transaction) { error in
            if let error = error {
                print("Failed to request end call transaction: \(error)")
            }
        }
    }
    
    func reportIncomingCall(uuid: UUID, roomName: String, completion: @escaping (Error?) -> Void) {
        currentCallUUID = uuid
        
        let update = CXCallUpdate()
        update.remoteHandle = CXHandle(type: .generic, value: roomName)
        update.hasVideo = true
        update.supportsHolding = false
        update.supportsGrouping = false
        update.supportsUngrouping = false
        
        provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
    }
    
    // MARK: - CXProviderDelegate
    
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        print("Audio session activated by CallKit")
        
        do {
            // Configure audio session
            try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers])
            
            // Enable audio engine
            try AudioManager.shared.setEngineAvailability(.default)
            
            print("Audio engine enabled")
        } catch {
            print("Failed to configure audio: \(error)")
        }
    }
    
    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        print("Audio session deactivated by CallKit")
        
        do {
            // Disable audio engine
            try AudioManager.shared.setEngineAvailability(.none)
            print("Audio engine disabled")
        } catch {
            print("Failed to disable audio engine: \(error)")
        }
    }
    
    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        print("Starting call")
        
        // Report that call is connecting
        provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date())
        
        Task {
            do {
                // Connect to LiveKit room
                // (Implement your connection logic here)
                
                // Report that call connected
                provider.reportOutgoingCall(with: action.callUUID, connectedAt: Date())
                action.fulfill()
            } catch {
                action.fail()
            }
        }
    }
    
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        print("Answering call")
        
        Task {
            do {
                // Connect to LiveKit room
                // (Implement your connection logic here)
                
                action.fulfill()
            } catch {
                action.fail()
            }
        }
    }
    
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        print("Ending call")
        
        Task {
            do {
                // Disconnect from LiveKit room
                await room?.disconnect()
                room = nil
                currentCallUUID = nil
                
                action.fulfill()
            } catch {
                action.fail()
            }
        }
    }
    
    func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
        print("Setting mute: \(action.isMuted)")
        
        Task {
            do {
                try await room?.localParticipant.setMicrophone(enabled: !action.isMuted)
                action.fulfill()
            } catch {
                action.fail()
            }
        }
    }
    
    func providerDidReset(_ provider: CXProvider) {
        print("Provider reset")
        
        Task {
            await room?.disconnect()
            room = nil
            currentCallUUID = nil
        }
    }
}

Audio Session Configuration

Recommended audio session configuration for CallKit:
try audioSession.setCategory(
    .playAndRecord,
    mode: .voiceChat,  // or .videoChat for video calls
    options: [.mixWithOthers]
)

Audio Session Modes

  • .voiceChat: Optimized for voice calls
  • .videoChat: Optimized for video calls with audio
See AudioSessionConfiguration.swift:25 for available configurations.

Testing

Test Incoming Calls

// Simulate an incoming call
let uuid = UUID()
CallManager.shared.reportIncomingCall(uuid: uuid, roomName: "Test Room") { error in
    if let error = error {
        print("Error reporting call: \(error)")
    } else {
        print("Incoming call reported successfully")
    }
}

Test Outgoing Calls

// Start an outgoing call
CallManager.shared.startCall(
    to: "Test Room",
    url: "wss://your-server.com",
    token: "your-token"
)

Troubleshooting

Common Issues

  1. Audio engine fails to start
    • Ensure setEngineAvailability(.default) is called inside didActivate callback
    • Don’t call it before connecting to the room
  2. Audio not working
    • Verify audio session category is set to .playAndRecord
    • Check that mode is .voiceChat or .videoChat
  3. CallKit not appearing
    • Verify Info.plist contains microphone permission
    • Ensure CXProviderConfiguration is properly configured
  4. App crashes on call
    • Make sure automatic audio configuration is disabled
    • Verify engine availability is set to .none before connecting

Best Practices

  1. Disable automatic audio management early: Call this in your app’s initialization
  2. Only enable audio engine in didActivate: Never before
  3. Always disable in didDeactivate: Clean up properly
  4. Handle all call actions: Implement all required CXProviderDelegate methods
  5. Test thoroughly: Test incoming, outgoing, and interrupted calls

Additional Resources

Next Steps

iOS Platform

Learn more about iOS-specific features

Audio Management

Deep dive into audio configuration

Build docs developers (and LLMs) love