🌐 KO

πŸ“ž Mastering CallKit

Make VoIP apps feel native! Integrate call UI with the system using CallKit.

⭐ Difficulty: ⭐⭐⭐ ⏱️ Est. Time: 2-3h πŸ“‚ System & Network

✨ CallKit is?

CallKit is a framework that integrates VoIP (Voice over IP) apps into the iPhone's native phone system. It displays calls with the same UI as FaceTime and Phone, and allows answering calls from the lock screen. Apps like Slack, WhatsApp, and Zoom use CallKit.

πŸ“¦ Key Features

Feature Overview
βœ… μ‹œμŠ€ν…œ 톡화 UI 톡합
βœ… 잠금 ν™”λ©΄ 톡화 μ•Œλ¦Ό
βœ… 톡화 기둝 (졜근 톡화)
βœ… 차단 및 식별 (Caller ID)
βœ… PushKit 연동 (λ°±κ·ΈλΌμš΄λ“œ μ•Œλ¦Ό)
βœ… 톡화 λŒ€κΈ°, 톡화 μ „ν™˜
βœ… μŒμ†Œκ±°, μŠ€ν”Όμ»€ μ œμ–΄

πŸ“ž Basic Structure: CXProvider and CXCallController

CallManager.swift
import CallKit
import AVFoundation

class CallManager: NSObject {
    static let shared = CallManager()

    // CXProvider: μ‹œμŠ€ν…œμ—μ„œ μ•±μœΌλ‘œ λ“€μ–΄μ˜€λŠ” 이벀트 처리
    private let provider: CXProvider

    // CXCallController: μ•±μ—μ„œ μ‹œμŠ€ν…œμœΌλ‘œ μ•‘μ…˜ μš”μ²­
    private let callController = CXCallController()

    private override init() {
        // Provider μ„€μ •
        let config = CXProviderConfiguration()
        config.localizedName = "MyVoIP"
        config.supportsVideo = true
        config.maximumCallGroups = 1
        config.maximumCallsPerCallGroup = 1
        config.supportedHandleTypes = [.phoneNumber, .generic]

        // λ²¨μ†Œλ¦¬ μ„€μ •
        config.ringtoneSound = "ringtone.caf"

        provider = CXProvider(configuration: config)

        super.init()

        provider.setDelegate(self, queue: nil)
    }
}

// MARK: - Provider Delegate
extension CallManager: CXProviderDelegate {
    // Audio Session setup (ν•„μˆ˜)
    func providerDidReset(_ provider: CXProvider) {
        // all End call
        print("Provider reset")
    }

    // Call μ‹œμž‘
    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        // Call μ‹œμž‘ 둜직
        configureAudioSession()
        action.fulfill()
    }

    // Call 응닡
    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        // Call 응닡 둜직
        configureAudioSession()
        action.fulfill()
    }

    // Call μ’…λ£Œ
    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        // Call μ’…λ£Œ 둜직
        action.fulfill()
    }

    // μŒμ†Œκ±°
    func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
        action.fulfill()
    }

    // λŒ€κΈ° (Hold)
    func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
        action.fulfill()
    }

    // Audio μ„Έμ…˜ ν™œμ„±ν™”
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        // VoIP μ˜€λ””μ˜€ μ‹œμž‘
        print("Audio session activated")
    }

    func configureAudioSession() {
        let session = AVAudioSession.sharedInstance()
        do {
            try session.setCategory(.playAndRecord, mode: .voiceChat)
            try session.setActive(true)
        } catch {
            print("Audio session error: \(error)")
        }
    }
}

πŸ“² Displaying Incoming Calls

IncomingCall.swift
import CallKit

extension CallManager {
    // Incoming call μ•Œλ¦Ό
    func reportIncomingCall(
        uuid: UUID,
        handle: String,
        hasVideo: Bool = false,
        completion: ((Error?) -> Void)? = nil
    ) {
        // CXCallUpdate둜 톡화 정보 μ„€μ •
        let update = CXCallUpdate()
        update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
        update.hasVideo = hasVideo
        update.localizedCallerName = handle

        // μ‹œμŠ€ν…œμ— 톡화 μ•Œλ¦Ό
        provider.reportNewIncomingCall(with: uuid, update: update) { error in
            if let error = error {
                print("Failed to report call: \(error.localizedDescription)")
            } else {
                print("Incoming call reported")
            }
            completion?(error)
        }
    }
}

