How to make a video call app with iOS CallKit and Sendbird Calls

Sendbird
CodeX
Published in
12 min readNov 4, 2021

--

By Jaesung Lee
Engineer | Sendbird

If you’re looking for tutorials about Swift, Kotlin, React, Flutter, and more, check out our developer portal.

  • For a complete iOS video chat app, check out our sample app on GitHub.
  • View our iOS video chat docs.

Introduction

This tutorial guides you through the process of developing VoIP apps using the Sendbird Calls framework and Apple’s CallKit framework. You’ll start by developing a simple project that allows you to make local calls using CallKit.

Each section provides the entire code of the file. You can copy and paste the code into the appropriate files. Provided codes may not be the only implementations. You can customize them to suit your needs by thoroughly reviewing the following steps. These steps will help you understand more about CallKit.

Step 1. Create a Sendbird account

1. Sign up for a free Sendbird account.

2. Create an account using your email id or click`Continue with Google`.

3. Set up your Organization by entering `Organization name` and `Phone number`.

4. Create a new “Chat + Calls” Application in the region closest to your locale.

  • On the left side of the screen, you should see a “Calls” menu, go into the “Studio” and create a new “phonebooth” user.

Step 2: Configuring CallKit

Step 2.1

To develop a VoIP app service, you need a VoIP certificate for the app. Go to the Apple Developer page and sign in.

Step 2.2

Go to Certificate, Identifiers & Profiles > Certificates > Create a New Certificate. You will find the VoIP Services Certificate under the Services section. Create the VoIP service certificate.

Step 2.3

Go to Target > Signing & Capabilities. Add Background Modes and enable Voice over IP. This will create a .entitlements file and appropriate permissions that allow you to use VoIP services. If you don’t enable Voice over IP, a CallKit error code 1 will occur.

Step 3: Designing the CallKit UI

Step 3.1

To configure localized information for CallKit, create a file named CXProviderConfiguration.extension.swift.

CXProvider

CXProvider is an object that represents a telephony provider. CXProvider is initialized with CXProviderConfiguration. VoIP apps should only create one instance of CXProvider per app and use it globally. For more information, see Apple developer docs — CXProvider.

Each provider can specify an object conforming to the CXProviderDelegate protocol to respond to events, such as starting a call, putting a call on hold, or activating a provider’s audio session.

Step 3.2

extension CXProviderConfiguration {
static var custom: CXProviderConfiguration {
// 1
let configuration = CXProviderConfiguration(localizedName: "Homing Pigeon")
// 2
// Native call log shows video icon if it was video call.
configuration.supportsVideo = true
// Support generic type to handle *User ID*
configuration.supportedHandleTypes = [.generic]
// Icon image forwarding to app in CallKit View
if let iconImage = UIImage(named: "App Icon") {
configuration.iconTemplateImageData = iconImage.pngData()
}
return configuration
}
}

A CXProviderConfiguration object controls the native call UI for incoming and outgoing calls, including the localized name of the provider, ringtone to be played for incoming calls, and the icon to be displayed during calls. For more information, see Apple developer docs — CXProviderConfiguration.

  1. Initialize the CXProviderConfiguration object with a localized name. This name will appear in the call view when your users receive a call through CallKit. Use appropriate naming, such as your app service name, as the localized name. In this case, we used “Homing Pigeon.”
  2. Configure the user interface and its capabilities. In this step, set supportsVideo, supportedHandleTypes, and iconTemplateImageData. If you want to further customize CallKit, see the table below. You can also refer to the Apple developer document — CXProviderConfiguration.

supportsVideo

configuration.supportsVideo = true

This is a Boolean value that indicates whether the call supports video capability in addition to audio. By default, it’s set to false. Apple document: supportsVideo.

If your service provides video calls, set supportsVideo to true. If your service does not provide video calls, skip this setting.

supportedHandleTypes

configuration.supportedHandleTypes = [.generic]

This is the types of call provider that you want to handle. See CXHandle.HandleType.

