team-telnyx / telnyx-webrtc-ios

The Telnyx iOS WebRTC Client SDK provides all the functionality you need to start making voice calls from an iPhone.
MIT License
18 stars 12 forks source link
ios voip webrtc

telnyx-webrtc-ios

Enable Telnyx real-time communication services on iOS. :telephone_receiver: :fire:

Project structure:

Project Setup:

  1. Clone the repository
  2. Run the command pod install to install de dependencies inside the project root folder.
  3. Open the Workspace : TelnyxRTC.xcworkspace
  4. You will find 3 targets to build:
    • The SDK
    • The SDK Tests
    • The Demo App

Screen Shot 2021-05-04 at 18 34 45

  1. Select the target TelnyxRTC (TelnyxRTC Project) to build the SDK

    Screen Shot 2021-05-04 at 18 35 18

  1. Select the target TelnyxRTCTests to run the tests. You will need to long press over the Run button and select Build for testing

Screen Shot 2021-03-03 at 10 04 05

  1. Select target TelnyxWebRTCDemo to run the demo app. The SDK should be manually builded in order to get the app running (Step 5)

  2. Enjoy 😎

    Credentials Outbound call Incoming call


SIP Credentials

In order to start making and receiving calls using the TelnyxRTC SDK you will need to get SIP Credentials:

  1. Access to https://portal.telnyx.com/
  2. Sign up for a Telnyx Account.
  3. Create a Credential Connection to configure how you connect your calls.
  4. Create an Outbound Voice Profile to configure your outbound call settings and assign it to your Credential Connection.

For more information on how to generate SIP credentials check the Telnyx WebRTC quickstart guide.


Adding Telnyx SDK to your iOS Client Application:

Currently the iOS SDK is supported using cocoapods.

Cocoapods

If your xcode project is not using cocoapods yet, you will need to configure it.

  1. Open your podfile and add the TelnyxRTC.

    pod 'TelnyxRTC', '~> 0.1.0'
  2. Install your pods. You can add the flag --repo-update to ensure your cocoapods has the specs updated.

    pod install --repo-update
  3. Open your .xcworkspace

  4. Import TelnyxRTC at the top level of your class:

    import TelnyxRTC
  5. Disable BITCODE (The GoogleWebRTC dependency has BITCODE disabled): Go to the Build Settings tab of your app target, search for “bitcode” and set it to “NO”

    Screen Shot 2021-05-07 at 17 46 08

  6. Enable VoIP and Audio background modes: Go to Signing & Capabilities tab, press the +Capability button and add those background modes:

    Screen Shot 2021-05-07 at 17 46 54

  7. Go to your Info.plist file and add the “Privacy - Microphone Usage Description” key with a description that your app requires microphone access in order to make VoIP calls.

    Screen Shot 2021-05-07 at 17 48 17

  8. You are all set!

Swift Package Manager

Xcode has a built-in support for Swift package manager. To add a package :

  1. Select Files > Add Packages
  2. On the Swift Package Manager Screen, Search for the https://github.com/team-telnyx/telnyx-webrtc-ios.git package.
  3. Select the main brach and click Add Package

Screen Shot 2021-05-07 at 17 48 17

NB: if Add Package is stuck downloading try File > Packages > Reset Package Caches or Run the command rm -rf ~/Library/Caches/org.swift.swiftpm/ in terminal

Read more in Apple documentation

Hint: Use either Cocoapods or Swift Package Manager for Individual Packages to avoid Duplicate binaries

Usage

Telnyx client setup

// Initialize the client
let telnyxClient = TxClient()

// Register to get SDK events
telnyxClient.delegate = self

// Setup yor connection parameters.

// Set the login credentials and the ringtone/ringback configurations if required.
// Ringtone / ringback tone files are not mandatory.
// You can user your sipUser and password
let txConfigUserAndPassowrd = TxConfig(sipUser: sipUser,
                                       password: password,
                                       pushDeviceToken: "DEVICE_APNS_TOKEN",
                                       ringtone: "incoming_call.mp3",
                                       ringBackTone: "ringback_tone.mp3",
                                       //You can choose the appropriate verbosity level of the SDK.
                                       //Logs are disabled by default
                                       logLevel: .all)