// Usage Example
let callUUID = UUID()
CallManager.shared.reportIncomingCall(
    uuid: callUUID,
    handle: "010-1234-5678",
    hasVideo: false
)

πŸ“€ Starting Outgoing Calls

OutgoingCall.swift
import CallKit

extension CallManager {
    // Outgoing call μ‹œμž‘
    func startCall(handle: String, videoEnabled: Bool = false) {
        let uuid = UUID()

        // Handle 생성
        let handle = CXHandle(type: .phoneNumber, value: handle)

        // Start Call Action
        let startCallAction = CXStartCallAction(call: uuid, handle: handle)
        startCallAction.isVideo = videoEnabled

        // Transaction 생성 및 μš”μ²­
        let transaction = CXTransaction(action: startCallAction)

        callController.request(transaction) { error in
            if let error = error {
                print("Start call failed: \(error.localizedDescription)")
            } else {
                print("Start call succeeded")
            }
        }
    }

    // Call μ’…λ£Œ
    func endCall(uuid: UUID) {
        let endCallAction = CXEndCallAction(call: uuid)
        let transaction = CXTransaction(action: endCallAction)

        callController.request(transaction) { error in
            if let error = error {
                print("End call failed: \(error.localizedDescription)")
            } else {
                print("Call ended")
            }
        }
    }
}

// Usage Example
CallManager.shared.startCall(handle: "010-9876-5432", videoEnabled: false)

πŸ“± SwiftUI Integration

CallView.swift
import SwiftUI
import CallKit

struct CallView: View {
    @State private var phoneNumber = ""
    @State private var isCallActive = false
    @State private var currentCallUUID: UUID?

    var body: some View {
        VStack(spacing: 20) {
            Text("VoIP 톡화")
                .font(.largeTitle)
                .bold()

            if isCallActive {
                // Call 쀑 UI
                VStack(spacing: 20) {
                    Image(systemName: "phone.fill")
                        .font(.system(size: 60))
                        .foregroundStyle(.green)

                    Text("In call...")
                        .font(.title2)

                    Text(phoneNumber)
                        .font(.title3)
                        .foregroundStyle(.secondary)

                    Button(action: endCall) {
                        HStack {
                            Image(systemName: "phone.down.fill")
                            Text("End call")
                        }
                        .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(.borderedProminent)
                    .tint(.red)
                }
            } else {
                // 닀이얼 UI
                TextField("Phone number", text: $phoneNumber)
                    .textFieldStyle(.roundedBorder)
                    .keyboardType(.phonePad)

                Button(action: makeCall) {
                    HStack {
                        Image(systemName: "phone.fill")
                        Text("μ „ν™” κ±ΈκΈ°")
                    }
                    .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
                .disabled(phoneNumber.isEmpty)

                Button("μˆ˜μ‹  ν…ŒμŠ€νŠΈ") {
                    simulateIncomingCall()
                }
                .buttonStyle(.bordered)
            }
        }
        .padding()
    }

    func makeCall() {
        currentCallUUID = UUID()
        CallManager.shared.startCall(handle: phoneNumber)
        isCallActive = true
    }

    func endCall() {
        if let uuid = currentCallUUID {
            CallManager.shared.endCall(uuid: uuid)
        }
        isCallActive = false
        currentCallUUID = nil
    }

    func simulateIncomingCall() {
        let uuid = UUID()
        CallManager.shared.reportIncomingCall(
            uuid: uuid,
            handle: "010-1234-5678"
        )
    }
}

πŸ”” PushKit Integration (Background Notifications)

PushKitManager.swift
import PushKit
import CallKit

class PushKitManager: NSObject, PKPushRegistryDelegate {
    static let shared = PushKitManager()

    private let pushRegistry = PKPushRegistry(queue: .main)

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

    // Push Token λ°›μŒ
    func pushRegistry(
        _ registry: PKPushRegistry,
        didUpdate credentials: PKPushCredentials,
        for type: PKPushType
    ) {
        let token = credentials.token.map { String(format: "%02x", $0) }.joined()
        print("VoIP Push Token: \(token)")

        // Server둜 토큰 전솑
        sendTokenToServer(token)
    }

    // Push μˆ˜μ‹  (톡화 μ•Œλ¦Ό)
    func pushRegistry(
        _ registry: PKPushRegistry,
        didReceiveIncomingPushWith payload: PKPushPayload,
        for type: PKPushType,
        completion: @escaping () -> Void
    ) {
        guard type == .voIP else { return }

        // Payloadμ—μ„œ 톡화 정보 μΆ”μΆœ
        let callerID = payload.dictionaryPayload["caller_id"] as? String ?? "Unknown"
        let hasVideo = payload.dictionaryPayload["has_video"] as? Bool ?? false

        // CallKit으둜 Incoming call ν‘œμ‹œ
        let callUUID = UUID()
        CallManager.shared.reportIncomingCall(
            uuid: callUUID,
            handle: callerID,
            hasVideo: hasVideo
        ) { error in
            completion()
        }
    }

    func sendTokenToServer(_ token: String) {
        // API ν˜ΈμΆœν•˜μ—¬ μ„œλ²„μ— μ €μž₯
    }
}

🎯 Practical Example: Complete VoIP App

VoIPApp.swift
import SwiftUI

@main
struct VoIPApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            CallView()
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        // PushKit 등둝
        PushKitManager.shared.registerForVoIPPushes()

        // CallManager μ΄ˆκΈ°ν™”
        _ = CallManager.shared

        return true
    }
}