CXHandle refers to how your users are identified in each call. Three possible types of handles are: .phoneNumber, .email, and .generic. Depending on the service you provide and how you manage your users, you may choose different options. If the users are identified by their phone number or their email address, choose .phoneNumber or .email. However, if it’s based on some random UUID value or other unspecified value, use .generic. .generic, which is an unspecified String value that can be used more flexibly.

iconTemplateImageData

if let iconImage = UIImage(named: "App Icon") {
configuration.iconTemplateImageData = iconImage.pngData()
}

This is the PNG data for the icon image to be displayed for the provider.

The icon image should be a square image with a side length of 40 points. The alpha channel of the image is used to create a white image mask, which is used in the system’s native in-call UI for the button which takes the user from this system UI to the third-party app.

See iconTemplateImageData.

Set .iconTemplateImageData to the icon image that will be displayed next to the localized name on the CallKit screen. Assign .pngData() to your app icon.

Step 4. Requesting CallKit actions

CallKit provides many call-related features such as dialing, ending, muting, holding, etc. Each of these features should be executed by appropriate CallKit actions called CXCallAction. These actions are called from a CXCallController object, which uses CXTransaction objects to execute each CXCallAction. In order to control CallKit, you must create corresponding CXCallActions and execute them by requesting a transaction with CXTransaction.

There are three steps to send a request to CallKit:

  1. Create CXCallAction object
  2. Create CXTransaction object
  3. Request the CXTransaction object via CXCallController

Step 4.1. Transaction

// Allow to request for actions
let callController = CXCallController()
// Request transaction
private func requestTransaction(with action: CXCallAction, completionHandler: (NSError? -> Void)?) {
let transaction = CXTransaction(action: action)
callController.request(transaction) { error in
completionHandler?(error as NSError?)
}
}

Add CXCallController property and another method named requestTransaction(with:completionHandler:). The method creates CXTransaction with CXCallAction and requests the transaction via callController. You always have to call this method after creating a CXCallAction object.

Step 4.2. Call actions

Start call action

The following implements a method for CXStartCallAction. This action represents the start of a call. If the action was requested successfully, a corresponding CXProviderDelegate.provider(_:perform:) event will be called.

func startCall(with uuid: UUID, calleeID: String, hasVideo: Bool, completionHandler: ((NSError?) -> Void)? = nil) {
// 1
let handle = CXHandle(type: .generic, value: calleeID)
let startCallAction = CXStartCallAction(call: uuid, handle: handle)

// 2
startCallAction.isVideo = hasVideo

// 3
self.requestTransaction(with: startCallAction, completionHandler: completionHandler)
}
  1. You have to create a CXHandle object associated with the call that will be used to identify the users involved with the call. This object will be included in CXStartCallAction along with a UUID.
  2. If the call has video, set .isVideo to true.
startCallAction.isVideo = hasVideo

3. As mentioned in Step 1, don’t forget to call requestTransaction(with:completionHandler:) method after creating a CXStartCallAction object.

End call action

The following implements another method for CXEndCallAction. This action represents that the call was ended. If the action was requested successfully, a corresponding CXProviderDelegate.provider(_:perform:) event will be called. CXEndCallAction only requires the UUID of the call. Create a CXEndCallAction object with the UUID.

func endCall(with uuid: UUID, completionHandler: ((NSError?) -> Void)? = nil) {
let endCallAction = CXEndCallAction(call: uuid)
self.requestTransaction(with: endCallAction, completionHandler: completionHandler)
}

Other call actions

Other CXCallActions can be implemented the same as CXStartCallAction and CXEndCallAction . Here is the list of other call actions:

Step 5. Managing calls

To easily manage CXCallController and call IDs, you may want to create a call manager which must be accessible from anywhere. The call manager will store and manage UUIDs of the ongoing calls to handle call events.

NOTE
You can also use
CXCallController.callObserver.calls property that manages a list of active calls(including ended calls) and observes call changes. Each call is a CXCall object that represents a call in CallKit. By checking the hasEnded attribute, you can handle ongoing calls.

For more information, see Apple Developer Document — CallObserver and Apple Developer Document — CXCall

import CallKit

