Business Productivity

How to Integrate Apple’s CallKit into iOS Applications Using the Amazon Chime SDK

Apple’s CallKit is a framework introduced with iOS 10. It gives users the ability to handle VoIP calls from third party apps, such as Skype, similar to how they would handle a system phone call. If your iOS application is currently using Amazon Chime SDK without Apple’s CallKit, you will notice that in-application audio cannot be started while a phone call or a call from another Apple CallKit-enabled application is in progress. This is because Apple CallKit enabled calls have higher priority than ones without Apple’s CallKit enabled. In addition, without Apple’s CallKit,  providing call history and enabling preferred user settings to silence calls, alerts, and notifications in your application is not possible.

With Apple CallKit integration, the user can be given the choice to end an in-progress call and start a new VoIP call or online meeting from your iOS application that integrates Amazon Chime SDK features (“Customer Application”). Integrating Apple’s CallKit also gives your  Customer Application a more native look and feel by providing the same UI as the iOS Phone app, logging the call history in the iOS Phone app, and respecting system settings such as Do Not Disturb.

Solution overview

The following sections walk through key steps to integrate Apple’s CallKit into your Customer Application and wire up relevant Amazon Chime SDK APIs to initiate outgoing and incoming VoIP calls using Apple’s CallKit, end the call, and handle events during a call such as mute/unmute and hold/resume. This blog will focus on the main Apple CallKit functionalities related to Amazon Chime SDK for iOS. Topics such as iOS VoIP Push Notification, iOS Call Directory app extension will not be covered in this blog.

All the code samples used  in this blog can be found in the demo app in our Amazon Chime SDK for iOS Github repository.

Note: Deploying this demo and receiving traffic from the demo created in this post can incur AWS charges.

Prerequisites

  • You have read Building a Meeting Application on iOS using the Amazon Chime SDK. You understand the basic architecture of Amazon Chime SDK and have deployed a serverless/browser demo meeting application.
  • Your  Customer Application is integrated with Amazon Chime SDK for iOS.
  • You have a basic to intermediate understanding of iOS development and tools.
  • You have installed Xcode version 11.0 or later.

Note: To answer incoming VoIP calls through VoIP Push Notification, the Customer Application also needs to have VoIP Push Notification integrated.

Key steps

  1. Configure the Customer Application
  2. Initialize CXProvider and CXCallController
  3. Start outgoing call
  4. Report new incoming call
  5. Configure AVAudioSession
  6. Start Amazon Chime SDK meeting session
  7. Mute/Unmute call
  8. Handle call holding
  9. End call
  10. Test
  11. Cleanup

Configure the application

Assuming the Customer Application is already integrated with Amazon Chime SDK without Apple’s CallKit, enable Voice over IP background mode in Signing & Capabilities tab of the Project file:

Initialize CXProvider and CXCallController

There are three key classes to get familiar with when using Apple’s CallKit: CXProviderDelegate, CXProviderCXCallController.

Refer to official Apple Documentation regarding their detailed responsibilities.

CXProviderDelegate

Implement CXProviderDelegate’s methods to handle events throughout the incoming/outgoing call’s life cycle. These methods are discussed in more detail below.

CXProvider

Only create one instance of CXProvider in the Customer Application. Initialize with CXProviderConfiguration and set up its delegate.

It’s used to report new incoming calls from events such as VoIP push notifications.

import CallKit

let configuration = CXProviderConfiguration(localizedName: "Amazon Chime SDK Demo")
configuration.maximumCallGroups = 1
configuration.maximumCallsPerCallGroup = 1
configuration.supportsVideo = true
configuration.supportedHandleTypes = [.generic]
configuration.iconTemplateImageData = UIImage(named: "callkit-icon")?.pngData()
provider = CXProvider(configuration: configuration)
// set delegate to your CXProviderDelegate implementation
provider.setDelegate(self, queue: nil)

CXCallController

CXCallController is used to perform actions on a call from within the Customer Application, not from Apple’s CallKit UI. These actions include: starting an outgoing call, ending a call, toggling mute, toggling hold.

let callController = CXCallController()

Start outgoing call

Associate each Amazon Chime SDK MeetingSession as a call with a unique UUID. This UUID is used to identify the call in CXProviderDelegate methods later on. CXHandle is used to identify the other caller in the iOS Phone application call history. Use CXCallController to start the outgoing call. There is no Apple CallKit UI in this case.

// Determine uuid and handleName based on MeetingSession
let handle = CXHandle(type: .generic, value: handleName)
let startCallAction = CXStartCallAction(call: uuid, handle: handle)
let transaction = CXTransaction(action: startCallAction)

callController.request(transaction) { error in
    if let error = error {
        self.logger.error(msg: "Error requesting CXStartCallAction transaction: \(error)")
    } else {
        self.logger.info(msg: "Requested CXStartCallAction transaction successfully")
    }
}

Report new incoming call

