π CallKit μμ μ 볡
VoIP μ±μ λ€μ΄ν°λΈ μ νμ²λΌ! CallKitμΌλ‘ ν΅ν UIλ₯Ό μμ€ν μ ν΅ν©νμΈμ.
β¨ CallKitμ΄λ?
CallKitμ VoIP(Voice over IP) μ±μ iPhoneμ λ€μ΄ν°λΈ ν΅ν μμ€ν μ ν΅ν©νλ νλ μμν¬μ λλ€. FaceTime, μ ν μ±κ³Ό λμΌν UIλ‘ ν΅νλ₯Ό νμνκ³ , μ κΈ νλ©΄μμλ ν΅νλ₯Ό λ°μ μ μμ΅λλ€. Slack, WhatsApp, Zoom λ±μ΄ CallKitμ μ¬μ©ν©λλ€.
π¦ μ£Όμ κΈ°λ₯
κΈ°λ₯ κ°μ
β
μμ€ν
ν΅ν UI ν΅ν©
β
μ κΈ νλ©΄ ν΅ν μλ¦Ό
β
ν΅ν κΈ°λ‘ (μ΅κ·Ό ν΅ν)
β
μ°¨λ¨ λ° μλ³ (Caller ID)
β
PushKit μ°λ (λ°±κ·ΈλΌμ΄λ μλ¦Ό)
β
ν΅ν λκΈ°, ν΅ν μ ν
β
μμκ±°, μ€νΌμ»€ μ μ΄π κΈ°λ³Έ ꡬ쑰: CXProviderμ 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 ν΅ν©
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 νΈμΆνμ¬ μλ²μ μ μ₯ } }
π― μ€μ μμ : μμ ν VoIP μ±
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 κ°μ΄λλΌμΈ
HIG κΆμ₯μ¬ν
β
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. ν΅ν μν λ³΄κ³ λλ½π§ μ€λ¬΄ νμ© ν
μ€μ ν¨ν΄
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 μ€μ
Info.plist
<!-- λ°±κ·ΈλΌμ΄λ λͺ¨λ --> <key>UIBackgroundModes</key> <array> <string>voip</string> </array> <!-- Entitlementsμμ μ€μ νμ --> - Push Notifications νμ±ν - Background Modes > Voice over IP 체ν¬
π‘ CallKit ν΅μ¬
β
μμ€ν
ν΅ν UIμ μλ²½ ν΅ν©
β
PushKitμΌλ‘ λ°±κ·ΈλΌμ΄λ μμ
β
μ κΈ νλ©΄μμλ ν΅ν κ°λ₯
β
ν΅ν κΈ°λ‘ μλ μ μ₯
β
FaceTimeκ³Ό λμΌν UX