class CallManager {
// 1
static let shared = CallManager()

let callController = CXCallController()

// 2
private(set) var callIDs: [UUID] = []

// MARK: Call Management
func containsCall(uuid: UUID) -> Bool {
return CallManager.shared.callIDs.contains(where: { $0 == uuid })
}

func addCall(uuid: UUID) {
self.callIDs.append(uuid)
}

func removeCall(uuid: UUID) {
self.callIDs.removeAll { $0 == uuid }
}

func removeAllCalls() {
self.callIDs.removeAll()
}
}

Create a new class named CallManager. Then, add a shared static instance to access it from everywhere (You may choose to use other patterns than singleton).

static let shared = CallManager() // singleton

If you want to know more about this pattern, see Managing a shared resource using a singleton.

Add callIDs property with a type of [UUID] and add methods for managing callIDs : addCall(uuid:), removeCall(uuid:) and removeAllCalls()

private(set) var callIDs: [UUID] = []func containsCall(uuid: UUID) -> Bool { ... }func addCall(uuid: UUID) { ... }func removeCall(uuid: UUID) { ... }func removeAllCalls() { ... }

Step 6. Handling CallKit events

To report new incoming calls or respond to new CallKit actions, you have to create a CXProvider object with the CXProviderConfiguration that was created in Section 2. You can also handle CallKit events of the call via CXProviderDelegate.

// ProviderDelegate.swift
class ProviderDelegate: NSObject {
// 2
private let provider: CXProvider
override init() {
provider = CXProvider(configuration: CXProviderConfiguration.custom)
super.init() // If the queue is `nil`, delegate will run on the main thread.
provider.setDelegate(self, queue: nil)
}
// 3
func reportIncomingCall(uuid: UUID, callerID: String, hasVideo: Bool, completionHandler: ((NSError?) -> Void)? = nil) {
// Update call based on DirectCall object
let update = CXCallUpdate()
// 4. Informations for iPhone local call log
let callerID = call.caller?.userId ?? "Unknown"
update.remoteHandle = CXHandle(type: .generic, value: callerID)
update.localizedCallerName = callerID
update.hasVideo = hasVideo
// 5. Report new incoming call and add it to `callManager.calls`
provider.reportNewIncomingCall(with: uuid, update: update) { error in
guard error == nil else {
completionHandler?(error as NSError?)
return
}
// Add call to call manager
CallManager.shared.addCall(uuid: uuid)
}
}
// 6
func connectedCall(uuid: UUID, startedAt: Int64) {
let connectedAt = Date(timeIntervalSince1970: Double(startedAt)/1000)
self.provider.reportOutgoingCall(with: uuid, connectedAt: connectedAt)
}
// 7
func endCall(uuid: UUID, endedAt: Date, reason: CXCallEndedReason) {
self.provider.reportCall(with: uuid, endedAt: endedAt, reason: reason)
}
}
// 1
extension ProviderDelegate: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) { }
}
  1. Import CallKit and create a ProviderDelegate class with NSObject and CXProviderDelegate conformance.
  2. Add two properties: callManager and provider. The callManager is the CallManager class that you created in Section 3. The provider reports actions for CallKit. When you initialize a provider, use the CXProviderConfiguration.custom that you already created in Section 2.
private let provider: CXProvider

override init() {
provider = CXProvider(configuration: CXProviderConfiguration.custom)

super.init()

// If the queue is `nil`, delegate will run on the main thread.
provider.setDelegate(self, queue: nil)
}
  1. To report a new incoming call, you need to create a CXCallUpdate instance with the relevant information about the incoming call as well as the CXHandle that identifies the users involved in the call.
  2. To make your calls richer, you can customize the CXHandle and CXCallUpdate instances. If the call has video, set hasVideo to true. The upper iPhone call log is based on CXHandle object.
  3. After reporting a new incoming call, you have to add it to CallManager.shared.calls by using the addCall(uuid:) method that was added earlier.
  4. CallKit keeps track of the connected time of the call and the end time of the call by listening to appropriate CallKit events. To tell the CallKit that the call was connected, call reportOutgoingCall(with:connectedAt:). This initiates the call duration elapsing, and informs the starting point of the call that is displayed in the call log of the iPhone app.
  5. To tell the CallKit that the call was ended, call reportCall(with:endedAt:reason:). This informs the endpoint of the call that will be displayed in the call log of the iPhone app as well.