After receiving a VoIP Push Notification for an incoming call, associate the call with a MeetingSession and use CXProvider to report the call to trigger Apple’s CallKit Answer UI. There are two scenarios when the user answers the call:

  1. When the Customer Application is in the foreground or background, the Customer Application will be brought to the foreground after the user clicks Answer button on the Apple CallKit Answer UI. Apple’s CallKit UI is in the background in this scenario.
  2. When the device is locked, user stays on the Apple CallKit UI after sliding to answer. The user can tap the last button to unlock the device and be brought to the Customer Application.

The user can also choose to decline the call. Optionally, the user can start a timer that ends the incoming call if it’s not answered within a period of time. This call will show in the iOS Phone application call history as Missed Call in Red.

// Create MeetingSession based on VoIP Push Notification

// Determine uuid and handleName based on MeetingSession

let handle = CXHandle(type: .generic, value: handleName)
let update = CXCallUpdate()
update.remoteHandle = handle
update.supportsHolding = true

provider.reportNewIncomingCall(with: uuid, update: update, completion: { error in
    if let error = error {
        self.logger.error(msg: "Error reporting new incoming call: \(error.localizedDescription)")
    } else {
        self.logger.info(msg: "Reported new incoming call successfully")
    }
})

Configure AVAudioSession

Use the following code to configure AVAudioSession’s category and mode. Here is the documentation from Apple regarding why it’s necessary.

import AVFoundation

func configureAudioSession() {
    let audioSession = AVAudioSession.sharedInstance()
    do {
        if audioSession.category != .playAndRecord {
            try audioSession.setCategory(AVAudioSession.Category.playAndRecord,
                                         options: AVAudioSession.CategoryOptions.allowBluetooth)
        }
        if audioSession.mode != .voiceChat {
            try audioSession.setMode(.voiceChat)
        }
    } catch {
        logger.error(msg: "Error configuring AVAudioSession: \(error.localizedDescription)")
    }
}

For outgoing call, this is added in:

// MARK: - Your CXProviderDelegate class

func provider(_: CXProvider, perform action: CXStartCallAction) {
    // End existing active MeetingSession if necessary
    
    // Find the MeetingSession based on action.callUUID
    
    // Save this MeetingSession for access in provider(_:didActivate:)
    
    configureAudioSession()
    action.fulfill()
}

For incoming call, this is added in:

// MARK: - Your CXProviderDelegate class

func provider(_: CXProvider, perform action: CXAnswerCallAction) {
    // End existing active MeetingSession if necessary
    
    // Find the MeetingSession based on action.callUUID
    
    // Save this MeetingSession for access in provider(_:didActivate:)
    
    // If there is a timer for answering call, cancel that timer since it's answered
    
    configureAudioSession()
    action.fulfill()
}

Start Chime SDK meeting session

After AVAudioSession is activated for incoming/outgoing call, the following CXProviderDelegate method will be invoked.
IMPORTANT: Make sure to call start(callKitEnabled: true) on AudioVideoControllerFacade here but not before. Otherwise Audio will not start properly, similar to this issue.

// MARK: - Your CXProviderDelegate class

func provider(_: CXProvider, didActivate _: AVAudioSession) {
    // Get the MeetingSession from perform:CXStartCallAction or perform:CXAnswerCallAction
    
    do {
        // Set up necessary observers on meetingSession.audioVideo
        
        try meetingSession.audioVideo.start(callKitEnabled: true)
    } catch {
        logger.error(msg: "Error starting the Meeting: \(error.localizedDescription)")
    }
}

In order for Apple’s CallKit to calculate duration for outgoing call, use the following two callbacks on AudioVideoObserver to report connecting and connected status:

// MARK: - Your AudioVideoObserver implementation class

func audioSessionDidStartConnecting(reconnecting: Bool) {
    if !reconnecting {
        provider.reportOutgoingCall(with: uuid, startedConnectingAt: Date())
    }
}

func audioSessionDidStart(reconnecting: Bool) {
    if !reconnecting {
        provider.reportOutgoingCall(with: uuid, connectedAt: Date())
    }
}

Mute/Unmute call

User can mute/unmute the call by toggling the Mute button on the Apple CallKit UI. If user can mute/unmute within the Customer Application, use CXCallController to perform this action:

// Determine uuid based on MeetingSession

let isMuted = true // or false
let setMutedAction = CXSetMutedCallAction(call: uuid, muted: isMuted)
let transaction = CXTransaction(action: setMutedAction)
callController.request(transaction, completion: { error in
    if let error = error {
        self.logger.error(msg: "Error requesting CXSetMutedCallAction transaction: \(error)")
    } else {
        self.logger.info(msg: "Requested CXSetMutedCallAction transaction successfully")
    }
})

Regardless of whether the Mute/Unmute action is triggered from Apple’s CallKit UI or within the Customer Application, the following method on CXProviderDelegate will be called, where we call the corresponding Amazon Chime SDK API:

// MARK: - Your CXProviderDelegate class

