π 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