<repo_name>paynerc/signal-2019-voip-ios<file_sep>/VoiceDialerSDKTester/VoiceDialerSDKTester/View Controllers/ViewController.swift
//
//  ViewController.swift
//  VoiceDialerSDKTester
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import UIKit
import VoiceDialerSDK

class ViewController: UIViewController {

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

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask{
        get{
            return .portrait
        }
    }

    @IBAction func placeCall(_ sender: Any) {
        
        // Set up your Twilio Function using twilio-function-access-token.js
        // and invoke it to generate a token
        // e.g. https://YOUR_TWILIO_FUNCTION.twil.io/voiceaccesstoken?identity=Alice&apiKeySecret=SECRET&appSid=AP_SID&apiKeySid=SK_API_KEY_SID
        
        VoiceDialer.placeCall(identity: "ACME Support",
                              accessToken: "ACCESS_TOKEN",
                              callParameters: ["orderID" : "12345",
                                               "name": "Alice"],
                              delegate: self)
    }
}

extension ViewController: VoiceDialerDelegate {
    func callStateDidChange(state: CallState, error: Error?) {
        print("callStateDidChange: \(state.rawValue), error: \(error != nil ? error!.localizedDescription : "N/A")")
    }

    func callDidFailToConnect(error: Error) {
        print("callDidFailToConnect: \(error.localizedDescription)")
    }

    func callWillHangup() {
        print("Hangup button pressed")
    }

    func callWillMute() {
        print("Mute button pressed")
    }

    func callWillUmute() {
        print("Unmute button pressed")
    }

    func callWillHold() {
        print("Hold button pressed")
    }

    func callWillResume() {
        print("Unhold button pressed")
    }
}
<file_sep>/VoiceDialerSDK/VoiceDialerSDK/View Controllers/InCallViewController.swift
//
//  InCallViewController.swift
//  VoiceDialerSDK
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import UIKit

class InCallViewController: UIViewController {

    @IBOutlet weak var contactImageContainer: UIView!
    @IBOutlet weak var identityLabel: UILabel!
    @IBOutlet weak var stateLabel: UILabel!
    @IBOutlet weak var muteButton: RoundButton!
    @IBOutlet weak var hangupButton: RoundButton!

    // MARK: - Instance properties
    weak var callController: CallController?

