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
Audio engine fails to start
Ensure setEngineAvailability(.default) is called inside didActivate callback
Don’t call it before connecting to the room
Audio not working
Verify audio session category is set to .playAndRecord
Check that mode is .voiceChat or .videoChat
CallKit not appearing
Verify Info.plist contains microphone permission
Ensure CXProviderConfiguration is properly configured
App crashes on call
Make sure automatic audio configuration is disabled
Verify engine availability is set to .none before connecting
Best Practices
Disable automatic audio management early : Call this in your app’s initialization
Only enable audio engine in didActivate : Never before
Always disable in didDeactivate : Clean up properly
Handle all call actions : Implement all required CXProviderDelegate methods
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