πŸ”§ Call Status Updates

CallUpdate.swift
import CallKit

extension CallManager {
    // Call Connected
    func reportCallConnected(uuid: UUID) {
        provider.reportOutgoingCall(with: uuid, connectedAt: Date())
    }

    // Call Start failed
    func reportCallFailed(uuid: UUID) {
        provider.reportCall(with: uuid, endedAt: Date(), reason: .failed)
    }

    // μƒλŒ€λ°©μ΄ κ±°λΆ€
    func reportCallRemoteEnded(uuid: UUID) {
        provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
    }

    // Call 정보 μ—…λ°μ΄νŠΈ
    func updateCall(uuid: UUID, localizedName: String) {
        let update = CXCallUpdate()
        update.localizedCallerName = localizedName
        provider.reportCall(with: uuid, updated: update)
    }
}

// Usage Example
let callUUID = UUID()

// Call Connection successful
CallManager.shared.reportCallConnected(uuid: callUUID)

// 5초 ν›„ End call
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
    CallManager.shared.reportCallRemoteEnded(uuid: callUUID)
}

πŸ“‹ Caller ID & Call Blocking Extension

CallDirectoryExtension.swift
import CallKit

// Call Directory Extension (별도 νƒ€κ²Ÿ)
class CallDirectoryHandler: CXCallDirectoryProvider {
    // 차단 번호 μΆ”κ°€
    override func beginRequest(with context: CXCallDirectoryExtensionContext) {
        do {
            // 차단할 번호 λͺ©λ‘
            let blockedNumbers: [CXCallDirectoryPhoneNumber] = [
                82101234567,  // κ΅­κ°€μ½”λ“œ 포함
                82109876543
            ]

            for number in blockedNumbers.sorted() {
                context.addBlockingEntry(withNextSequentialPhoneNumber: number)
            }

            // λ°œμ‹ μž ν‘œμ‹œ 정보
            let identifications: [(CXCallDirectoryPhoneNumber, String)] = [
                (82101111111, "홍길동"),
                (82102222222, "배달 μ„œλΉ„μŠ€")
            ]

            for (number, label) in identifications.sorted(by: { $0.0 < $1.0 }) {
                context.addIdentificationEntry(
                    withNextSequentialPhoneNumber: number,
                    label: label
                )
            }

            context.completeRequest()
        } catch {
            context.cancelRequest(withError: error)
        }
    }
}

πŸ’‘ HIG Guidelines