func provider(_: CXProvider, perform action: CXSetMutedCallAction) {
    // Find the MeetingSession based on action.callUUID
    
    if action.isMuted {
        meetingSession.audioVideo.realtimeLocalMute()
    } else {
        meetingSession.audioVideo.realtimeLocalUnmute()
    }
    action.fulfill()
}

Handle call holding

When an interrupting PSTN or Apple CallKit call is answered, the following method on CXProviderDelegate will be called to put active call on hold:

// MARK: - Your CXProviderDelegate class

func provider(_: CXProvider, perform action: CXSetHeldCallAction) {
    // Find the MeetingSession based on action.callUUID
    
    if action.isOnHold {
        meetingSession.audioVideo.stop()
    }    
    action.fulfill()
}

IMPORTANT: Do not call start(callKitEnabled: true) here when action.isOnHold is false because provider(_:didActivate:) will be invoked, which will call start(callKitEnabled: true) there.

When the interrupting call is ended by the user, previous call will be automatically resumed. However this is not the case when the interrupting call is ended by the remote party. In this case use CXCallController to perform a CXSetHeldCallAction from within the Customer Application:

// Determine uuid based on MeetingSession

let setHeldCallAction = CXSetHeldCallAction(call: uuid, onHold: false)
let transaction = CXTransaction(action: setHeldCallAction)
callController.request(transaction, completion: { error in
    if let error = error {
        self.logger.error(msg: "Error requesting CXSetHeldCallAction transaction: \(error)")
    } else {
        self.logger.info(msg: "Requested CXSetHeldCallAction transaction successfully")
    }
})

End call

User can end the call by tapping the End button on the Apple CallKit UI. If the call needs to be ended with in the Customer Application, such as an End button in the Customer Application or incoming call not answered after a period of time, use CXCallController to end the call:

// Determine uuid based on MeetingSession

let endCallAction = CXEndCallAction(call: uuid)
let transaction = CXTransaction(action: endCallAction)
callController.request(transaction, completion: { error in
    if let error = error {
        self.logger.error(msg: "Error requesting CXEndCallAction transaction: \(error)")
    } else {
        self.logger.info(msg: "Requested CXEndCallAction transaction successfully")
    }
})

Regardless of whether the call is ended from Apple’s CallKit UI or within the Customer Application by requesting CXEndCallAction, the following method on CXProviderDelegate will be called. Invoke stop() on AudioVideoControllerFacade and do any necessary UI update in the Customer Application.

// MARK: - Your CXProviderDelegate class

func provider(_: CXProvider, perform action: CXEndCallAction) {
    // Find the MeetingSession based on action.callUUID
    
    meetingSession.audioVideo.stop()
    action.fulfill()
}

The following callback on AudioVideoObserver will be invoked after audio session stops, where we report to the CXProvider that a call is ended at a given time for a particular reason.

// MARK: - Your AudioVideoObserver implementation class

func audioSessionDidStopWithStatus(sessionStatus: MeetingSessionStatus) {
    // Remove observers on meetingSession.audioVideo
    
    switch sessionStatus.statusCode {
    case .ok:
        // If the call is on hold, do not exit the meeting
        if isOnHold {
            return
        }
    case .audioCallEnded, .audioServerHungup:
        provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
    case .audioJoinedFromAnotherDevice:
        provider.reportCall(with: uuid, endedAt: Date(), reason: .answeredElsewhere)
    case .audioDisconnectAudio:
        provider.reportCall(with: uuid, endedAt: Date(), reason: .declinedElsewhere)
    default:
        provider.reportCall(with: uuid, endedAt: Date(), reason: .failed)
    }
    
    // Update UI to reflect that call is ended
}

Test

Now the Customer Application should be integrated with Apple’s CallKit, with the Amazon Chime SDK handling the audio, video, and communication to other relevant AWS services. After building and running the Customer Appplication on a physical iOS device, outgoing/incoming/missed calls should show in the iOS Phone application Call History with corresponding handle and duration. To test call holding, start an Apple CallKit meeting with Amazon Chime SDK in the Customer Application, then make a phone call or start an Apple CallKit call from another application.

Note: Apple’s CallKit does not work on iOS simulator.

Cleanup

If you no longer want to keep the demo active in your AWS account and wish to avoid incurring AWS charges, the demo resources can be removed by deleting the two AWS CloudFormation stacks created in the prerequisites that can be found in the AWS CloudFormation console.

Conclusion

This blog is a guide for developers to integrate Apple’s CallKit into their iOS applications while using Amazon Chime SDK for audio/video meetings and VoIP calling. CXProvider and CXCallController are used to initiate/end calls from within the Customer Application and to perform actions on active call to sync up with Apple’s CallKit UI. For the most up to date code sample for Apple CallKit integration, reference our iOS Demo app in Amazon Chime SDK for iOS Github repository. If you still have questions regarding  Apple’s CallKit, other suggestions for improvements, or feature requests, please feel free to create an issue in the repository.