// Or use a JWT Telnyx Token to authenticate
let txConfigToken = TxConfig(token: "MY_JWT_TELNYX_TOKEN",
                             pushDeviceToken: "DEVICE_APNS_TOKEN",
                             ringtone: "incoming_call.mp3",
                             ringBackTone: "ringback_tone.mp3",
                             //You can choose the appropriate verbosity level of the SDK. Logs are disabled by default
                             logLevel: .all)

do {
   // Connect and login
   // Use `txConfigUserAndPassowrd` or `txConfigToken`
   try telnyxClient.connect(txConfig: txConfigToken)
} catch let error {
   print("ViewController:: connect Error \(error)")
}

// You can call client.disconnect() when you're done.
Note: you need to relese the delegate manually when you are done.

// Disconnecting and Removing listeners.
telnyxClient.disconnect();

// Release the delegate
telnyxClient.delegate = nil

Telnyx client delegate

You will need to instantiate the client and set the delegate.

// Initialize the client
let telnyxClient = TxClient()

// Register to get SDK events
telnyxClient.delegate = self

Then you will receive the following events:

extension ViewController: TxClientDelegate {

    func onRemoteCallEnded(callId: UUID) {
        // Call has been removed internally.
    }

    func onSocketConnected() {
       // When the client has successfully connected to the Telnyx Backend.
    }

    func onSocketDisconnected() {
       // When the client from the Telnyx backend
    }

    func onClientError(error: Error)  {
        // Something went wrong.
    }

    func onClientReady()  {
       // You can start receiving incoming calls or
       // start making calls once the client was fully initialized.
    }

    func onSessionUpdated(sessionId: String)  {
       // This function will be executed when a sessionId is received.
    }

    func onIncomingCall(call: Call)  {
       // Someone is calling you.
       // This delegate method will be called when the app is in foreground and the Telnyx Client is connected.
    }

    func onPushCall(call: Call) {
       // If you have configured Push Notifications and app is in background or the Telnyx Client is disconnected
       // this delegate method will be called after the push notification is received.
       // Update the current call with the incoming call
       self.currentCall = call 
    }

    // You can update your UI from here based on the call states.
    // Check that the callId is the same as your current call.
    func onCallStateUpdated(callState: CallState, callId: UUID) {
      // handle the new call state
      switch (callState) {
      case .CONNECTING:
          break
      case .RINGING:
          break
      case .NEW:
          break
      case .ACTIVE:
          break
      case .DONE:
          break
      case .HELD:
          break
      }
    }
}

Calls

Outboud call

   // Create a client instance
   self.telnyxClient = TxClient()

   // Asign the delegate to get SDK events
   self.telnyxClient?.delegate = self

   // Connect the client (Check TxClient class for more info)
   self.telnyxClient?.connect(....)

   // Create the call and start calling
   self.currentCall = try self.telnyxClient?.newCall(callerName: "Caller name",
                                                     callerNumber: "155531234567",
                                                     // Destination is required and can be a phone number or SIP URI
                                                     destinationNumber: "18004377950",
                                                     callId: UUID.init())

This is a general example: In order to fully support outbound calls you will need to implement CallKit to properly handle audio states. For more information check Audio Session Handling WebRTC + CallKit section.

Inbound call

How to answer an incoming call:

//Init your client
func initTelnyxClient() {
   //
   self.telnyxClient = TxClient()

   // Asign the delegate to get SDK events
   self.telnyxClient?.delegate = self

   // Connect the client (Check TxClient class for more info)
   self.telnyxClient?.connect(....)
}

extension ViewController: TxClientDelegate {
    //....
    func onIncomingCall(call: Call) {
        // We are automatically answering any incoming call as an example, but
        // maybe you want to store a reference of the call, and answer the call after a button press.
        self.myCall = call.answer()
    }
}

This is a general example: In order to fully support inbound calls you will need to implement PushKit + CallKit. For more information check Setting up VoIP push notifications section.



Setting up VoIP push notifications:

In order to receive incoming calls while the app is running in background or closed, you will need to perform a set of configurations over your Mission Control Portal Account and your application.


VoIP Push - Portal setup

During this process you will learn how to create a VoIP push credential and assign the credential to a SIP Connection.

This process requires:

