π 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 { // μ€λμ€ μΈμ μ€μ (νμ) func providerDidReset(_ provider: CXProvider) { // λͺ¨λ ν΅ν μ’ λ£ print("Provider reset") } // ν΅ν μμ func provider(_ provider: CXProvider, perform action: CXStartCallAction) { // ν΅ν μμ λ‘μ§ configureAudioSession() action.fulfill() } // ν΅ν μλ΅ func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { // ν΅ν μλ΅ λ‘μ§ configureAudioSession() action.fulfill() } // ν΅ν μ’ λ£ func provider(_ provider: CXProvider, perform action: CXEndCallAction) { // ν΅ν μ’ λ£ λ‘μ§ action.fulfill() } // μμκ±° func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { action.fulfill() } // λκΈ° (Hold) func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) { action.fulfill() } // μ€λμ€ μΈμ νμ±ν 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)") } } }
π² μμ μ ν νμνκΈ°
IncomingCall.swift
import CallKit extension CallManager { // μμ μ ν μλ¦Ό 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) } } } // μ¬μ© μμ let callUUID = UUID() CallManager.shared.reportIncomingCall( uuid: callUUID, handle: "010-1234-5678", hasVideo: false )
π€ λ°μ μ ν μμνκΈ°
OutgoingCall.swift
import CallKit extension CallManager { // λ°μ μ ν μμ 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") } } } // ν΅ν μ’ λ£ 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") } } } } // μ¬μ© μμ 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 { // ν΅ν μ€ UI VStack(spacing: 20) { Image(systemName: "phone.fill") .font(.system(size: 60)) .foregroundStyle(.green) Text("ν΅ν μ€...") .font(.title2) Text(phoneNumber) .font(.title3) .foregroundStyle(.secondary) Button(action: endCall) { HStack { Image(systemName: "phone.down.fill") Text("ν΅ν μ’ λ£") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(.red) } } else { // λ€μ΄μΌ UI TextField("μ νλ²νΈ", 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 μ°λ (λ°±κ·ΈλΌμ΄λ μλ¦Ό)
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)") // μλ²λ‘ ν ν° μ μ‘ 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μΌλ‘ μμ μ ν νμ 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 } }
π§ ν΅ν μν μ λ°μ΄νΈ
CallUpdate.swift
import CallKit extension CallManager { // ν΅ν μ°κ²°λ¨ func reportCallConnected(uuid: UUID) { provider.reportOutgoingCall(with: uuid, connectedAt: Date()) } // ν΅ν μμ μ€ν¨ func reportCallFailed(uuid: UUID) { provider.reportCall(with: uuid, endedAt: Date(), reason: .failed) } // μλλ°©μ΄ κ±°λΆ func reportCallRemoteEnded(uuid: UUID) { provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded) } // ν΅ν μ 보 μ λ°μ΄νΈ func updateCall(uuid: UUID, localizedName: String) { let update = CXCallUpdate() update.localizedCallerName = localizedName provider.reportCall(with: uuid, updated: update) } } // μ¬μ© μμ let callUUID = UUID() // ν΅ν μ°κ²° μ±κ³΅ CallManager.shared.reportCallConnected(uuid: callUUID) // 5μ΄ ν ν΅ν μ’ λ£ DispatchQueue.main.asyncAfter(deadline: .now() + 5) { CallManager.shared.reportCallRemoteEnded(uuid: callUUID) }
π Caller ID λ° μ°¨λ¨ νμ₯
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 νμ μ¬μ©
- λ°±κ·ΈλΌμ΄λμμλ ν΅ν μμ
- 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("ν΅ν μ’ λ£λ¨") } } } // 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μΌλ‘ λ°±κ·ΈλΌμ΄λ μμ
β
μ κΈ νλ©΄μμλ ν΅ν κ°λ₯
β
ν΅ν κΈ°λ‘ μλ μ μ₯
β
FaceTimeκ³Ό λμΌν UX