HIG Recommendations
βœ… DO
1. μ •ν™•ν•œ λ°œμ‹ μž 정보 ν‘œμ‹œ
   - 이름, 번호λ₯Ό λͺ…ν™•ν•˜κ²Œ
   - localizedCallerName μ‚¬μš©

2. μ˜€λ””μ˜€ μ„Έμ…˜ μ˜¬λ°”λ₯΄κ²Œ μ„€μ •
   - .playAndRecord + .voiceChat
   - provider didActivate μ—μ„œ μ˜€λ””μ˜€ μ‹œμž‘

3. 톡화 μƒνƒœ μ¦‰μ‹œ μ—…λ°μ΄νŠΈ
   - μ—°κ²°, μ’…λ£Œ μƒνƒœλ₯Ό μ •ν™•ν•˜κ²Œ 보고
   - reportCallConnected, reportCallEnded

4. PushKit ν•„μˆ˜ μ‚¬μš©
   - In background도 톡화 μˆ˜μ‹ 
   - VoIP Push만 μ‚¬μš©

❌ DON'T
1. 일반 Push Notification μ‚¬μš© κΈˆμ§€
2. CallKit 없이 VoIP μ•± μ œμž‘ λΆˆκ°€
3. 톡화 μ•„λ‹Œ μš©λ„λ‘œ PushKit μ‚¬μš©
4. 톡화 μƒνƒœ 보고 λˆ„λ½

πŸ”§ Practical Tips

Real-World Patterns
import CallKit

// 1. 톡화 κ΄€μ°°μž (Observer)
class CallObserver {
    let callObserver = CXCallObserver()

    init() {
        callObserver.setDelegate(self, queue: .main)
    }
}

extension CallObserver: CXCallObserverDelegate {
    func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) {
        print("Call changed: \(call.uuid)")

        if call.isOnHold {
            print("톡화 λŒ€κΈ° 쀑")
        }

        if call.hasEnded {
            print("End call됨")
        }
    }
}

// 2. μŒμ†Œκ±° μƒνƒœ ν† κΈ€
func toggleMute(callUUID: UUID, isMuted: Bool) {
    let muteAction = CXSetMutedCallAction(call: callUUID, muted: isMuted)
    let transaction = CXTransaction(action: muteAction)

    CXCallController().request(transaction) { error in
        if let error = error {
            print("Mute failed: \(error)")
        }
    }
}

// 3. 닀쀑 μ•‘μ…˜ (톡화 μ „ν™˜)
func swapCalls(currentUUID: UUID, incomingUUID: UUID) {
    let holdAction = CXSetHeldCallAction(call: currentUUID, onHold: true)
    let answerAction = CXAnswerCallAction(call: incomingUUID)

    let transaction = CXTransaction(actions: [holdAction, answerAction])

    CXCallController().request(transaction) { error in
        if let error = error {
            print("Swap failed: \(error)")
        }
    }
}

πŸ” Info.plist & Entitlements Configuration

Info.plist
<!-- λ°±κ·ΈλΌμš΄λ“œ λͺ¨λ“œ -->
<key>UIBackgroundModes</key>
<array>
    <string>voip</string>
</array>

<!-- Entitlementsμ—μ„œ μ„€μ • ν•„μš” -->
- Push Notifications ν™œμ„±ν™”
- Background Modes > Voice over IP 체크

πŸ’‘ CallKit 핡심
βœ… μ‹œμŠ€ν…œ 톡화 UI와 μ™„λ²½ 톡합
βœ… PushKit으둜 λ°±κ·ΈλΌμš΄λ“œ μˆ˜μ‹ 
βœ… 잠금 ν™”λ©΄μ—μ„œλ„ 톡화 κ°€λŠ₯
βœ… 톡화 기둝 auto save
βœ… FaceTimeκ³Ό λ™μΌν•œ UX

πŸ“¦ Learning Resources

πŸ’»
GitHub Project
πŸ“–
Apple Official Docs
πŸŽ₯
WWDC 2016

πŸ“Ž Apple Official Resources

πŸ“˜ Documentation πŸ’» Sample Code 🎬 WWDC Sessions