Handling VoIP Push Notifications with CallKit

Vonage Dev
12 min readFeb 15, 2021

Last updated on May 25, 2023

In this tutorial, you will use CallKit to handle the VoIP push notifications sent to an iOS device when using the Vonage Client SDK for iOS. VoIP push notifications are the primary method for receiving inbound calls with the Client SDK since they will reach the device regardless of whether your application is in the foreground or not. CallKit allows you to integrate your iOS application into the system so your application can look like a native iOS phone call. Using the Vonage Client SDK with Callkit will allow you to build calling into your application while having a consistent and familiar interface for inbound calls.

Prerequisites

  • A Vonage API account. If you don’t have one already, you can sign up today.
  • An Apple Developer account and an iOS device (or simulator in Xcode 14 and above).
  • A GitHub account.
  • Xcode 12 and Swift 5 or greater.
  • Cocoapods to install the Vonage Client SDK for iOS.
  • Our Command Line Interface. You can install it with npm install -g @vonage/cli.

The Starter Project

This blog will be building on top of the “Receiving a phone call in-app” from the Vonage developer portal. This tutorial will start from the finished state of the tutorial project. Either follow along, or if you are already familiar with building a Vonage Client SDK voice application, you can clone the starter project from GitHub.

Set up Push Certificates

There are two types of push notifications that you can use in an iOS app, VoIP pushes with PushKit or User Notifications. This tutorial will be focusing on VoIP pushes. Apple Push Notifications service (APNs) uses certificate-based authentication to secure the connections between APNs and Vonage servers. So you will need to create a certificate and upload it to the Vonage Servers so Vonage can send a push to the device when there is an incoming call.

Adding a Push Notification Capability

To use push notifications, you are required to add the push notification capability to your Xcode project. Make sure you are logged into your Apple developer account in Xcode via preferences. If so, select your target and then choose Signing & Capabilities:

Then select add capability and add the Push Notifications and Background Modes capabilities:

Under the Background Modes capability, select Voice over IP and Background processing. If Xcode is automatically managing the signing of your app it will update the provisioning profile linked to your Bundle Identifier to include the capabilities.

When using VoIP push notifications, you have to use the CallKit framework. Link it to your project by adding it under Frameworks, Libraries, and Embedded Content under General:

Generating a Push Certificate

To generate a push certificate, you will need to log in to your Apple developer account and head to the Certificates, Identifiers & Profiles page and add a new certificate:

Choose Apple Push Notification service SSL (Sandbox & Production) and continue.

You will now need to choose the App ID for the app that you want to add VoIP push notifications to and continue. If your app is not listed, you will have to create an App ID. Xcode can do this for you if it automatically manages your signing. Otherwise, you can create a new App ID on the Certificates, Identifiers & Profiles page under Identifiers. Make sure to select the push notifications capability when doing so.

You will be prompted to upload a Certificate Signing Request (CSR). You can follow the instructions on Apple’s help website to create a CSR on your Mac. Once the CSR is uploaded, you will be able to download the certificate. Double-click the .cer file to install it in Keychain Access.

To get the push certificate in the format that is needed by the Vonage servers, you will need to export it. Locate your VoIP Services certificate in Keychain Access and right-click to export it. Name the export applecert and select .p12 as the format:

Upload Your Push Certificate

Now that you have a push certificate linked to your iOS application, you need to upload it to the Vonage servers. You upload your certificate to the Vonage servers by making a POST request, you can do so using your terminal or using the upload tool.

Using the terminal, clone the upload tool:

git clone git@github.com:Vonage-Community/tool-clientsdk-javascript-ios_push_uploader.git

Then change into the directory:

cd tool-clientsdk-javascript-ios_push_uploader

To run the tool, install the dependencies with npm install once that is complete run the project with node server.js. The tool will be available on your localhost on port printed to the terminal.

Enter your Vonage Application ID, private key, certificate file, and optional password then upload. The page will show the status of your upload on the page once it is complete.

The ClientManager Class

Create a new Swift file (CMD + N) and call it ClientManager. This class will encapsulate the code needed to interface with the Client SDK since you will need to get information from the Client SDK in multiple places in future steps:

protocol ClientManagerDelegate: AnyObject {
func clientStatusUpdated(_ clientManager: ClientManager, status: String)
}
struct Constants {
static let deviceId = "VGDeviceID"
static let pushToken = "VGPushToken"
}
final class ClientManager: NSObject {
weak var delegate: ClientManagerDelegate?
private let client = VGVoiceClient()
private var isActiveCall = false
static let shared = ClientManager()
override init() {
super.init()
initializeClient()
}
func initializeClient() {
client.delegate = self
}

func login(isPushLogin: Bool = false) {
print("VPush: Login - isPush:", isPushLogin)
guard !isActiveCall else { return }

getJWT { jwt in
self.client.createSession(jwt) { error, sessionID in
let statusText: String
if error == nil {
statusText = "Connected"
} else {
statusText = error!.localizedDescription
}
self.delegate?.clientStatusUpdated(self, status: statusText)
}
}
}
private func getJWT(completion: @escaping (String) -> Void) {
let jwt = "ALICE_JWT"

completion(jwt)
}
}

Replace ALICE_JWT with the JWT you generated earlier, in a production environment this is where you would fetch a JWT from your authentication server/endpoint.

With this new class, you will need to move the call Client SDK code from the ViewController class to the ClientManager class. The two classes will communicate with the ClientManagerDelegate observers. Make the following changes to your ViewController class:

class ViewController: UIViewController {

private let connectionStatusLabel = UILabel()

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.

ClientManager.shared.delegate = self

connectionStatusLabel.text = ""
connectionStatusLabel.textAlignment = .center
connectionStatusLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(connectionStatusLabel)

view.addConstraints([
connectionStatusLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
connectionStatusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor)
])
}
}
extension ViewController: ClientManagerDelegate {
func clientStatusUpdated(_ clientManager: ClientManager, status: String) {
DispatchQueue.main.async {
self.connectionStatusLabel.text = status
}
}
}

Register for Push Notifications

The next step is to register a device for push notifications to let Vonage know which device to send the push notification to for which user. In the ClientManager class add the pushToken property and the following functions to handle the push token of the device:

final class ClientManager: NSObject {
public var pushToken: Data?
weak var delegate: ClientManagerDelegate?
...
func invalidatePushToken(_ completion: (() -> Void)? = nil) {
print("VPush: Invalidate token")
if let deviceId = UserDefaults.standard.object(forKey: Constants.deviceId) as? String {
client.unregisterDeviceTokens(byDeviceId: deviceId) { error in
if error == nil {
self.pushToken = nil
UserDefaults.standard.removeObject(forKey: Constants.pushToken)
UserDefaults.standard.removeObject(forKey: Constants.deviceId)
completion?()
}
}
} else {
completion?()
}
}
private func registerPushIfNeeded(with token: Data) {
shouldRegisterToken(with: token) { shouldRegister in
if shouldRegister {
self.client.registerVoipToken(token, isSandbox: true) { error, deviceId in
if error == nil {
print("VPush: push token registered")
UserDefaults.standard.setValue(token, forKey: Constants.pushToken)
UserDefaults.standard.setValue(deviceId, forKey: Constants.deviceId)
} else {
print("VPush: registration error: \(String(describing: error))")
return
}
}
}
}
}
private func shouldRegisterToken(with token: Data, completion: @escaping (Bool) -> Void) {
let storedToken = UserDefaults.standard.object(forKey: Constants.pushToken) as? Data

if let storedToken = storedToken, storedToken == token {
completion(false)
return
}

invalidatePushToken {
completion(true)
}
}

The registerPushIfNeeded function takes a token and then uses the shouldRegisterToken function to check if the token has already been registered. If it has not, registerVoipToken on the client will register the push notification with Vonage. In the AppDelegate class, you can now register for VoIP push notifications. Import PushKit at the top of the file:

import PushKit

Add a local instance of the ClientManager class:

class AppDelegate: UIResponder, UIApplicationDelegate {
...
private let clientManager = ClientManager.shared
...
}

Create a new extension at the end of the file which contains a function to register the device for push notifications:

extension AppDelegate: PKPushRegistryDelegate {
func registerForVoIPPushes() {
let voipRegistry = PKPushRegistry(queue: nil)
voipRegistry.delegate = self
voipRegistry.desiredPushTypes = [PKPushType.voIP]
}
}

Update the didFinishLaunchingWithOptions function to call the registerForVoIPPushes function and log in to the Client SDK:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
AVAudioSession.sharedInstance().requestRecordPermission { (granted:Bool) in
print("Allow microphone use. Response: \(granted)")
}
registerForVoIPPushes()
clientManager.login()
return true
}

Add the PKPushRegistryDelegate functions to handle the push notification registration to the extension:

extension AppDelegate: PKPushRegistryDelegate {
...
func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
clientManager.pushToken = pushCredentials.token
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
clientManager.invalidatePushToken(nil)
}
}

The push token is stored as a property on the ClientManager class as you only want to register the token with Vonage when the client is logged in so edit the login function in the ClientManager class to handle this:

func login(isPushLogin: Bool = false) {
print("VPush: Login - isPush:", isPushLogin)
guard !isActiveCall else { return }

ongoingPushLogin = isPushLogin

getJWT { jwt in
self.client.createSession(jwt) { error, sessionID in
let statusText: String
if error == nil {
statusText = "Connected"

if isPushLogin {
self.handlePushLogin()
} else {
self.handleLogin()
}
} else {
statusText = error!.localizedDescription
}

self.delegate?.clientStatusUpdated(self, status: statusText)
}
}
}
private func handlePushLogin() {
ongoingPushLogin = false

if let storedAction = storedAction {
storedAction()
}
}
private func handleLogin() {
if let token = pushToken {
registerPushIfNeeded(with: token)
}
}

Handle Incoming Push Notifications

With the device registered, it can now receive push notifications from Vonage. The Client SDK has functions for checking if a push notification payload is the expected payload and for processing the payload. When processCallInvitePushData is called, it converts the payload into a call that is received on the didReceiveInviteForCall function of the VGVoiceClientDelegate.

Like registering a push token, you only want to process an incoming push when the Client SDK has been logged in. Implement the functions on the ClientManager class alongside a local variable to store an incoming push:

final class ClientManager: NSObject {
...
private var ongoingPushLogin = false
private var ongoingPushKitCompletion: () -> Void = { }
private var storedAction: (() -> Void)?
...
func isVonagePush(with userInfo: [AnyHashable : Any]) -> Bool {
VGVoiceClient.vonagePushType(userInfo) == .unknown ? false : true
}
func processPushPayload(with payload: [AnyHashable : Any], pushKitCompletion: @escaping () -> Void) -> String? {
self.ongoingPushKitCompletion = pushKitCompletion
return client.processCallInvitePushData(payload)
}
...
}

The PKPushRegistryDelegate has a function that is called when there is an incoming push called didReceiveIncomingPushWith add it to the extension PKPushRegistryDelegate in the AppDelegate.swift file:

func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
if clientManager.isVonagePush(with: payload.dictionaryPayload) {
clientManager.login(isPushLogin: true)
_ = clientManager.processPushPayload(with: payload.dictionaryPayload, pushKitCompletion: completion)
}
}

It is recommended that you perform a login when you have an incoming VoIP push notification which is why login is called here. This uses the logic in the ClientManager class that stores information about a push to be used after the login has been completed. The logic will be implemented at a later stage.

When your iOS application has an incoming VoIP push notification, you must handle it using the CXProvider class in the CallKit framework. Create a new Swift file (CMD + N) called ProviderDelegate:

import CallKit
import AVFoundation
import VonageClientSDKVoice
final class ProviderDelegate: NSObject {
private let provider: CXProvider
private let callController = CXCallController()
private var activeCall: UUID? = nil

override init() {
provider = CXProvider(configuration: ProviderDelegate.providerConfiguration)
super.init()
provider.setDelegate(self, queue: nil)
}

static var providerConfiguration: CXProviderConfiguration = {
let providerConfiguration = CXProviderConfiguration()
providerConfiguration.maximumCallsPerCallGroup = 1
providerConfiguration.supportedHandleTypes = [.generic, .phoneNumber]
return providerConfiguration
}()
}

The callController property is a CXCallController object used by the class to handle user actions on the CallKit UI. Next, create an extension at the end of the file to implement the CXProviderDelegate:

extension ProviderDelegate: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
activeCall = nil
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
ClientManager.shared.answer(activeCall!.uuidString.lowercased()) { error in
if error == nil {
action.fulfill()
} else {
action.fail()
}
}
}

func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
hangup(action: action)
}

func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
VGVoiceClient.enableAudio(audioSession)
}

func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
VGVoiceClient.disableAudio(audioSession)
}
}

When CallKit activates and deactivates the audio session, the delegate functions enable and disable the Client SDK audio using the CallKit audio session.

When the CallKit UI answers the call, it calls the CXAnswerCallAction delegate function. This calls the answer function that you will implement on the ClientManager in a future step.

CXEndCallAction gets called when the call is ended from the CallKit UI which calls the hangup function that you will implement next. Implement the remainder of the functions needed in the ProviderDelegate class:

final class ProviderDelegate: NSObject {
...

func reportCall(_ callID: String, caller: String, completion: @escaping () -> Void) {
activeCall = UUID(uuidString: callID)
let update = CXCallUpdate()
update.localizedCallerName = caller

provider.reportNewIncomingCall(with: activeCall!, update: update) { error in
if error == nil {
completion()
}
}
}

func didReceiveHangup(_ callID: String) {
let uuid = UUID(uuidString: callID)!
provider.reportCall(with: uuid, endedAt: Date.now, reason: .remoteEnded)
}

func reportFailedCall(_ callID: String) {
let uuid = UUID(uuidString: callID)!
provider.reportCall(with: uuid, endedAt: Date.now, reason: .failed)
}

private func hangup(action: CXEndCallAction) {
if activeCall == nil {
endCallTransaction(action: action)
} else {
ClientManager.shared.reject(activeCall!.uuidString.lowercased()) { error in
if error == nil {
self.endCallTransaction(action: action)
}
}
}
}

private func endCallTransaction(action: CXEndCallAction) {
self.callController.request(CXTransaction(action: action)) { error in
if error == nil {
self.activeCall = nil
action.fulfill()
} else {
action.fail()
}
}
}
}

The reportCall function calls reportNewIncomingCall which triggers the CallKit system UI, the other functions help to either update or end calls. Now that the ProviderDelegate class is complete, you can update the ClientManager class to use it. Add the providerDelegate property to the client manager:

final class ClientManager: NSObject {
...
private let providerDelegate = ProviderDelegate()
...
}

Then implement the VGVoiceClientDelegate:

extension ClientManager: VGVoiceClientDelegate {
func voiceClient(_ client: VGVoiceClient, didReceiveInviteForCall callId: VGCallId, from caller: String, with type: VGVoiceChannelType) {
print("VPush: Received invite", callId)
providerDelegate.reportCall(callId, caller: caller, completion: ongoingPushKitCompletion)
}

func voiceClient(_ client: VGVoiceClient, didReceiveHangupForCall callId: VGCallId, withQuality callQuality: VGRTCQuality, reason: VGHangupReason) {
print("VPush: Received hangup")
isActiveCall = false
providerDelegate.didReceiveHangup(callId)
}

func voiceClient(_ client: VGVoiceClient, didReceiveInviteCancelForCall callId: String, with reason: VGVoiceInviteCancelReason) {
print("VPush: Received invite cancel")
providerDelegate.reportFailedCall(callId)
}

func client(_ client: VGBaseClient, didReceiveSessionErrorWith reason: VGSessionErrorReason) {
let reasonString: String!

switch reason {
case .tokenExpired:
reasonString = "Expired Token"
case .pingTimeout, .transportClosed:
reasonString = "Network Error"
default:
reasonString = "Unknown"
}

delegate?.clientStatusUpdated(self, status: reasonString)
}
}

After the SDK has processed the call, you will get a call invite on the didReceiveInviteForCall delegate function, which will, in turn, report the call. didReceiveHangupForCall and didReceiveInviteCancelForCall also call their respective functions on the provider delegate. To complete the ClientManager class, add the answer and reject functions:

func answer(_ callID: String, completion: @escaping (Error?) -> Void) {
let answerAction = {
print("VPush: Answer", callID)
self.isActiveCall = true
self.client.answer(callID, callback: completion)
}

if ongoingPushLogin {
print("VPush: Storing answer")
storedAction = answerAction
} else {
answerAction()
}

}
func reject(_ callID: String, completion: @escaping (Error?) -> Void) {
let rejectAction = {
print("VPush: Reject", callID)
self.isActiveCall = false
self.client.reject(callID, callback: completion)
}

if ongoingPushLogin {
print("VPush: Storing Reject")
storedAction = rejectAction
} else {
rejectAction()
}
}

Again, you can only answer or reject a call after the Client SDK has been logged in. Both functions use the ongoingPushLogin to check if the login has been successfully completed; if not, the action is stored using storedAction. If you look at the handlePushLogin function, you can see that when the login is complete, it calls a stored action if there is one.

Try it out

Build and Run (CMD + R) the project onto your iOS device (or simulator in Xcode 14 and above), accept the microphone permissions and lock the device. Then call the number linked to your Vonage Application from earlier. You will see the incoming call directly on your lock screen; then once you pick it up it will go into the familiar iOS call screen:

If you check the call logs on the device, you will also see the call listed there.

What’s Next?

You can find the completed project on GitHub. You can do a lot more with the Client SDK and CallKit; you can use CallKit for outbound calls. Learn more about the Client SDK on the Vonage Client SDK Overview and CallKit on developer.apple.com.

Originally published at https://developer.vonage.com/en/blog/handling-voip-push-notifications-with-callkit

--

--

Vonage Dev

Developer content from the team at Vonage, including posts on our Java, Node.js, Python, DotNet, Ruby and Go SDKs. https://developer.vonage.com