배웠던 CallKit개념을 활용하여 Raywenderlich에서 제공하는 CallKit 프로젝트를 따라 정리해보겠습니다.
과거에 VoIP앱들은 제한적이고, 알림을 전달하는 과정이 어려웠지만 CallKit의 도입으로 수월해졌다.
🚨 CallKit 기능은 시뮬레이터에서 작동하지 않습니다. (실제기기를 사용해야합니다.)
CallKit은 앱들이 기본전화UI와 통합하는 것을 허용함으로써 VoIP 환경을 개선하는 것을 목표로 하는 프레임워크
CXProvider는 시스템에 대역외(out-of-band)알림을 보고하기위해 사용됩니다. (일반적으로 수신전화와 같은 외부이벤트입니다.)
이벤트가 발생하면 CXProvider는 call update
를 생성하여 시스템에 알립니다. call update
는 새로운,변경된 전화와 관련된 정보를 캡슐화하고 CXCallUpdate 클래스로, 전화하는 사람이름이나 전화화가 비디오 또는 오디오 전용인지와 같은 속성을 표시합니다.
시스템이 앱에 이벤트를 알려줄 대는 CXAction의 인스턴스를 사용합니다. CXAction은 통신작업을 나타내는 추상클래스입니다. 각각의 작업을 위해, CallKit은 CXAction의 다른 구체적인 메서드를 제공합니다.
앱은 CXProviderDelegate프로토콜을 사용하여 CXProvider와 통신할 수 있으며, 이 프로토콜은 provider생명주기이벤트 및 수신 액션에 대한 메서드를 정의합니다.
앱은 CXCallController를 사용하여 StartCallAction과 같은 사용자가 시작한 요청을 시스템에 알립니다.
CXCallController는 이러한 요청을 하기 위해 트랜잭션(transactions)을 사용합니다. CXTransaction으로 표시되는 트랜잭션에는 하나 이상의 CXAction 인스턴스가 포함됩니다. CXCallController는 시스템에 트랜잭션(transactions)을 보냅니다. 모든 것이 정상이면 시스템은 provider에게 적절한 조치를 취하여 응답합니다.
// ProvideDelegate.swift
import AVFoundation
import CallKit
class ProviderDelegate: NSObject {
// 1.provider and the call controller를 참조할 변수 저장합니다.
private let callManager: CallManager
private let provider: CXProvider
init(callManager: CallManager) {
self.callManager = callManager
// 2. static변수를 통해 적절한 CXProviderConfiguration와 함께 초기화
provider = CXProvider(configuration: ProviderDelegate.providerConfiguration)
super.init()
// 3. provider가보내는 이벤트에 응답하도록 델리게이트 설정 (CXProviderDelegate를 채택해야함)
provider.setDelegate(self, queue: nil)
}
// 4. 통화그룹수 1명 지정, 비디오오디오 지원, 전화번호 처리 지정
static var providerConfiguration: CXProviderConfiguration = {
let providerConfiguration = CXProviderConfiguration(localizedName: "Hotline")
providerConfiguration.supportsVideo = true //
providerConfiguration.maximumCallsPerCallGroup = 1 // 통화그룹수 지정
providerConfiguration.supportedHandleTypes = [.phoneNumber]
return providerConfiguration
}()
}
provider를 위한 delegate를 생성합니다.
// ProvideDelegate.swift
func reportIncomingCall(
uuid: UUID,
handle: String,
hasVideo: Bool = false,
completion: ((Error?) -> Void)?
) {
// 1. (시스템에 알리기위해)전화관련데이터를 포함하는 call update생성
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
update.hasVideo = hasVideo
// 2. 이 메서드를 사용하여 시스템에게 수신전화를 알립니다.
provider.reportNewIncomingCall(with: uuid, update: update) { error in
if error == nil {
// 3. 시스템이 호출을 처리한 후 에러가 없다면 Call인스턴스 생성한 후 callManager에 추가
let call = Call(uuid: uuid, handle: handle)
self.callManager.add(call: call)
}
// 4. 에러발생
completion?(error)
}
}
// ProviderDelegate.swift
extension ProviderDelegate: CXProviderDelegate {
func providerDidReset(_ provider: CXProvider) {
stopAudio()
for call in callManager.calls {
call.end()
}
callManager.removeAllCalls()
}
}
// AppDelegate.swift
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
let callManager = CallManager()
var providerDelegate: ProviderDelegate!
class var shared: AppDelegate {
return UIApplication.shared.delegate as! AppDelegate
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
providerDelegate = ProviderDelegate(callManager: callManager)
return true
}
func displayIncomingCall(
uuid: UUID,
handle: String,
hasVideo: Bool = false,
completion: ((Error?) -> Void)?
) {
providerDelegate.reportIncomingCall(
uuid: uuid,
handle: handle,
hasVideo: hasVideo,
completion: completion)
}
}
// CallsViewController.swift
@IBAction private func unwindForNewCall(_ segue: UIStoryboardSegue) {
// 1.
guard
let newCallController = segue.source as? NewCallViewController,
let handle = newCallController.handle
else {
return
}
let videoEnabled = newCallController.videoEnabled
// 2.
let backgroundTaskIdentifier =
UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
AppDelegate.shared.displayIncomingCall(
uuid: UUID(),
handle: handle,
hasVideo: videoEnabled
) { _ in
UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier)
}
}
}
// ProviderDelegate.swift
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// 1. callManager를 통해 call의 UUID를 참조합니다.
guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
action.fail()
return
}
// 2. 앱이 통화에 대한 오디오 세션을 구성합니다. 시스템이 세션을 높은 우선순위로 활성화합니다
configureAudioSession()
// 3. answer메서드는 전화가 활성(active)상태인것을 나타냅니다.
call.answer()
// 4. 행동을 처리할 때 실패하거나 수행하는 것이 중요합니다.
// 프로세스 중에 오류가 없다고 가정하면 fulfill()을 호출하여 성공을 나타낼 수 있습니다.
action.fulfill()
}
// 5. 시스템이 provider의 오디오세션을 활성화하면 delegate에게 알립니다 -> 전화오디오 시작
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
startAudio()
}
// ProviderDelegate.swift
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
// 1. callManager로 call을 참조
guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
action.fail()
return
}
// 2. 통화를 종료하기 위해 통화의 오디오를 중지합니다.
stopAudio()
// 3. 통화상태를 end로 변경하여 다른 클래스가 새로운 상태에 반응할 수 있습니다.
call.end()
// 4. action을 실행할 수 있습니다.
action.fulfill()
// 5. call이 더이상 필요하않으므로 callmanger는 call을 처리(dispose)할 수 있습니다.
callManager.remove(call: call)
}
// CallManager.swift
// CXCallController 통신하기위해 인스턴스 생성
private let callController = CXCallController()
func end(call: Call) {
// 1. 통화종료작업을 생성. (나중에 식별하기위한 call의 uuid를 초기화할 때 전달)
let endCallAction = CXEndCallAction(call: call.uuid)
// 2. 액션을 트랜잭션으로 포장하여 시스템으로 보냅니다.
let transaction = CXTransaction(action: endCallAction)
requestTransaction(transaction)
}
// 3. 시스템은 provider가 이 트랜잭션을 수행하도록 요청하고, 그러면 방금 구현한 메서드가 호출됩니다.
private func requestTransaction(_ transaction: CXTransaction) {
callController.request(transaction) { error in
if let error = error {
print("Error requesting transaction: \(error)")
} else {
print("Requested transaction successfully")
}
}
}
// CallsViewController.swift
override func tableView(
_ tableView: UITableView,
commit editingStyle: UITableViewCell.EditingStyle,
forRowAt indexPath: IndexPath
) {
let call = callManager.calls[indexPath.row]
callManager.end(call: call)
}
CXProviderDelegate 공식문서의 Handling Call Actions을 보면 여러 Action을 처리할 수 있습니다.
통화 보류처리하기
// ProviderDelegate.swift
func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
guard let call = callManager.callWithUUID(uuid: action.callUUID) else {
action.fail()
return
}
// 1. call에 대한 참조를 한 후 action의 isOnHold속성에 따라 상태를 업데이트합니다.
call.state = action.isOnHold ? .held : .active
// 2. 상태에 따라 통화 오디오처리를 시작하거나 중지합니다.
if call.state == .held {
stopAudio()
} else {
startAudio()
}
// 3. action 실행
action.fulfill()
}
// CallManager.swift
func setHeld(call: Call, onHold: Bool) {
let setHeldCallAction = CXSetHeldCallAction(call: call.uuid, onHold: onHold)
let transaction = CXTransaction()
transaction.addAction(setHeldCallAction)
requestTransaction(transaction)
}
// CallsViewController.swift
override func tableView(
_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath
) {
let call = callManager.calls[indexPath.row]
call.state = call.state == .held ? .active : .held
callManager.setHeld(call: call, onHold: call.state == .held)
tableView.reloadData()
}
// ProviderDelegate.swift
// provider는 발신전화요청이 올 때 이 메서드를 호출합니다.
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
let call = Call(uuid: action.callUUID, outgoing: true,
handle: action.handle.value)
// 1. 오디오세션을 구성합니다. 단지 configuration(구성)만 한 후, 실제 호출은 provider(_:didActivate)
configureAudioSession()
// 2. 델리게이트는 call의 생명주기를 관리합니다. 처음에 발신전화가 연결되었다고 보고된 후
// cll 연결되면, provider delegate는 연결또한 보고합니다.
call.connectedStateChanged = { [weak self, weak call] in
guard
let self = self,
let call = call
else {
return
}
if call.connectedState == .pending {
self.provider.reportOutgoingCall(with: call.uuid, startedConnectingAt: nil)
} else if call.connectedState == .complete {
self.provider.reportOutgoingCall(with: call.uuid, connectedAt: nil)
}
}
// 3. start()메서드가 실행되면 생명주기를 변경 된 후 성공적으로 연결되면 action을 실행합니다.
call.start { [weak self, weak call] success in
guard
let self = self,
let call = call
else {
return
}
if success {
action.fulfill()
self.callManager.add(call: call)
} else {
action.fail()
}
}
}
// CallManager.swift
func startCall(handle: String, videoEnabled: Bool) {
// 1 CXHandle로 표시되는 핸들은 핸들 유형과 값을 지정할 수 있습니다.
let handle = CXHandle(type: .phoneNumber, value: handle)
// 2 CXStartCallAction은 고유한 UUID와 핸들을 매개변수로 받습니다.
let startCallAction = CXStartCallAction(call: UUID(), handle: handle)
// 3 시스템에 요청하기위해 action을 transaction으로 랩핑
// isVideo 속성을 설정하여 통화가 오디오 전용인지 화상 통화인지 지정합니다.
startCallAction.isVideo = videoEnabled
let transaction = CXTransaction(action: startCallAction)
requestTransaction(transaction)
}
provider delegate가 발신전화를 처리할 준비가 되면 앱에서 발신전화를 만드는 방법을 처리합니다.
// CallsViewController.swift
@IBAction private func unwindForNewCall(_ segue: UIStoryboardSegue) {
guard
let newCallController = segue.source as? NewCallViewController,
let handle = newCallController.handle
else {
return
}
let videoEnabled = newCallController.videoEnabled
let incoming = newCallController.incoming
if incoming {
let backgroundTaskIdentifier =
UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
AppDelegate.shared.displayIncomingCall(
uuid: UUID(),
handle: handle,
hasVideo: videoEnabled
) { _ in
UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier)
}
}
}
// 수신 전화가 아니라면, callManager는 발신전화를 시작하도록 요청합니다.
else {
callManager.startCall(handle: handle, videoEnabled: videoEnabled)
}
}
사용자의 선택에 따라 여러 동작을 CXTransaction으로 결합합니다.
예를 들어, 사용자가 진행 중인 통화를 종료하고 새 통화를 응답하도록 선택하는 경우라면 시스템은 전자의 경우 CXEndCallAction, 후자의 경우 CXStartCallAction을 생성합니다. 두 작업은 트랜잭션으로 포장되어 provider에게 전송되며, provider는 이를 개별적으로 처리합니다.
앱은 한 번에 하나의 오디오 세션만 처리합니다. 통화 재개를 선택하면 다른 사람은 자동으로 보류됩니다.
The directory extension는 CallKit에 의해 제공되는 새로운 extension point입니다.
시스템이 전화를 받으면 주소록에서 일치하는 항목을 확인합니다. 찾을 수 없는 경우 app-specific directory extensions을 확인할 수 있습니다.
XCode는 자동으로 CallDirectoryHandler.swift 파일을 생성합니다.
// CallDirectoryHandler.swift
private func addAllBlockingPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
let phoneNumbers: [CXCallDirectoryPhoneNumber] = [ 1234 ]
for phoneNumber in phoneNumbers {
context.addBlockingEntry(withNextSequentialPhoneNumber: phoneNumber)
}
}
addAllBlockingPhoneNumbers(to:)는 차단해야 하는 모든 전화 번호를 수집합니다.
// CallDirectoryHandler.swift
private func addAllIdentificationPhoneNumbers(to context: CXCallDirectoryExtensionContext) {
let phoneNumbers: [CXCallDirectoryPhoneNumber] = [ 1111 ]
let labels = [ "RW Tutorial Team" ]
for (phoneNumber, label) in zip(phoneNumbers, labels) {
context.addIdentificationEntry(
withNextSequentialPhoneNumber: phoneNumber,
label: label
)
}
}
addAllIdentificationPhoneNumbers(to:)는 전화번호를 식별합니다.
Settings → Phone(전화) → Call Blocking & Identification → 허용
차단번호 오고 있는지 디버깅 하는 방법
- ProviderDelegate에서 reportIncomingCall(uuid:handle:hasVideo:completion:)에서 알 수 있습니다.
- reportNewIncomingCall(withupdate:completion:)