DEV Community

Cover image for How To Handle VoIP Push Notifications using iOS Callkit
Abdul πŸ‘¨πŸΎβ€πŸ’» for Vonage

Posted on • Originally published at developer.vonage.com

How To Handle VoIP Push Notifications using iOS Callkit

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:

signing and capabilities tag

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

add capability button

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:

add callkit framework

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:

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

Certificate wizard checkbox

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:

keychain access export

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
Enter fullscreen mode Exit fullscreen mode

Then change into the directory:

cd tool-clientsdk-javascript-ios_push_uploader
Enter fullscreen mode Exit fullscreen mode

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.

upload tool success

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add a local instance of the ClientManager class:

class AppDelegate: UIResponder, UIApplicationDelegate {
    ...
    private let clientManager = ClientManager.shared
    ...
}
Enter fullscreen mode Exit fullscreen mode

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]
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }()
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()

    ...
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

incoming call with locked screen

active call from locked 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.

Top comments (0)