Make and receive an audio call with CallKit
This guide shows you how to make an audio call in your iOS app. We assume you've already set up your iOS app with the In-app Calling iOS SDK. If you haven't already, create an app first.
Please, check our reference application for full implementation and additional functionalities, which is available on GitHub.
Note:
Starting from iOS13, both incoming and outgoing calls should be reported and handled via CallKit. For further reading about the intentions and usage of CallKit integration, refer to Push notifications documentation.
Making an audio call
First, create a few properties in theSinchCallKitService
class that will be used to work with CallKit: a CXCallController
and a CXProvider
.final class SinchCallKitService: NSObject {
// An object interacts with calls by performing actions and observing calls.
private var callController: CXCallController!
// Represents telephony provider.
private var provider: CXProvider!
init(delegate: CXProviderDelegate) {
super.init()
self.callController = CXCallController()
let configuration = CXProviderConfiguration()
configuration.supportedHandleTypes = [.generic]
configuration.supportsVideo = true
configuration.ringtoneSound = Ringtone.incoming
self.provider = CXProvider(configuration: configuration)
self.provider.setDelegate(delegate, queue: nil)
}
}
Next, add a
CallRegistry
object in SinchClientMediator
, to map Sinch's call IDs to CallKit's callId. For example implementation of CallRegistry
, please refer to the
CallRegistry.swift
file in Sinch's Swift sample app, bundled together with Swift SDK. Also, set the
SinchClientMediator
as the delegate of the CXProvider
via SinchCallKitService
so that it can process incoming CallKit events.final class SinchClientMediator: NSObject {
// Maps Sinch's call Ids to CallKit's call Id.
private let callRegistry = CallRegistry()
private let callKitService: SinchCallKitService?
init() {
super.init()
...
// Set SinchClientMediator as delegate of CXProviderDelegate.
self.callKitService = SinchCallKitService(delegate: self)
}
}
Next, add
SinchCallKitService.call(userId:uuid:callback:)
method, which requests the initiation of a new CallKit call.Note:
SinchClient
is still not involved.final class SinchCallKitService: NSObject {
...
func call(userId: String, uuid: UUID, with completion: @escaping (Error?) -> Void) {
let handle = CXHandle(type: .generic, value: userId)
let startCallAction = CXStartCallAction(call: uuid, handle: handle)
let startCallTransaction = CXTransaction(action: startCallAction)
self.callController.request(startCallTransaction, completion: completion)
}
}
And in
SinchClientMediator
we can implement the call(destination:with:)
method, which will be used to initiate a new CallKit call:final class SinchClientMediator: NSObject {
...
func call(destination userId: String, with callback: @escaping CallStartedCallback) {
let uuid = UUID()
callStartedCallback = callback
let errorCompletion: (Error?) -> Void = { [weak self] error in
guard let self = self, let error = error else { return }
DispatchQueue.main.async {
self.callStartedCallback(.failure(error))
self.callStartedCallback = nil
}
}
self.callKitService?.call(userId: userId, uuid: uuid, with: errorCompletion)
}
}
SinchClientMediator
by implementing the callbacks of CXProviderDelegate
protocol. At the moment, we're interested in implementing only a subset of the
CXProviderDelegate
methods:- notification of
AVAudioSession
events to theSinchClient
, to change audio session active state
extension SinchClientMediator: CXProviderDelegate {
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
self.sinchClient?.callClient.didActivate(audioSession: audioSession)
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
self.sinchClient?.callClient.didDeactivate(audioSession: audioSession)
}
}
- After a call start request succeeds, the
provider(_:perform:)
callback (forCXStartCallAction
) will be invoked. At that point, a SinchCall can be created via theSinchCallClient
(the entry point for calling functionality). If the call starts successfully, thecallId
should be stored in theCallRegistry
andSinchClientMediator
set as the call’s delegate. - To end all ongoing calls if the provider resets, implement the
providerDidReset(_:)
callback ofCXProviderDelegate
.
extension SinchClientMediator: CXProviderDelegate {
...
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
defer { callStartedCallback = nil }
guard let callClient = self.sinchClient?.callClient else {
action.fail()
callStartedCallback?(.failure(CallError.noClient))
return
}
let recipientIdentifier = action.handle.value
// Actual start of Sinch call.
let callResult = callClient.callUser(withId: recipientIdentifier)
switch callResult {
case .success(let call):
self.callRegistry.addSinchCall(call)
self.callRegistry.map(uuid: action.callUUID, to: call.callId)
// Assigning the delegate of the newly created SinchCall
// to track call establishment, progress and ending.
call.delegate = self
action.fulfill()
case .failure(let error):
// Report that unable to start call.
action.fail()
}
callStartedCallback?(callResult)
}
func providerDidReset(_ provider: CXProvider) {
// End any ongoing calls if the provider resets, and remove
// them from the app's list of calls because they are no longer valid.
self.callRegistry.activeSinchCalls.forEach { $0.hangup() }
// Remove all calls from the app's list of calls.
self.callRegistry.reset()
}
}
Outgoing call UI
In this app,SinchClientMediator
is set as the delegate for all SinchCall
instances (to handle CallKit integration), but it also forwards call events to the AudioCallViewController which manages the call UI. In this implementation it is accomplished by implementing an Observer pattern, where
AudioCallViewController
is the observer of SinchClientMediator
. SinchClientMediator
should conform to SinchCallDelegate
to listen to call event action and pass it to other observers.protocol SinchClientMediatorObserver: SinchCallDelegate {}
final class SinchClientMediator: NSObject {
...
// List of observers who listens for call actions through the whole application.
private var observers: [SinchClientMediatorObserver?] = []
}
extension SinchClientMediator: SinchCallDelegate {
// For each observer callback action will be called.
private func fanoutDelegateCall(_ callback:
(_ observer: SinchClientMediatorObserver?) -> Void) {
observers.removeAll(where: { $0 === nil })
observers.forEach { callback($0) }
}
func addObserver(_ observer: SinchClientMediatorObserver) {
guard observers.firstIndex(where: { $0 === observer }) == nil else { return }
observers.append(observer)
}
func removeObserver(_ observer: SinchClientMediatorObserver) {
guard let index = observers.firstIndex(where: { $0 === observer }) else { return }
observers.remove(at: index)
}
}
Add methods for reporting outgoing calls and call end to CallKit in
SinchCallKitService
:final class SinchCallKitService: NSObject {
...
func reportOutgoingCallProgressed(uuid: UUID, time: Date?) {
self.provider.reportOutgoingCall(with: uuid, startedConnectingAt: time)
}
func reportOutgoingCallAnswered(uuid: UUID, time: Date?) {
self.provider.reportOutgoingCall(with: uuid, connectedAt: time)
}
func reportCallEnd(uuid: UUID, time: Date?, endCause: EndCause) {
self.provider.reportCall(with: uuid, endedAt: time, reason: endCause.callEndReason)
}
}
Implement the relevant
SinchCallDelegate
methods to handle call progress, ringing, answer, establishment and ending. Make sure to notify observers whenever an action occurs:callDidProgress(_ call:)
to notify observers that call started and in progresscallDidRing(_ call:)
to notify observers that call is ringingcallDidEstablish(_ call:)
to notify observers that call was establishedcallDidAnswer(_ call:)
to notify observers that call was answeredcallDidEnd(_ call:)
to notify observers that call has ended
As soon as an outgoing call progresses or is answered, report that event to CallKit. Likewise, when the call ends, report the call termination to CallKit as well.
// By implementing this we can handle call establishment, progress
// and ending wherever observers were added.
extension SinchClientMediator: SinchCallDelegate {
...
func callDidProgress(_ call: SinchCall) {
if let uuid = self.callRegistry.uuid(from: call.callId), call.direction == .outgoing {
self.callKitService?.reportOutgoingCallProgressed(uuid: uuid,
time: call.details.startedTime)
}
self.fanoutDelegateCall { $0?.callDidProgress(call) }
}
func callDidRing(_ call: SinchCall) {
self.fanoutDelegateCall { $0?.callDidRing(call) }
}
func callDidAnswer(_ call: SinchRTC.SinchCall) {
if let uuid = self.callRegistry.uuid(from: call.callId), call.direction == .outgoing {
self.callKitService?.reportOutgoingCallAnswered(uuid: uuid,
time: call.details.establishedTime)
}
self.fanoutDelegateCall { $0?.callDidAnswer(call) }
}
func callDidEstablish(_ call: SinchCall) {
self.fanoutDelegateCall { $0?.callDidEstablish(call) }
}
func callDidEnd(_ call: SinchCall) {
defer { call.delegate = nil }
if let uuid = self.callRegistry.uuid(from: call.callId) {
self.callKitService?.reportCallEnd(uuid: uuid,
time: call.details.endedTime,
endCause: call.details.endCause)
}
self.callRegistry.removeSinchCall(withId: call.callId)
if call.details.endCause == .error {
// Report call ended with error.
} else {
// Report call ended with success.
}
self.fanoutDelegateCall { $0?.callDidEnd(call) }
}
}
Next, be sure to add
AudioCallViewController
as an observer when its view loads, and extend that class to conform to the SinchClientMediatorObserver
protocol.final class AudioCallViewController: UIViewController {
var sinchClientMediator: SinchClientMediator?
// AudioCallViewController holds the call object to be able to end the call.
var call: SinchCall?
override func viewDidLoad() {
super.viewDidLoad()
// Add observer to track call actions.
sinchClientMediator?.addObserver(self)
}
}
extension AudioCallViewController: SinchClientMediatorObserver {
func callDidProgress(_ call: SinchCall) {
self.callInfoLabel.text = "Initiating..."
}
func callDidRing(_ call: SinchCall) {
self.callInfoLabel.text = "Ringing..."
let audio = Ringtone.ringback
let path = Bundle.main.path(forResource: audio, ofType: nil)
do {
try sinchClientMediator?.sinchClient?
.audioController
.startPlayingSoundFile(withPath: path, looping: true)
} catch {
// Report error if sound file was not played.
}
}
func callDidAnswer(_ call: SinchRTC.SinchCall) {
self.callInfoLabel.text = "Connecting..."
// Stop playing sound when call was answered.
sinchClientMediator?.sinchClient?
.audioController
.stopPlayingSoundFile()
}
func callDidEstablish(_ call: SinchCall) {
// UI setup.
guard timer == nil else { return }
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { _ in
let establishedTime = call.details.establishedTime ?? Date()
let interval = Int(Date().timeIntervalSince(establishedTime))
let minutes = Int(interval / 60).timePresentation
let seconds = Int(interval % 60).timePresentation
// Displays some call information when call was established.
self.duration = "\(minutes):\(seconds)"
self.callInfoLabel.text = "\(self.duration) with \(call.remoteUserId)"
})
}
func callDidEnd(_ call: SinchCall) {
timer?.invalidate()
timer = nil
// Finish call, by stop playing sound, dismissing and removing observers.
dismiss(animated: true)
sinchClientMediator?.sinchClient?
.audioController
.stopPlayingSoundFile()
sinchClientMediator?.removeObserver(self)
}
}
Finally, using the Connection Inspector view, trigger
sinchClientMediator?.call(destination:with:)
as a reaction to pushing the "Call" button in MainViewController
, and present AudioCallViewController
in case of success.final class MainViewController: UIViewController {
...
var sinchClientMediator: SinchClientMediator?
@IBAction private func call(_ sender: Any) {
let recipient = recipientNameTextField.text ?? ""
sinchClientMediator?.call(destination: recipient) {
[weak self] (result: Result<SinchCall, Error>) in
guard let self = self else { return }
switch result {
// On success, transition to the call view controller.
case .success(let call):
let audioCallViewController: AudioCallViewController =
self.prepareViewController(identifier: "call")
// Pass call to be able to finish it.
audioCallViewController.call = call
self.present(audioCallViewController, animated: true)
case .failure(let error):
// Report error if call was not initiated.
}
}
}
}
Note:
At this point you still can't receive calls. To test your implementation up to this point, you can try to place a call to a non-existing user and verify that the call fails with an error message along the lines of: "Unable to connect call (destination user not found)".
Receiving an audio call
Sinch SDK requires APNs VoIP notifications to establish calls. Make sure you've uploaded your APNs signing keys to your Sinch application (see Create app section).
Starting from iOS13, incoming VoIP notifications must be reported to CallKit or your app will be killed (refer to Sinch public docs for further details). This section describes how to get notified of and handle incoming calls, describe how to report calls to CallKit, handling and showing incoming VoIP notification.
Report an incoming call to CallKit
To enable push notification usage in Sinch client, instantiate aSinchManagedPush
object as an AppDelegate
property, and request a device token for VoIP notifications:class AppDelegate: UIResponder, UIApplicationDelegate {
// Create instance to enable push notification.
private var sinchPush: SinchManagedPush?
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
...
sinchPush = SinchRTC.managedPush(forAPSEnvironment: .development)
sinchPush?.delegate = self
sinchPush?.setDesiredPushType(SinchManagedPush.TypeVoIP)
return true
}
}
Let's now extend AppDelegate to conform to
SinchManagedPushDelegate
and handle incoming VoIP notifications. The implementation of SinchClientMediator.reportIncomingCall(withPushPayload:withCompletion:)
will follow. Don't forget to forward the incoming push payload to
SinchClient
with SinchClient.relayPushNotification(withUserInfo:)
, which allows Sinch client to instantiate a new SinchCall
object based on information contained in the push payload.// Conform to SinchManagedPushDelegate to handle VoIP notification.
extension AppDelegate: SinchManagedPushDelegate {
func managedPush(_ managedPush: SinchRTC.SinchManagedPush,
didReceiveIncomingPushWithPayload payload: [AnyHashable : Any],
for type: String) {
sinchClientMediator.reportIncomingCall(with: payload, and: { error in
DispatchQueue.main.async {
// Forward the incoming push payload to Sinch client.
sinchClientMediator.sinchClient?
.relayPushNotification(withUserInfo: payload)
}
guard let error = error else { return }
// Report error if push notification was not processed correctly.
})
}
}
Create a
SinchCallKitService.reportIncomingCall(localUserId:remoteUserId:uuid:with:)
to handle reporting the incoming call to CallKit. This method will be called from SinchClientMediator.reportIncomingCall(withPushPayload:and:)
and will handle push notification and reporting of a new call.final class SinchCallKitService: NSObject {
...
func reportIncomingCall(localUserId: String,
remoteUserId: String,
uuid: UUID,
with completion: @escaping (Error?) -> Void) {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: remoteUserId)
self.provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
}
}
final class SinchClientMediator: NSObject {
...
func reportIncomingCall(with pushPayload: [AnyHashable: Any],
and completion: @escaping (Error?) -> Void) {
// Extract call information from the push payload.
let notification = queryPushNotificationPayload(pushPayload)
guard notification.isCall, notification.isValid else { return }
let callNotification = notification.callResult
let callId = callNotification.callId
guard self.callRegistry.uuid(from: callId) == nil else { return }
let uuid = UUID()
self.callRegistry.map(uuid: uuid, to: callId)
self.callKitService?.reportIncomingCall(localUserId: self.localUserId,
remoteUserId: callNotification.remoteUserId,
uuid: uuid,
with: { [weak self] error in
guard let self = self else { return }
// If we get an error here from the OS, it is
// possibly the callee's phone has "Do Not Disturb" turned on.
self.hangupCall(with: callId, on: error)
completion(error)
})
}
// If error occurred, just finish the call.
private func hangupCall(with callId: String, on error: Error?) {
guard let error = error else { return }
guard let call = self.callRegistry.sinchCall(for: callId) else { return }
call.hangup()
self.callRegistry.removeSinchCall(withId: callId)
}
}
Note that in order to properly handle the user tapping the “Answer” button in the CallKit UI, we must implement one more delegate method for
CXProviderDelegate
method:extension SinchClientMediator: CXProviderDelegate {
...
// To be able to answer the call we need to react to CXAnswerCallAction.
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
guard self.sinchClient != nil else {
action.fail()
return
}
guard let call = self.callRegistry.sinchCall(from: action.callUUID) else {
action.fail()
return
}
self.sinchClient?.audioController.configureAudioSessionForCallKitCall()
call.answer()
action.fulfill()
}
}
Handling incoming call
To react to the creation of aSinchCall
after receiving a VoIP notification, SinchClientMediator
should be set as the delegate of the SinchCallClient
.final class SinchClientMediator: NSObject {
...
func createAndStart(with userId: String,
and callback: @escaping (_ error: Error?) -> Void) {
...
sinchClient.callClient.delegate = self
sinchClient.start()
}
}
SinchClientMediator
:- assign a call delegate, to handle call progress
- add call to the
CallRegistry
to fetch it in CallKit callbacks - possibly propagate the incoming call event to UI controllers (in this example, AppDelegate handles it)
extension SinchClientMediator: SinchCallClientDelegate {
func client(_ client: SinchRTC.SinchCallClient,
didReceiveIncomingCall call: SinchRTC.SinchCall) {
// To handle call events properly, it's important to set call delegate.
call.delegate = self
self.callRegistry.addSinchCall(call)
guard UIApplication.shared.applicationState != .background else { return }
delegate?.handleIncomingCall(call)
}
}
Create a new protocol called
SinchClientMediatorDelegate
(responsible for handling incoming calls), and add a weak delegate property of that type to the SinchClientMediator
class.// Create SinchClientMediatorDelegate to handle incoming calls.
protocol SinchClientMediatorDelegate: AnyObject {
func handleIncomingCall(_ call: SinchCall)
}
final class SinchClientMediator: NSObject {
...
weak var delegate: SinchClientMediatorDelegate?
}
Assign
AppDelegate
as the SinchClientMediatorDelegate
for your SinchClientMediator
instance (to handle incoming call events).class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Assign delegate to handle incoming calls
// throughout the application in AppDelegate.
sinchClientMediator.delegate = self
}
}
Implement the
SinchClientMediatorDelegate
method handleIncomingCall(_:)
. In this implementation, present an AudioCallViewController
and pass the incoming SinchCall
to it.// Implementation to handle incoming call.
extension AppDelegate: SinchClientMediatorDelegate {
// Navigate to call controller during incoming call.
func handleIncomingCall(_ call: SinchCall) {
transitionToCallViewController(for: call)
}
private func transitionToCallViewController(_ call: SinchCall) {
guard let rootViewController = window?.rootViewController else { return }
let presentedViewController =
rootViewController.presentedViewController ?? rootViewController
let presentingViewController =
prepareAudioCallViewController(for: call,
presentedViewController: presentedViewController)
guard let presentingViewController = presentingViewController else { return }
presentedViewController.present(presentingViewController, animated: true)
}
private func prepareAudioCallViewController(for call: SinchCall,
presentedViewController: UIViewController)
-> AudioCallViewController? {
let audioCallViewController = presentedViewController
.prepareViewController(identifier: "call") as? AudioCallViewController
audioCallViewController?.call = call
return audioCallViewController
}
}
Ending an audio call
Now that it’s possible to place and receive calls, we must provide a way to terminate the call.Add
SinchCallKitService.end(uuid:with:)
and SinchClientMediator.end(call:)
, which will request CallKit to terminate an ongoing call.final class SinchCallKitService: NSObject {
...
func end(uuid: UUID, with completion: @escaping (Error?) -> Void) {
let endCallAction = CXEndCallAction(call: uuid)
let endCallTransaction = CXTransaction(action: endCallAction)
self.callController.request(endCallTransaction, completion: completion)
}
}
final class SinchClientMediator: NSObject {
...
func end(call: SinchCall) {
guard let uuid = self.callRegistry.uuid(from: call.callId) else { return }
let errorCompletion: (Error?) -> Void = { [weak self] error in
guard let self = self else { return }
if let error = error {
// Report error if call was not ended correctly.
}
self.callStartedCallback = nil
}
self.callKitService?.end(uuid: uuid, with: errorCompletion)
}
}
Then implement the actual hang-up action in the corresponding
CXProviderDelegate
callback.extension SinchClientMediator: CXProviderDelegate {
...
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
guard self.sinchClient != nil else {
action.fail()
return
}
guard let call = self.callRegistry.sinchCall(from: action.callUUID) else {
action.fail()
return
}
call.hangup()
action.fulfill()
}
}
Using the Connections Inspector, hook up the ‘Hangup’ button in
AudioCallViewController
to call sinchClientMediator?.end(call:)
when tapped. Note that
AudioCallViewController
needs access to the SinchCall
object for the ongoing call. Add a SinchCall
property to AudioCallViewController
and ensure it is set in both code paths that lead to this controller (MainViewController
, AppDelegate
).final class AudioCallViewController: UIViewController {
...
// To end call, should be passed to AudioCallViewController.
var call: SinchCall?
@IBAction private func hangup(_ sender: Any) {
guard let call = call else { return }
sinchClientMediator?.end(call: call)
dismiss(animated: true)
}
}
Logging out
A user can decide to log out, to stop receiving push notifications, or deallocateSinchClient
for better memory efficiency.Add a new method SinchClientMediator.logout(withCompletion:)
.final class SinchClientMediator: NSObject {
...
func logout(with completion: () -> Void) {
defer { completion() }
guard let client = sinchClient else { return }
// Termination of client.
if client.isStarted {
// Remove push registration from Sinch backend.
client.unregisterPushNotificationDeviceToken()
client.terminateGracefully()
}
sinchClient = nil
callKitService = nil
}
}
Using Connection Inspector View, invoke the following method as a reaction after tapping the "Logout" button in
MainViewController
.final class MainViewController: UIViewController {
...
// Connect logout action to button.
@IBAction private func logout(_ sender: Any) {
sinchClientMediator?.logout { [weak self] in
guard let self = self else { return }
self.dismiss(animated: true)
}
}
}
Next steps
Now that you've built a simple app to make and receive calls, learn more about the iOS SDK