For complete instructions on how to setup Push Notifications got to this link.


VoIP Push - App Setup

The following setup is required in your application to receive Telnyx VoIP push notifications:

a. Add Push Notifications capability to your Xcode project

  1. Open the xcode workspace associated with your app.

  2. In the Project Navigator (the left-hand menu), select the project icon that represents your mobile app.

  3. In the top-left corner of the right-hand pane in Xcode, select your app's target.

  4. Press the +Capabilities button.

    Screen Shot 2021-11-26 at 13 34 12

  5. Enable Push Notifications

    Screen Shot 2021-11-26 at 13 35 51

b. Configure PushKit into your app:

  1. Import pushkit
    import PushKit
  2. Initialize PushKit:
    
    private var pushRegistry = PKPushRegistry.init(queue: DispatchQueue.main)
    ...

func initPushKit() { pushRegistry.delegate = self pushRegistry.desiredPushTypes = Set([.voIP]) }

3. Implement PKPushRegistryDelegate 
```Swift
extension AppDelegate: PKPushRegistryDelegate {

    // New push notification token assigned by APNS.
    func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) {
        if (type == .voIP) {
            // This push notification token has to be sent to Telnyx when connecting the Client.
            let deviceToken = credentials.token.reduce("", {$0 + String(format: "%02X", $1) })
            UserDefaults.standard.savePushToken(pushToken: deviceToken)
        }
    }

    func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
        if (type == .voIP) {
            // Delete incoming token in user defaults
            let userDefaults = UserDefaults.init()
            userDefaults.deletePushToken()
        }
    }

    /**
     This delegate method is available on iOS 11 and above. 
     */
    func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
        if (payload.type == .voIP) {
            self.handleVoIPPushNotification(payload: payload)
        }

        if let version = Float(UIDevice.current.systemVersion), version >= 13.0 {
            completion()
        }
    }

    func handleVoIPPushNotification(payload: PKPushPayload) {
        if let metadata = payload.dictionaryPayload["metadata"] as? [String: Any] {

            let callId = metadata["call_id"] as? String
            let callerName = (metadata["caller_name"] as? String) ?? ""
            let callerNumber = (metadata["caller_number"] as? String) ?? ""
            let caller = callerName.isEmpty ? (callerNumber.isEmpty ? "Unknown" : callerNumber) : callerName

            let uuid = UUID(uuidString: callId)

            // Re-connect the client and process the push notification when is received.
            // You will need to use the credentials of the same user that is receiving the call. 
            let txConfig = TxConfig(sipUser: sipUser,
                                password: password,
                                pushDeviceToken: "APNS_PUSH_TOKEN")

            //Call processVoIPNotification method 

            try telnyxClient?.processVoIPNotification(txConfig: txConfig, serverConfiguration: serverConfig,pushMetaData: metadata)

            // Report the incoming call to CallKit framework.
            let callHandle = CXHandle(type: .generic, value: from)
            let callUpdate = CXCallUpdate()
            callUpdate.remoteHandle = callHandle
            callUpdate.hasVideo = false

            provider.reportNewIncomingCall(with: uuid, update: callUpdate) { error in
                  if let error = error {
                      print("AppDelegate:: Failed to report incoming call: \(error.localizedDescription).")
                  } else {
                      print("AppDelegate:: Incoming call successfully reported.")
                  }
            }
    }
}
  1. If everything is correctly set-up when the app runs APNS should assign a Push Token.
  2. In order to receive VoIP push notifications. You will need to send your push token when connecting to the Telnyx Client.

 let txConfig = TxConfig(sipUser: sipUser,
                         password: password,
                         pushDeviceToken: "DEVICE_APNS_TOKEN",
                         //You can choose the appropriate verbosity level of the SDK. 
                         logLevel: .all)

 // Or use a JWT Telnyx Token to authenticate
 let txConfigToken = TxConfig(token: "MY_JWT_TELNYX_TOKEN",
                             pushDeviceToken: "DEVICE_APNS_TOKEN",
                             //You can choose the appropriate verbosity level of the SDK. Logs are disabled by default
                             logLevel: .all)

For more information about Pushkit you can check the official Apple docs.

Important:

c. Configure CallKit into your App:

PushKit requires you to use CallKit when handling VoIP calls. CallKit ensures that apps providing call-related services on a user’s device work seamlessly together on the user's device, and respect features like Do Not Disturb. CallKit also operates the system's call-related UIs, including the incoming or outgoing call screens. Use CallKit to present these interfaces and manage interactions with them.

For more information about CallKit you can check the official Apple docs.

General Setup:

  1. Import CallKit:

    import CallKit
  2. Initialize CallKit

    func initCallKit() {
    let configuration = CXProviderConfiguration(localizedName: "TelnyxRTC")
    configuration.maximumCallGroups = 1
    configuration.maximumCallsPerCallGroup = 1
    callKitProvider = CXProvider(configuration: configuration)
    if let provider = callKitProvider {
      provider.setDelegate(self, queue: nil)
    }
    }
  3. Implement CXProviderDelegate methods.

Audio Session Handling WebRTC + CallKit

To get CallKit properly working with the TelnyxRTC SDK you need to set the audio device state based on the CallKit AudioSession state like follows:

extension AppDelegate : CXProviderDelegate {

    ...

    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        self.telnyxClient?.enableAudioSession(audioSession: audioSession)
    }

    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        self.telnyxClient?.disableAudioSession(audioSession: audioSession)
    }
}


Reporting calls with CallKit

To properly report calls to callKit with right statuses, you need to invoke the following callKit methods at the right instances:

  1. Starting A New Call : When ever you start a call, report to callkit using the provider.reportCall() method.
        let callUpdate = CXCallUpdate()

        callUpdate.remoteHandle = callHandle
        callUpdate.supportsDTMF = true
        callUpdate.supportsHolding = true
        callUpdate.supportsGrouping = false
        callUpdate.supportsUngrouping = false
        callUpdate.hasVideo = false
        provider.reportCall(with: uuid, updated: callUpdate)
  1. When user receives a Call : Use provider.reportNewIncomingCall(with: uuid, update: callUpdate) to report an incoming call. This sends a request to callKit the to provide the native call interface to the user.
        guard let provider = callKitProvider else {
            print("AppDelegate:: CallKit provider not available")
            return
        }

        let callHandle = CXHandle(type: .generic, value: from)
        let callUpdate = CXCallUpdate()
        callUpdate.remoteHandle = callHandle

        provider.reportNewIncomingCall(with: uuid, update: callUpdate) { error in
            // handle error
        }
  1. When callee answers an outgoing call : Use provider.reportOutgoingCall(with: callKitUUID, connectedAt:nil) to report a connected outgoing call. This provides the time when the outgoing call goes to active to callKit.
        if let provider = self.callKitProvider,
            let callKitUUID = self.callKitUUID {
            let date = Date()
            provider.reportOutgoingCall(with: callKitUUID, connectedAt:date)
        }

    NB : This should be used only when the call is outgoing.

Best Practices when Using PushNotifications with Callkit.

  1. When receiving calls from push notifications, it is always required to wait for the connection to the WebSocket before fulfilling the call answer action. This can be achieved by implementing the CXProviderDelegate in the following way (SDK version >=0.1.11):
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        self.telnyxClient?.answerFromCallkit(answerAction: action)
    }

When the answerFromPush(answerAction: action) is called, Callkit sets the call state to connecting to alert the user that the call is being connected. Once the call is active, the timer starts.

Connecting State Active call

The previous SDK versions requires handling the websocket connection state on the client side. It can be done in the following way:

var callAnswerPendingFromPush:Bool = false

func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        print("AppDelegate:: ANSWER call action: callKitUUID [\(String(describing: self.callKitUUID))] action [\(action.callUUID)]")
        if(currentCall != nil){
            self.currentCall?.answer()
        }else {
            self.callAnswerPendingFromPush = true
        }
        action.fulfill()
}

func onPushCall(call: Call) {
        print("AppDelegate:: TxClientDelegate onPushCall() \(call)")
        self.currentCall = call //Update the current call with the incoming call

        //Answer Call if call was answered from callkit
        //This happens when there's a race condition between login and receiving PN
        // when User answer's the call from PN and there's no Call or INVITE message yet. Set callAnswerPendingFromPush = true
        // Whilst we wait fot onPushCall Method to be called
         if(self.callAnswerPendingFromPush){
            self.currentCall?.answer()
            self.callAnswerPendingFromPush = false
        }

}

Likewise for ending calls, the endCallFromCallkit(endAction:action) method should be called from :

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

        self.telnyxClient?.endCallFromCallkit(endAction:action)

}

Calling this method solves the race condition, where call is ended before the client connects to the webserver. This way the call is ended on the callee side once a connection is established.

  1. Logs on the receiver's end are essential for thorough debugging of issues related to push notifications. However, the debugger is not attached when the app is completely killed. To address this, you can simply put the app in the background. VOIP push notifications should then come through, and the debugger should capture all logs.

Handling Multiple Calls

To handle multiples, we can rely on the CXProviderDelegate delegate which invokes functions corresponding to what action was performed on the callkit user interface.

  1. End and Accept or Decline : The end and accept button on the callkit user interface accepts the new call and ends the previous call. Callkit then invokes the CXAnswerCallAction and CXEndCallAction when the end and accept button is pressed. You can handle this scenario by
 var currentCall: Call?
 var previousCall: Call?

 //current calkit uuid
 var callKitUUID: UUID?

     func onIncomingCall(call: Call) {
        guard let callId = call.callInfo?.callId else {
            print("AppDelegate:: TxClientDelegate onIncomingCall() Error unknown call UUID")
            return
        }
        print("AppDelegate:: TxClientDelegate onIncomingCall() callKitUUID [\(String(describing: self.callKitUUID))] callId [\(callId)]")

        self.callKitUUID = call.callInfo?.callId

        //Update the previous call with the current call
        self.previousCall = self.currentCall

        //Update the current call with the incoming call
        self.currentCall = call 
        ..
  }

Subsequently, when the user clicks on the End and Accept or Decline Button, you will need to determine which of these buttons was clicked. You can do that as follows:

    //Callkit invokes CXEndCallAction and  CXAnswerCallAction delegate function for accept and answer
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        print("AppDelegate:: END call action: callKitUUID [\(String(describing: self.callKitUUID))] action [\(action.callUUID)]")

        // if the callKitUUID is the same as the one provided by the action
        // callkit expects you to end the current call
        if(self.callKitUUID == action.callUUID){
            if let onGoingCall = self.previousCall {
                self.currentCall = onGoingCall
                self.callKitUUID = onGoingCall.callInfo?.callId
            }
        }else {
            // callkit expects you to end the previous call
            self.callKitUUID = self.currentCall?.callInfo?.callId
        }
        self.telnyxClient?.endCallFromCallkit(endAction:action)
    }

Note

While handling multiple calls, you should report the call end to callkit properly with the right callUUID. This will keep your active calls with the callkit user interface until there are no more active sessions.

  1. Hold and Accept or Decline: The hold and accept button on the callkit user interface accepts the new call and holds the previous call. Callkit then invokes the CXSetHeldCallAction when the hold and accept button is pressed.
 func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
        print("provider:performSetHeldAction:")
        //request to hold previous call, since we have both the current and previous calls
        previousCall?.hold()
        action.fulfill()
 }

Also, you will need to un-hold the previous call when the current call gets ended on CXEndCallAction.


   func provider(_ provider: CXProvider, perform action: CXEndCallAction) {        
        if(previousCall?.callState == .HELD){
            print("AppDelegate:: call held.. unholding call")
            previousCall?.unhold()
        }
        ...
   }

Note

While handling multiple calls, you should report the call end to callkit properly with the right callUUID. This will keep your active calls with the callkit user interface until there are no more active sessions.

Disable Push Notification

Push notifications can be disabled for the current user by calling :

telnyxClient.disablePushNotifications()

Note : Signing back in, using same credentials will re-enable push notifications.

Privacy Manifest

Support for privacy manifest is added from version 0.1.26

Sending Debug Stats

In case of any need to investigate any issue by Telnyx, please enable the debug stats that will be sent to Telnyx for analysis.

currentCall?.startDebugStats()

Please store the debug_stats_id that would be used for investigation

Documentation:

For more information you can:

  1. Clone the repository
  2. And check the exported documentation in: docs/index.html

Questions? Comments? Building something rad? Join our Slack channel and share.

License

MIT Licence © Telnyx