Step 6. Handle CXCallAction event

Interaction with CallKit UI

When the provider performs CXCallActions, corresponding CXProviderDelegate methods can be called. In order to properly respond to the users’ actions, you have to implement appropriate Sendbird Calls actions in the method.

Important: Don’t forget to execute action.fulfill() before the method is ended.

Important: To access the UUID of the call, you have to use action.callUUID property, not action.uuid.

For more information about CXProviderDelegate methods, refer to the Apple developer document — CXProviderDelegate.

// ProviderDelegate.swift
extension ProviderDelegate: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
// Stop audio
// End all calls because they are no longer valid
// Remove all calls from the app's list of call
CallManager.shared.removeAllCalls()
}
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
// Get call object
// Configure audio session
// Add call to `callManger.callIDs`.
// Report connection started
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// Configure audio session
// Accept call
// Notify incoming call accepted
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
// Mute the call
// End the call
action.fulfill() // Remove the ended call from `callManager.callIDs`.
CallManager.shared.removeCall(uuid: action.callUUID)
}
func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
// update holding state.
// Mute the call when it's on hold.
// Stop the video when it's a video call.
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
// stop / start audio
action.fulfill()
}
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
// Start audio
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
// Restart any non-call related audio now that the app's audio session has been
// de-activated after having its priority restored to normal.
}
}

Step 7. Interaction with UI

You can start and end calls with CallKit using its default view. Next, let’s try to use a custom UI with CallKit. For the sake of clarity, this tutorial skips creating related storyboard files and ViewController files. Instead, suppose that there is one text field for entering the remote user’s ID, one button for making an outgoing call, another button for receiving an incoming call, and the last button for ending the call.

// ViewController.swift
import UIKit

class ViewController: UIViewController {
let providerDelegate = ProviderDelegate()

// UUID of ongoing call
var callID: UUID?

// 1
@IBAction func didTapOutgoingCall() {
guard let calleeID = userIDTextField.text?.trimmingCharacters(in: .whitespaces) else { return }
guard !calleeID.isEmpty else { return }
let uuid = UUID()
self.callID = uuid

CallManager.shared.startCall(with: uuid, calleeID: calleeID, hasVideo: false) { error in
// ...
}
}

// 2
@IBAction func didTapEnd() {
guard let callID = self.callID else { return }
CallManager.shared.endCall(with: callID) { error in
guard error == nil else { return }
}
self.callID = nil
}

// 3
@IBAction func didTapIncomingCall() {
guard let callerID = userIDTextField.text?.trimmingCharacters(in: .whitespaces) else { return }
guard !callerID.isEmpty else { return }
let uuid = UUID()
self.callID = uuid

providerDelegate.reportIncomingCall(uuid: uuid, callerID: callerID, hasVideo: false) { error in
// ...
}
}
}
  1. Make an outgoing call: Because the user is initiating a call, you have to create a request for the call. This action requires callee’s user ID and the unique UUID of the call.
  2. Implement the action for the end button: This action will end the call based on the callID.
  3. Answer an incoming audio call: To do this, you have to simulate an incoming audio call. Because CallKit is not aware of the incoming call, you have to report to the CallKit about the incoming call. This action requires the caller’s user ID and unique UUID of the call. Currently, because the incoming call is made locally, you will use a randomly generated UUID() instead of a real call’s UUID. If you want to test incoming video calls, assign the value of hasVideo parameter as true.

Conclusion

This tutorial covered the development of VoIP apps using the Sendbird Calls framework built on top of WebRTC and the Apple CallKit framework. You are on your way to building awesome engaging apps with voice and video calls. Happy iOS calling app building!

--

--

Sendbird
CodeX

Sendbird is the richest and most proven conversations platform for mobile apps using chat, voice, and video. (https://sendbird.com/?utm=ga)