    override func viewDidLoad() {
        super.viewDidLoad()

        view.layer.cornerRadius = 10.0
        view.layer.shadowColor = UIColor.black.cgColor
        view.layer.shadowOffset = CGSize.init(width: 2, height: 2)
        view.layer.shadowRadius = 3.5
        view.layer.shadowOpacity = 0.4

        contactImageContainer.makeRound()

        if let callController = callController {
            identityLabel.text = callController.identity

            muteButton.isSelected = callController.isMuted
        }

        let recognizerDoubleTap = UITapGestureRecognizer(target: self, action: #selector(doubleTap))
        recognizerDoubleTap.numberOfTapsRequired = 2
        view.addGestureRecognizer(recognizerDoubleTap)
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask{
        get{
            return .portrait
        }
    }

    @IBAction func muteButtonPressed(_ sender: Any) {
        guard let callController = callController else {
            return
        }

        callController.isMuted = !callController.isMuted
        muteButton.isSelected = callController.isMuted
    }

    @IBAction func hangupButtonPressed(_ sender: Any) {
        guard let callController = callController else {
            return
        }

        callController.hangupCall()
    }

    @objc func doubleTap() {
        guard let callController = callController else {
            return
        }

        callController.displayDialerView()
    }
}
<file_sep>/VoiceDialerSDK/VoiceDialerSDK/UI Resources/Custom Controls/RoundButton.swift
//
//  RoundButton.swift
//  VoiceDialerSDK
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import UIKit

// TODO:
// - Make @IBDesignable
// - Add properties and mark as @IBInspectable for
//     gap
//     imageOnTop
//     selectedButtonColor
//     unselectedButtonColor ?

class RoundButton: UIButton {
    override func awakeFromNib() {
        makeRound()
        centerImageAndText(gap: 5.0, imageOnTop: true)
    }

    override var bounds: CGRect {
        didSet {
            makeRound()
            centerImageAndText(gap: 5.0, imageOnTop: true)
        }
    }

    override var isSelected: Bool {
        didSet {
            self.backgroundColor = (isSelected == true ? UIColor.Buttons.Selected : UIColor.Buttons.Unselected)
        }
    }

    // TODO: While this generally works, it doesn't quite do exactly what I want. I would like all the text for all the
    //       buttons to be aligned. I think we need to do a bit more work in here with the maths to make it so that it
    //       behaves consistently across all the buttons.
    private func centerImageAndText(gap: CGFloat, imageOnTop: Bool) {
        guard let imageView = self.imageView,
            let titleLabel = self.titleLabel,
            let _ = titleLabel.text else { return }

        let sign: CGFloat = imageOnTop ? 1 : -1
        let imageSize = imageView.frame.size
        self.titleEdgeInsets = UIEdgeInsets(top: (imageSize.height + gap) * sign, left: -imageSize.width, bottom: 0, right: 0)

        let titleSize = titleLabel.bounds.size;
        self.imageEdgeInsets = UIEdgeInsets(top: -(titleSize.height + gap) * sign, left: 0, bottom: 0, right: -titleSize.width)
    }
}
<file_sep>/VoiceDialerSDK/VoiceDialerSDK/Helpers/CallState.swift
//
//  CallState.swift
//  VoiceDialerSDK
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import Foundation

@objc public enum CallState: Int {
    case Connecting
    case Ringing
    case Connected
    case Reconnecting
    case Disconnected
}
<file_sep>/VoiceDialerSDK/VoiceDialerSDK/Helpers/CallController.swift
//
//  CallController.swift
//  VoiceDialerSDK
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import UIKit
import TwilioVoice

class CallController: NSObject {

    let overlayWindow: OverlayWindow = OverlayWindow(frame: UIScreen.main.bounds)

    var identity: String
    var accessToken: String
    var callParameters: [String : String]
    var delegate: VoiceDialerDelegate?

    var isMuted: Bool {
        get {
            guard let call = call else {
                return false
            }
            return call.isMuted
        }
        set {
            isMuted == false ? delegate?.callWillMute() : delegate?.callWillUmute()
            call?.isMuted = newValue
        }
    }

    var isOnHold: Bool {
        get {
            guard let call = call else {
                return false
            }
            return call.isOnHold
        }
        set {
            isOnHold == false ? delegate?.callWillHold() : delegate?.callWillResume()
            call?.isOnHold = newValue
        }
    }

    var isOnSpeaker: Bool = false {
        didSet {
            do {
                try AVAudioSession.sharedInstance().overrideOutputAudioPort(isOnSpeaker == true ? .speaker : .none)
            } catch {
            }
        }
    }

    var callState: CallState = .Connecting {
        didSet {
            if let dialerViewController = dialerViewController {
                dialerViewController.callState = callState
            }
        }
    }

    private var call: TVOCall?
    private var connectedAt: Date?
    private var timer: Timer?

    // View Controllers for the various views
    private var dialerViewController: DialerViewController?
    private var inCallViewController: InCallViewController?

    init(identity: String, accessToken: String, callParameters: [String : String], delegate: VoiceDialerDelegate?) {
        self.identity = identity
        self.accessToken = accessToken
        self.callParameters = callParameters
        self.delegate = delegate

        super.init()
    }

    func initiateCall() {
        displayDialerView()

        let connectOptions = TVOConnectOptions(accessToken: accessToken) { (builder) in
            builder.params = self.callParameters
        }

        call = TwilioVoice.connect(with: connectOptions, delegate: self)
    }

    func displayDialerView() {
        // If we don't have the dialer view, create it
        if dialerViewController == nil {
            dialerViewController = VoiceDialer.storyboard().instantiateViewController(withIdentifier: "DialerViewController") as? DialerViewController
        }

        if let dialerViewController = dialerViewController,
            let rootViewController = VoiceDialer.sharedInstance.overlayWindow.rootViewController {
            let overlayWindow = VoiceDialer.sharedInstance.overlayWindow

            dialerViewController.callController = self

            overlayWindow.frame = UIScreen.main.bounds
            VoiceDialer.sharedInstance.overlayWindow.makeKeyAndVisible()

            if rootViewController.presentedViewController != nil {
                rootViewController.presentedViewController?.dismiss(animated: true) {
                    self.inCallViewController = nil
                    rootViewController.present(dialerViewController, animated: true)
                }
            } else {
                rootViewController.present(dialerViewController, animated: true)
            }
        }
    }

    func displayInCallView() {
        // If we don't have the in call view, create it
        if inCallViewController == nil {
            inCallViewController = VoiceDialer.storyboard().instantiateViewController(withIdentifier: "InCallViewController") as? InCallViewController
        }

        if let inCallViewController = inCallViewController,
            let rootViewController = VoiceDialer.sharedInstance.overlayWindow.rootViewController {
            let overlayWindow = VoiceDialer.sharedInstance.overlayWindow

            inCallViewController.callController = self

            let width = UIScreen.main.bounds.width - 20
            inCallViewController.view.frame = CGRect(x: 0, y: 0, width: width, height: 100)
            let inCallWindowFrame = CGRect(x: 10, y: 40, width: width, height: 100)

            VoiceDialer.sharedInstance.overlayWindow.makeKeyAndVisible()

            if rootViewController.presentedViewController != nil {
                rootViewController.presentedViewController?.dismiss(animated: true) {
                    overlayWindow.frame = inCallWindowFrame
                    self.dialerViewController = nil
                    rootViewController.present(inCallViewController, animated: true)
                }
            } else {
                overlayWindow.frame = inCallWindowFrame
                rootViewController.present(inCallViewController, animated: true)
            }
        }
    }
    
    @objc func updateConnectionDuration() {
        if let connectedAt = connectedAt {
            let elapsed = Date().timeIntervalSince(connectedAt)
            let minutes = Int(elapsed) / 60 % 60
            let seconds = Int(elapsed) % 60

            let stringDuration = String(format:"%02i:%02i", minutes, seconds)

            if let dialerViewController = dialerViewController {
                if dialerViewController.isViewLoaded {
                    dialerViewController.stateLabel.text = stringDuration
                }
            }

            if let inCallViewController = inCallViewController {
                if inCallViewController.isViewLoaded {
                    inCallViewController.stateLabel.text = stringDuration
                }
            }
        }
    }

    // MARK: - Call Actions
    func hangupCall() {
        delegate?.callWillHangup()

        if let call = call,
            call.state != .disconnected {
            call.disconnect()
        } else {
            cleanupUI()
        }
    }

    func cleanupUI() {
        if let rootViewController = VoiceDialer.sharedInstance.overlayWindow.rootViewController {
            let overlayWindow = VoiceDialer.sharedInstance.overlayWindow
            // Reset the overlay window's frame so the animation works better
            overlayWindow.frame = UIScreen.main.bounds
            rootViewController.presentedViewController?.dismiss(animated: true, completion: {
                self.dialerViewController = nil
                self.inCallViewController = nil
                VoiceDialer.sharedInstance.cleanupActiveCall()
            })
        }
    }
}

extension CallController: TVOCallDelegate {
    func call(_ call: TVOCall, didFailToConnectWithError error: Error) {
        callState = .Disconnected

        if let dialerViewController = dialerViewController {
            dialerViewController.displayError(message: error.localizedDescription)
        }

        delegate?.callDidFailToConnect(error: error)
    }

    func callDidStartRinging(_ call: TVOCall) {
        callState = .Ringing

        delegate?.callStateDidChange(state: callState, error: nil)
    }

    func callDidConnect(_ call: TVOCall) {
        callState = .Connected
        connectedAt = Date()

        let timer = Timer(timeInterval: 1.0,
                          target: self,
                          selector: #selector(updateConnectionDuration),
                          userInfo: nil,
                          repeats: true)
        RunLoop.current.add(timer, forMode: .common)
        self.timer = timer

        delegate?.callStateDidChange(state: callState, error: nil)

        displayInCallView()
    }

    func call(_ call: TVOCall, isReconnectingWithError error: Error) {
        callState = .Reconnecting

        delegate?.callStateDidChange(state: callState, error: error)
    }

    func callDidReconnect(_ call: TVOCall) {
        callState = .Connected

        delegate?.callStateDidChange(state: callState, error: nil)
    }

    func call(_ call: TVOCall, didDisconnectWithError error: Error?) {
        callState = .Disconnected

        timer?.invalidate()
        timer = nil

        if let error = error {
            dialerViewController?.displayError(message: error.localizedDescription)
        }

        delegate?.callStateDidChange(state: callState, error: error)

        cleanupUI()
    }
}
<file_sep>/README.md
# Signal 2019 Voice Demo - Mobile VoIP contextual Calling

This repository contains an iOS project, a call orchestrator, an access token generator, and a simple JS application that demonstrate Mobile VoIP contextual Calling. It is a fictitious online shopping application (hard coded PNG order form) that Alice uses to call Agent Bob regarding her order.

To set up this demo, you will need a [Twilio](https://twilio.com) account

## TwiML App & Call Orchestration
You can use [twiml-bin-call-orchestrator.xml](twiml-bin-call-orchestrator.xml) as your call orchestrator. Simply create a TwiML bin with contents and point your TwiML App to it

## Access Token
You will need an access token for Alice (Phone App) and for Agent Bob (JS Client). Use [twilio-function-access-token.js](twilio-function-access-token.js) as your access generator. Simply create a new Twilio Function and using a browser, enter the URL with the required params as specified in the file.

## Getting started with iOS App

The `VoiceDialerSDK.framework` and `VoiceDialerSDKTester.app` are written using Swift 5.0 and Xcode 10.2.

### Dependencies

The project uses Carthage to manage the `TwilioVoice.framework` dependency. After cloning this repository, run the `carthage bootstrap` command to fetch the `TwilioVoice.framework` dependency.

### Building

Before compiling the framework and application, be sure to replace the `ACCESS_TOKEN` place holder with a valid access token for your Twilio account. For more information, take a look at Twilio's [Access Token](https://www.twilio.com/docs/voice/voip-sdk/ios/get-started#access-tokens) documentation.

## Getting started with Agent Bob's JS App
Update [quickstart.js](JSClient/quickstart.js) with Agent Bob's access token and save the file. You will now need an http server to that serves Agent Bob's Application. When Agent Bob receives a call, it will show who is calling and the order they are calling about.

<file_sep>/VoiceDialerSDK/VoiceDialerSDK/UI Resources/OverlayWindow.swift
//
//  OverlayWindow.swift
//  VoiceDialerSDK
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import UIKit

class OverlayWindow: UIWindow {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        backgroundColor = UIColor.clear
        windowLevel = .statusBar - 1

        rootViewController = UIViewController()
    }
}
<file_sep>/VoiceDialerSDK/VoiceDialerSDK/VoiceDialerDelegate.swift
//
//  VoiceDialerDelegate.swift
//  VoiceDialerSDK
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import UIKit

@objc public protocol VoiceDialerDelegate: AnyObject {

    func callStateDidChange(state: CallState, error: Error?)

    func callDidFailToConnect(error: Error)

    func callWillHangup()

    func callWillMute()

    func callWillUmute()

    func callWillHold()

    func callWillResume()
}
<file_sep>/VoiceDialerSDK/VoiceDialerSDK/VoiceDialer.swift
//
//  VoiceDialer.swift
//  VoiceDialerSDK
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import UIKit

@objc public class VoiceDialer: NSObject {

    // Singleton creator
    static let sharedInstance: VoiceDialer = {
        let instance = VoiceDialer()

        // Any other initialization as required...

        return instance
    }()

    // MARK: - Instance properties
    let overlayWindow: OverlayWindow = OverlayWindow(frame: UIScreen.main.bounds)
    var callController: CallController?

    @objc public class func placeCall(identity: String,
                                      accessToken: String,
                                      callParameters: [String : String],
                                      delegate: VoiceDialerDelegate?) {

        // If we don't have an active call, we can start one...
        if sharedInstance.callController != nil {
            print("Already in an active call...");
            return
        }

        let callController = CallController(identity: identity,
                                            accessToken: accessToken,
                                            callParameters: callParameters,
                                            delegate: delegate)

        callController.initiateCall()

        sharedInstance.callController = callController
    }

    @objc public class func hangupCall() {
        guard let callController = sharedInstance.callController else {
            return
        }

        callController.hangupCall()
    }

    func cleanupActiveCall() {
        callController = nil
        resetOverlayWindow()
    }

    private func resetOverlayWindow() {
        overlayWindow.frame = UIScreen.main.bounds
        overlayWindow.isHidden = true
    }

    class func storyboard() -> UIStoryboard {
        let frameworkBundle = Bundle(for: VoiceDialer.self)
        let storyboard = UIStoryboard(name: "Main", bundle: frameworkBundle)

        return storyboard
    }
}
<file_sep>/VoiceDialerSDK/VoiceDialerSDK/View Controllers/DialerViewController.swift
//
//  DialerViewController.swift
//  VoiceDialerSDK
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import UIKit

class DialerViewController: UIViewController {

    @IBOutlet weak var contactImageContainer: UIView!
    @IBOutlet weak var identityLabel: UILabel!
    @IBOutlet weak var stateLabel: UILabel!
    @IBOutlet weak var muteButton: RoundButton!
    @IBOutlet weak var keypadButton: RoundButton!
    @IBOutlet weak var speakerButton: RoundButton!
    @IBOutlet weak var holdButton: RoundButton!
    @IBOutlet weak var hangupButton: RoundButton!

    // MARK: - Instance properties
    weak var callController: CallController?

    var callState: CallState = .Connecting {
        didSet {
            switch callState {
            case .Connecting:
                stateLabel.text = "Connecting..."
            case .Ringing:
                stateLabel.text = "Ringing..."
            case .Connected:
                stateLabel.text = "Connected..."
                muteButton.isEnabled = true
                // keypadButton.isEnabled = true
                speakerButton.isEnabled = true
                holdButton.isEnabled = true
            case .Reconnecting:
                stateLabel.text = "Reconnecting..."
                muteButton.isEnabled = false
                keypadButton.isEnabled = false
                speakerButton.isEnabled = false
                holdButton.isEnabled = false
            case .Disconnected:
                stateLabel.text = "Disconnected..."
                muteButton.isEnabled = false
                keypadButton.isEnabled = false
                speakerButton.isEnabled = false
                holdButton.isEnabled = false
            }
        }
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask{
        get{
            return .portrait
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        contactImageContainer.makeRound()
        muteButton.isEnabled = false
        keypadButton.isEnabled = false
        speakerButton.isEnabled = false
        holdButton.isEnabled = false

        if let callController = callController {
            callState = callController.callState
            identityLabel.text = callController.identity

            // Set button states as necessary
            muteButton.isSelected = callController.isMuted
            holdButton.isSelected = callController.isOnHold
            speakerButton.isSelected = callController.isOnSpeaker
        }

        let recognizerDoubleTap = UITapGestureRecognizer(target: self, action: #selector(doubleTap))
        recognizerDoubleTap.numberOfTapsRequired = 2
        view.addGestureRecognizer(recognizerDoubleTap)
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        UIDevice.current.isProximityMonitoringEnabled = true
    }

    override func viewWillDisappear(_ animated: Bool) {
        UIDevice.current.isProximityMonitoringEnabled = false

        super.viewWillDisappear(animated)
    }

    func displayError(message: String) {
        let alert = UIAlertController(title: message, message: nil, preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default) { (action) in
            self.callController?.cleanupUI()
        }
        alert.addAction(okAction)

        self.present(alert, animated: true)
    }

    @IBAction func muteButtonPressed(_ sender: Any) {
        guard let callController = callController else {
            return
        }

        callController.isMuted = !callController.isMuted
        muteButton.isSelected = callController.isMuted
    }

    @IBAction func keypadButtonPressed(_ sender: Any) {
        print("Keypad button pressed!")
    }

    @IBAction func speakerButtonPressed(_ sender: Any) {
        guard let callController = callController else {
            return
        }

        callController.isOnSpeaker = !callController.isOnSpeaker
        speakerButton.isSelected = callController.isOnSpeaker
    }

    @IBAction func holdButtonPressed(_ sender: Any) {
        guard let callController = callController else {
            return
        }

        callController.isOnHold = !callController.isOnHold
        holdButton.isSelected = callController.isOnHold
    }

    @IBAction func hangupButtonPressed(_ sender: Any) {
        guard let callController = callController else {
            return
        }

        callController.hangupCall()
    }

    @objc func doubleTap() {
        guard let callController = callController else {
            return
        }

        if callState == .Connected {
            callController.displayInCallView()
        }
    }
}
<file_sep>/VoiceDialerSDK/VoiceDialerSDK/UI Resources/Custom Controls/UIViewExtension.swift
//
//  UIViewExtension.swift
//  VoiceDialerSDK
//
//  Created by <NAME> on 8/2/19.
//  Copyright © 2019 <NAME>. All rights reserved.
//

import UIKit

extension UIView {

    func makeRound() {
        self.layer.cornerRadius = self.bounds.size.width / 2
    }
}
<file_sep>/twilio-function-access-token.js
// The following http params are required to generate an access token: 
// identity=
// apiKeySecret=
// &appSid=
// apiKeySid=


exports.handler = function(context, event, callback) {
    const response = new Twilio.Response();

    // Add CORS Headers
    let headers = {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET",
        "Content-Type": "application/json"
    };
  
    // Set headers in response
    response.setHeaders(headers);
    
    const identity = event.identity;
    const apiKeySecret = event.apiKeySecret;
    const appSid = event.appSid;
    const apiKeySid = event.apiKeySid;

    // grab the identity from the URL
    if (!identity || !apiKeySecret || !appSid || !apiKeySecret) {
        response.setStatusCode(400);
        callback(`Sorry, can't do, missing a required param`);
        return;
    }
  
    const AccessToken = require('twilio').jwt.AccessToken;
    const VoiceGrant = AccessToken.VoiceGrant;

    // Create an access token which we will sign and return to the client,
    // containing the grant we just created
    const voiceGrant = new VoiceGrant({
        // This is the TwiML app that the client will call out to. This parameter is
        // required, even if the client wants to call a PSTN number directly.
        outgoingApplicationSid: appSid,

        // This would allow push notifications for mobile
        //pushCredentialSid: null,

        // This client can receive calls
        incomingAllow: true
    });

  // Create an access token which we will sign and return to the client,
  // containing the grant we just created
  const token = new AccessToken(context.ACCOUNT_SID, apiKeySid, apiKeySecret);
  token.addGrant(voiceGrant);
  token.identity = identity;

  // Include identity and token in a JSON response
  response.setBody({
    'identity': identity,
    'token': token.toJwt()
  });
  
  response.setStatusCode(200);
  callback(null, response);
};