본문으로 건너뛰기

PlanetKit Flutter에 푸시 알림 연동 방법

전주헌

안녕하세요. LINE Planet에서 iOS/macOS와 Flutter PlanetKit을 담당하고 있는 전주헌입니다.

LINE Planet은 음성 및 영상 통화를 서비스에 통합할 수 있는 클라우드 플랫폼과 클라이언트 SDK를 제공하는 실시간 커뮤니케이션 플랫폼(communication platform as a service, CPaaS)입니다. 이 글에서는 LINE Planet의 Flutter SDK인 PlanetKit Flutter에 푸시 알림을 연동하는 방법을 소개합니다. 연동 과정에서 가장 중요한 푸시 알림과 수신 통화 흐름을 단계별로 안내하겠습니다.

이 글에서 다루는 주요 내용은 다음과 같습니다.

  • 푸시 알림이 모든 수신 통화의 진입점인 이유와 cc_param의 역할
  • Flutter 플랫폼에서 통화 진행 흐름
    • iOS: VoIP 푸시 → CallKit → PlanetKit
    • Android: FCM(Firebase Cloud Messaging) → 포그라운드(foreground) 서비스 → PlanetKit
  • 통화가 울리는 동안 앱을 유지하는 Android 포그라운드 서비스 설정 방법
  • 플랫폼/앱 상태별 푸시 핸들러 구현 방법
  • PlanetKit에서 통화 수락/거절 처리 방법
Note

이 글에서는 사용자 인증 정보(사용자 ID, 서비스 ID, 액세스 토큰)를 미리 준비했다고 가정하고 푸시 처리와 콜 셋업에 집중합니다. 인증 정보 준비 방법은 다음 문서를 참고하세요.

PlanetKit Flutter에서 푸시의 역할

코드로 들어가기 전에, PlanetKit Flutter에서 푸시 알림이 어떤 역할을 하고 왜 수신 통화에 필수인지 살펴보겠습니다.

먼저 PlanetKit은 푸시 전달을 관리하지 않습니다. 여러분의 사용자에게 전화가 걸려오면 여러분의 앱 서버가 대상 단말로 푸시 알림을 전송합니다. 이를 위해서 앱은 다음을 수행해야 합니다.

  1. 푸시 수신
  2. 페이로드에서 cc_param 필드 추출
  3. 추출한 cc_param 문자열로 PlanetKitCcParam 객체 생성
  4. PlanetKitManagerPlanetKitCcParam을 전달하여 통화 검증 및 셋업

여기서 cc_param이란 통화를 위한 탑승권이라고 생각하면 됩니다. PlanetKit이 통화 세션을 설정하는 데 필요한 최소 정보만 담긴 PlanetKit 전용 인코딩 문자열입니다.

Warning

푸시 페이로드 구조(cc_param과 발신자 정보를 담는 필드명 포함)는 PlanetKit이 아니라 여러분의 앱 서버에서 정의해야 하며, 각자의 상황에 맞춰 자유롭게 설계할 수 있습니다. 자세한 내용은 앱 서버 역할 문서를 참고하세요. 이 글의 예시에서는 특정 필드 구조(cc_param, my_user_id, my_service_id, from_user_id)를 사용합니다.

푸시는 iOS와 Android에서 다음과 같이 각 플랫폼에 한정된 역할도 수행합니다.

플랫폼푸시 유형푸시가 무엇을 트리거해야 하는가
iOSAPNs VoIP(PushKit)CallKit 수신 통화 보고: 네이티브 통화 UI 표시
AndroidFCM(데이터 메시지)포그라운드 서비스: 벨이 울리고 통화하는 동안 프로세스 유지

통화 흐름 이해하기

푸시의 역할을 이해했으니 이제 각 플랫폼별 전체 통화 흐름을 따라가 보겠습니다. 두 플랫폼은 접근 방식이 꽤 다릅니다. 코드를 작성하기 전에 왜 다른지 그 이유를 이해해야 합니다.

iOS: VoIP 푸시에서 CallKit을 거쳐 PlanetKit까지

먼저 PushKit이란 Apple의 VoIP 푸시 전달 프레임워크입니다. PushKit은 일반 APNs와는 다르게 앱이 종료된 상태라고 하더라도 즉시 깨우며, 사용자와 상호작용하기 전에 PKPushRegistryDelegate를 실행하는 것을 보장합니다. 이와 같은 특성을 갖추고 있기 때문에 수신 통화 전달에 적합합니다(참고: Apple PushKit 문서).

다음으로 CallKit이란 서드파티 VoIP 통화를 iOS에서 제공하는 기본적인 통화 경험과 통합하기 위한 Apple의 프레임워크입니다. 시스템 수준의 수신 통화 화면(발신자 이름, 수락 및 거절 버튼)을 제공합니다. 여기서 중요한 점은, Apple은 PushKit으로 수신한 모든 VoIP 푸시에 대해 반드시 CallKit에 수신 통화를 보고하도록 강제한다는 것입니다. 이는 OS 수준에서 강제하는 것으로 건너뛸 수 없습니다. 자세한 내용은 Apple CallKit 문서를 참고하세요.

iOS의 전체 통화 흐름은 다음과 같습니다.

APNs가 VoIP 푸시 전달

▼ PKPushRegistryDelegate (네이티브 Swift — Dart 실행 전)
CallKit UUID 생성 ← CallKit에서 통화를 식별하기 위해 UUID 필요
CallKit.reportNewIncomingCall(uuid, update) ← 반드시 이 단계에서 5초 이내에 실행해야 함

▼ VoIP 푸시 페이로드를 MethodChannel을 통해 Dart로 전달
Dart { cc_param, callkit_uuid, from_user_id, ... } 수신


PlanetKitManager.instance.verifyCall(param, handler)

├─ onVerified → map callkit_uuid ↔ PlanetKit callId
├─ onConnected → CallKit.reportOutgoingCall(connectedAt:)
└─ onDisconnected → CallKit.reportCall(endedAt:reason:)

요약하면, 푸시가 도착하면 네이티브 Swift 코드가 즉시 CallKit 수신 통화 UI를 실행하고 통화를 식별할 UUID를 생성합니다. 이어서 페이로드를 Dart로 전달하면 Dart에서는 verifyCall이 비동기로 PlanetKit 검증 작업을 처리합니다. 여기서 CallKit과 PlanetKit은 서로 다른 식별자를 사용하기 때문에 UUID와 callId를 매핑하는 작은 맵을 유지해 동기화합니다(이 부분은 구현 섹션에서 자세히 설명하겠습니다).

Warning

푸시 수신 후 약 5초 이내에 CallKit 수신 통화를 보고하지 않으면 iOS는 앱을 종료합니다. 이 처리는 반드시 네이티브 Swift 코드에서 수행해야 합니다. Dart를 먼저 거치면 안 됩니다.

Android: FCM 푸시에서 포그라운드 서비스를 거쳐 PlanetKit까지

FCM은 Google의 크로스플랫폼 메시징 서비스입니다. FCM은 여러분의 앱 서버가 Android 클라이언트로 데이터 메시지를 푸시해 사용자 상호작용 없이 앱을 깨우거나 알릴 수 있게 해줍니다. FCM 메시지는 앱이 완전히 종료된 상태에서도 도착할 수 있으며, 이 경우 Firebase가 백그라운드 Dart Isolate(이하 Isolate)를 시작해 메시지를 전달합니다. 자세한 내용은 Firebase Cloud Messaging 문서를 참고하세요.

여기서 Isolate의 개념과 그 특성을 간략히 짚고 넘어가겠습니다. Isolate는 자체 메모리 힙이 있는 독립적인 실행 컨텍스트로, Flutter에서 모든 Dart 코드는 Isolate 내부에서 실행됩니다. 푸시 알림을 연동하는 측면에서 꼭 고려해야 할 Isolate의 중요한 특징 중 하나는 Isolate 간에는 Dart 메모리 공간을 공유하지 않는다는 것입니다. 이에 따라 Firebase가 푸시를 처리하기 위해 백그라운드 Isolate를 시작할 때 메인 Isolate에서 생성한 객체나 싱글턴, 상태에 접근할 수 없습니다. 다만 Flutter의 네이티브(플랫폼) 메모리 레이어는 공유하기 때문에 포그라운드 서비스가 Kotlin 메모리에 통화 ID를 저장하면 두 Isolate 모두에서 여기에 접근할 수 있습니다. 참고로 이와 같은 특성은 Android FCM에만 해당하는 것으로 iOS에서는 PushKit이 항상 앱을 직접 깨우기 때문에 별도 백그라운드 Isolate가 존재하지 않습니다.

위와 같은 이유로 Android의 실행 흐름은 앱 실행 여부에 따라 두 가지로 나뉩니다.

먼저 앱이 실행 중인 상태가 아닐 때의 백그라운드 푸시 흐름은 다음과 같습니다.

FCM 푸시 수신

▼ 백그라운드 Isolate의 최상위 Dart 함수
백그라운드 Isolate에서 PlanetKit 초기화
PlanetKitManager.instance.verifyBackgroundCall(param, handler)

├─ onVerified → ForegroundService.instance.startService(backgroundCallId)
└─ onDisconnected → ForegroundService.instance.stopService()

사용자가 알림 탭 → 앱 실행


callId = await ForegroundService.instance.getCallId()
PlanetKitManager.instance.adoptBackgroundCall(callId, handler)
└─ UI Isolate에서 통화 제어 전체 수행

앱이 이미 실행 중일 때의 포그라운드 푸시 흐름은 다음과 같습니다.

FCM 푸시 수신

▼ FirebaseMessaging.onMessage (메인 Isolate — 앱 실행 중)
PlanetKitManager.instance.verifyCall(param, handler)

├─ onVerified → ForegroundService.instance.startService(callId)
└─ onDisconnected → ForegroundService.instance.stopService()

위와 같이 백그라운드의 경우 흐름이 복잡해집니다. 백그라운드 Isolate는 앱의 일반 메모리 공간에 접근할 수 없으므로 해당 Isolate에서 PlanetKit을 다시 초기화해야 합니다. 통화 ID는 사용자가 앱을 열 때 메인 Isolate에서 가져올 수 있도록 Kotlin 레이어(포그라운드 서비스)에 저장합니다. 이 과정은 구현 섹션에서 단계별로 다루겠습니다.

Note

iOS에서는 PushKit이 여러분의 delegate가 호출되기 전에 앱이 실행되는 것을 보장하므로 verifyCall이 항상 작동합니다. 반면 Android에서는 앱이 완전히 종료된 상태에서 FCM 데이터 메시지가 도착할 수 있습니다. 그런 경우 verifyBackgroundCall을 사용해야 합니다. 이때 통화 ID는 Kotlin 레이어에 저장하며, 앱이 열릴 때 메인 Isolate가 이를 조회합니다. 만약 앱이 이미 실행 중이라면 iOS와 동일하게 바로 verifyCall을 사용합니다.

사전 준비

Android에서 푸시 처리를 구현하기 전에 포그라운드 서비스를 설정해야 합니다. 설정해야 하는 이유와 설정 과정을 살펴보겠습니다.

Android: 포그라운드 서비스 설정

Android에서는 OS가 메모리를 확보하기 위해 백그라운드 프로세스를 종료할 수 있습니다. 포그라운드 서비스는 특별한 유형의 Android 서비스로 높은 우선순위로 실행돼 지속 알림을 표시하며, 수신 통화처럼 시간에 민감한 작업 중에 OS가 프로세스를 종료해 버리지 않도록 막습니다. 만약 포그라운드 서비스를 설정하지 않으면 수신 통화가 울린 뒤 사용자가 응답하기 전에 앱 프로세스가 종료될 수 있습니다. 따라서 푸시 처리를 구현하기 전에 먼저 설정해야 합니다.

그럼 설정 과정을 살펴보겠습니다. 먼저 AndroidManifest.xml에 필요한 권한과 서비스를 선언합니다.

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<application ...>
<service
android:name=".YourForegroundService"
android:foregroundServiceType="phoneCall|microphone|mediaPlayback"
android:exported="false" />
</application>

다음으로 서비스를 구현합니다. Dart에서 전달받은 통화 ID는 Dart에서 조회할 때(서로 다른 Dart Isolate에서 조회하는 것도 포함) MethodChannel 핸들러(메인 스레드에서 실행)가 반환할 수 있도록 companion object에 저장합니다.

// Kotlin
class YourForegroundService : Service() {

companion object {
// Stored in native (Kotlin) memory so it is accessible across Dart isolates.
var currentCallId: String? = null
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Store the call ID passed from Dart
currentCallId = intent?.getStringExtra("callId")

createNotificationChannel()
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Incoming Call")
.setContentText(intent?.getStringExtra("callerName") ?: "")
.setSmallIcon(R.drawable.ic_call)
.setContentIntent(openAppPendingIntent())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setOngoing(true)
.setUsesChronometer(true)
.build()

when {
// Android 11+ (API 30+): PHONE_CALL + MICROPHONE preferred
// Falls back to MEDIA_PLAYBACK on SecurityException (Android 14+ edge case)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
val hasMic = ContextCompat.checkSelfPermission(this, RECORD_AUDIO) == GRANTED
val type = if (hasMic)
FOREGROUND_SERVICE_TYPE_PHONE_CALL or FOREGROUND_SERVICE_TYPE_MICROPHONE
else
FOREGROUND_SERVICE_TYPE_PHONE_CALL or FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
try {
startForeground(NOTIFICATION_ID, notification, type)
} catch (e: SecurityException) {
startForeground(NOTIFICATION_ID, notification,
FOREGROUND_SERVICE_TYPE_PHONE_CALL or FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
}
}
// Android 10 (API 29)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
startForeground(NOTIFICATION_ID, notification,
FOREGROUND_SERVICE_TYPE_PHONE_CALL or FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
// Android 8/9 (API 26–28): no serviceType parameter
else -> startForeground(NOTIFICATION_ID, notification)
}
return START_NOT_STICKY
}

override fun onDestroy() {
super.onDestroy()
currentCallId = null
}

override fun onBind(intent: Intent?) = null

private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "Ongoing Calls", NotificationManager.IMPORTANCE_DEFAULT
).apply { setShowBadge(true) }
getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
}
}
}
Warning

위 구현은 설명을 위해 최소한으로 작성한 예시입니다. 실제 서비스에는 포괄적인 에러 처리, 사용자 정의 알림 액션(예: 거절 버튼), 배터리 최적화 허용 안내, 브랜드에 맞는 알림 채널 관리 등을 포함해야 합니다.

다음으로 MainActivity에서 MethodChannel을 통해 start, stop, getCallId를 Dart에 노출합니다. 핵심은 currentCallId가 Kotlin 메모리에 있기 때문에 서비스가 시작한 백그라운드 Isolate와 앱 실행 시 이를 읽는 메인 Isolate가 같은 값을 본다는 것입니다.

// Kotlin
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "your_app/foreground_service")
.setMethodCallHandler { call, result ->
when (call.method) {
"start" -> {
val intent = Intent(context, YourForegroundService::class.java).apply {
putExtra("callerName", call.argument<String>("callerName") ?: "")
putExtra("callId", call.argument<String>("callId") ?: "")
}
ContextCompat.startForegroundService(context, intent)
result.success(null)
}
"stop" -> {
context.stopService(Intent(context, YourForegroundService::class.java))
result.success(null)
}
// Returns the call ID stored by the running service.
// Because the value lives in Kotlin memory, both the background Dart isolate
// (which started the service) and the main Dart isolate (which reads it on
// launch) see the same value.
"getCallId" -> {
result.success(YourForegroundService.currentCallId)
}
}
}

마지막으로 여러분의 앱 어디에서든 ForegroundService에 쉽고 편하게 접근할 수 있도록 MethodChannel을 Dart 싱글톤으로 감싸겠습니다.

// Dart
class ForegroundService {
static final ForegroundService instance = ForegroundService._();
ForegroundService._();

static const _channel = MethodChannel('your_app/foreground_service');

Future<void> startService({required String callId, String callerName = ''}) =>
_channel.invokeMethod('start', {'callId': callId, 'callerName': callerName});

Future<void> stopService() =>
_channel.invokeMethod('stop');

/// Queries the Kotlin layer for the active background call ID.
/// Returns null if the foreground service is not running.
Future<String?> getCallId() =>
_channel.invokeMethod<String?>('getCallId');
}

푸시 알림을 실제로 울리는 통화로 만들기

사전 준비가 끝났으니 이제 핵심 로직을 구현할 차례입니다. 푸시 알림을 받아 검증해서 울리는 통화로 만드는 과정이며, iOS부터 시작해서 Android에서는 두 가지 케이스를 다루겠습니다.

iOS 푸시 흐름 구현

iOS에서는 PushKit VoIP 푸시가 항상 앱을 깨우고 즉시 PKPushRegistryDelegate를 실행합니다. MethodChannel을 통해 Dart로 페이로드가 전달될 때 Flutter 엔진은 이미 실행된 상태입니다. 따라서 iOS에서는 항상 verifyCall을 사용하며, 포그라운드/백그라운드 구분이 없습니다.

1단계: VoIP 푸시 등록(네이티브 Swift)

여러분의 AppDelegate 또는 전용 핸들러에서 PKPushRegistry를 설정합니다.

// Swift
import PushKit

let voipRegistry = PKPushRegistry(queue: .main)
voipRegistry.delegate = self // self implements PKPushRegistryDelegate
voipRegistry.desiredPushTypes = [.voIP]

시스템이 토큰을 발급하면 여러분의 서버가 기기를 등록할 수 있도록 Dart로 전달합니다.

// Swift
func pushRegistry(_ registry: PKPushRegistry,
didUpdate credentials: PKPushCredentials,
for type: PKPushType) {
guard type == .voIP else { return }
let token = credentials.token.map { String(format: "%02x", $0) }.joined()
tokenChannel.invokeMethod("onVoipToken", arguments: token)
}

2단계: CallKit 설정(네이티브 Swift)

앱 시작 시 CXProvider를 한 번 설정합니다.

// Swift
import CallKit

let config = CXProviderConfiguration(localizedName: "YourAppName")
config.supportsVideo = true
config.maximumCallsPerCallGroup = 1
config.supportedHandleTypes = [.generic]
config.ringtoneSound = "ringtone.wav" // file in app bundle

let provider = CXProvider(configuration: config)
provider.setDelegate(self, queue: nil) // self implements CXProviderDelegate

Info.plist에 백그라운드 모드 키를 추가합니다.

<!-- Info.plist -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string> <!-- required for CallKit audio session -->
<string>voip</string> <!-- required for PushKit VoIP delivery -->
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone is needed for voice calls.</string>

Xcode Signing & Capabilities에서 Push Notifications, Background Modes > Voice over IP, Background Modes > Audio, AirPlay, and Picture in Picture를 활성화합니다.

3단계: 푸시 도착 시 즉시 CallKit에 보고(네이티브 Swift)

iOS에서 가장 중요한 단계입니다. VoIP 푸시가 도착하면 delegate 메서드를 리턴하기 전에 대략 5초 이내에 CallKit에 보고해야 합니다. Dart를 먼저 거치면 안 됩니다.

또 한 가지 주의해야 할 점은, CallKit에서 통화를 식별하기 위한 UUID를 반드시 여기서 생성해야 합니다. CallKit은 통화 생애 주기(수신, 연결, 종료)를 추적하는 데 UUID를 사용합니다. UUID는 네이티브 코드에서 푸시 시점(Dart 실행 전)에 생성한 뒤 Dart로 전달해 양쪽에서 동일한 통화를 참조하도록 합니다.

// Swift
func pushRegistry(_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void) {
guard type == .voIP else { completion(); return }

// Generate a fresh UUID to identify this call in CallKit.
// Do NOT reuse any ID from the payload — PlanetKit's session ID is not a UUID.
// This UUID will be used for all subsequent CallKit operations for this call.
let callKitUuid = UUID()

// Build the call update from push payload fields
let update = CXCallUpdate()
update.remoteHandle = CXHandle(
type: .generic,
value: payload.dictionaryPayload["from_user_id"] as? String ?? "Unknown"
)
update.hasVideo = (payload.dictionaryPayload["type"] as? String) == "V"
update.supportsHolding = true
update.supportsDTMF = false

// Report to CallKit IMMEDIATELY — shows native incoming call UI
provider.reportNewIncomingCall(with: callKitUuid, update: update) { error in
completion()
}

// Attach the UUID to the payload and forward to Dart
// Dart will use this UUID to link the CallKit call to the PlanetKit call
var voipPushPayload = payload.dictionaryPayload
voipPushPayload["callkit_uuid"] = callKitUuid.uuidString
pushChannel.invokeMethod("onVoipPush", arguments: voipPushPayload)
}

4단계: CallKit UI 이벤트 전달 및 라우팅

CXProviderDelegate는 사용자가 수락, 종료, 음 소거 등을 눌렀을 때 호출됩니다. 이를 EventChannel(Swift 측)을 통해 Dart로 전달하고, 여러분의 이벤트 핸들러에서 PlanetKit으로 라우팅합니다(Dart 측).

// Swift
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
eventSink?(["event": "accept", "callKitUuid": action.callUUID.uuidString])
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
eventSink?(["event": "ended", "callKitUuid": action.callUUID.uuidString])
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
eventSink?(["event": action.isMuted ? "mute" : "unmute",
"callKitUuid": action.callUUID.uuidString])
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXSetHeldCallAction) {
eventSink?(["event": action.isOnHold ? "hold" : "unhold",
"callKitUuid": action.callUUID.uuidString])
action.fulfill()
}
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
eventSink?(["event": "audioSessionActivated"])
}

이벤트가 Dart에 도착하면 PlanetKit으로 라우팅합니다.

// Dart
void _onCallKitEvent(Map<String, dynamic> event) {
switch (event['event'] as String) {
case 'accept':
currentCall?.acceptCall();
case 'ended':
currentCall?.endCall();
case 'mute':
currentCall?.muteMyAudio(true);
case 'unmute':
currentCall?.muteMyAudio(false);
case 'hold':
currentCall?.hold(reason: 'your app reason');
case 'unhold':
currentCall?.unhold();
case 'audioSessionActivated':
// CRITICAL: PlanetKit does not use the microphone until this is called.
// Missing this results in a connected call with no audio.
currentCall?.notifyCallKitAudioActivation();
}
}
Warning

audioSessionActivated 이벤트가 발생하면 notifyCallKitAudioActivation()을 반드시 호출해야 합니다. PlanetKit은 이 메서드를 호출하기 전까지 마이크 사용을 시작하지 않습니다.

5단계: Dart에서 푸시 수신 후 verifyCall 호출

// Dart
pushChannel.setMethodCallHandler((call) async {
if (call.method != 'onVoipPush') return;

final payload = Map<String, dynamic>.from(call.arguments as Map);
// callKitUuid was generated in native code and attached to the payload.
final callKitUuid = payload['callkit_uuid'] as String;
final ccParamStr = payload['cc_param'] as String;
final myUserId = payload['my_user_id'] as String;
final myServiceId = payload['my_service_id'] as String;

await _verifyCall(
ccParamStr: ccParamStr,
myUserId: myUserId,
myServiceId: myServiceId,
callKitUuid: callKitUuid,
);
});
// Dart
// Mirrors iOS CallKit's CXCallEndedReason raw values.
// The integer rawValues are forwarded to Swift via MethodChannel and must
// match the iOS CXCallEndedReason values exactly.
enum CXCallEndedReason {
failed(1),
remoteEnded(2),
unanswered(3),
answeredElsewhere(4),
declinedElsewhere(5);

const CXCallEndedReason(this.rawValue);
final int rawValue;
}
// Dart
// Dart has no direct access to CXProvider, so these helpers forward the
// CallKit UUID to Swift via callChannel. Swift then calls the CXProvider API.
Future<void> _reportCallConnected(String callKitUuid) =>
callChannel.invokeMethod('reportCallConnected', {'callKitUuidString': callKitUuid});

Future<void> _reportCallEnded(String callKitUuid, CXCallEndedReason reason) =>
callChannel.invokeMethod('reportCallEnded', {
'callKitUuidString': callKitUuid,
'reason': reason.rawValue,
});
// Dart
Future<void> _verifyCall({
required String ccParamStr,
required String myUserId,
required String myServiceId,
String? callKitUuid,
}) async {
final ccParam = await PlanetKitCcParam.createCcParam(ccParamStr);
if (ccParam == null) {
// Invalid cc_param — dismiss CallKit UI so it doesn't hang open
if (callKitUuid != null) await _reportCallEnded(callKitUuid, CXCallEndedReason.failed);
return;
}

final param = PlanetKitVerifyCallParamBuilder()
.setMyUserId(myUserId)
.setMyServiceId(myServiceId)
.setCcParam(ccParam)
.setRingtonePath('assets/ringtone.wav')
.setEndTonePath('assets/end.wav')
.setHoldTonePath('assets/hold.wav')
.setCallKitType(PlanetKitCallKitType.user)
.build();

final result = await PlanetKitManager.instance.verifyCall(param, _callEventHandler(callKitUuid));

if (result.reason != PlanetKitStartFailReason.none) {
if (callKitUuid != null) await _reportCallEnded(callKitUuid, CXCallEndedReason.failed);
return;
}

currentCall = result.call;
_callKitEventSub = callKitEventStream.listen(_onCallKitEvent);
}

CallKit UUID와 PlanetKit 통화 ID 연결하기

이 단계에서는 중요한 세부 작업이 하나 있습니다. CallKit은 푸시 시점에 UUID가 필요하지만, PlanetKit의 통화 ID는 verifyCall이 비동기로 완료된 이후에야 얻을 수 있다는 것입니다. 이 둘을 작은 맵으로 연결하는 과정은 다음과 같습니다.

// Dart
// PlanetKit callId → CallKit UUID (populated in onVerified, removed in onDisconnected)
final Map<String, String> _pkIdToUuid = {};
// Dart
PlanetKitCallEventHandler _callEventHandler(String? callKitUuid) => PlanetKitCallEventHandler(
onVerified: (call, peerUseResponderPreparation) {
if (callKitUuid != null) _pkIdToUuid[call.callId] = callKitUuid;
},
onConnected: (call, isInResponderPreparation, shouldFinishPreparation) async {
final uuid = _pkIdToUuid[call.callId];
if (uuid != null) await _reportCallConnected(uuid);
},
onDisconnected: (call, reason, source, userCode, byRemote) async {
final uuid = _pkIdToUuid[call.callId];
if (uuid != null) await _reportCallEnded(uuid, _mapReason(reason));
_pkIdToUuid.remove(call.callId);
},
);

CXCallEndedReason _mapReason(PlanetKitDisconnectReason reason) {
final name = reason.toString().toLowerCase();
if (name.contains('error') || name.contains('failed')) return CXCallEndedReason.failed;
if (name.contains('unanswered') || name.contains('timeout')) return CXCallEndedReason.unanswered;
return CXCallEndedReason.remoteEnded;
}

Swift 측에서는 callChannel MethodChannel 핸들러를 등록해 Dart에서 호출하는 reportCallConnectedreportCallEnded를 받아 CXProvider API로 변환합니다. 파라미터 이름이 callId가 아닌 callKitUuid인 점에 주의해야 합니다. PlanetKit 통화 ID가 아니라 CallKit UUID를 담는다는 것을 명확히 하기 위해서입니다.

// Swift — register alongside other MethodChannel setups at app startup
callChannel.setMethodCallHandler { [weak self] call, result in
guard let self else { result(FlutterError(code: "unavailable", message: nil, details: nil)); return }
let args = call.arguments as? [String: Any] ?? [:]
switch call.method {
case "reportCallConnected":
let callKitUuid = args["callKitUuid"] as? String ?? ""
self.reportCallConnected(callKitUuid: callKitUuid)
result(nil)
case "reportCallEnded":
let callKitUuid = args["callKitUuid"] as? String ?? ""
let reason = args["reason"] as? Int ?? CXCallEndedReason.remoteEnded.rawValue
self.reportCallEnded(callKitUuid: callKitUuid, reason: reason)
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
// callKitUuid carries a CallKit UUID — not PlanetKit's callId.
func reportCallConnected(callKitUuid: String) {
let uuid = UUID(uuidString: callKitUuid) ?? UUID()
provider.reportOutgoingCall(with: uuid, connectedAt: Date())
}
func reportCallEnded(callKitUuid: String, reason: Int) {
let uuid = UUID(uuidString: callKitUuid) ?? UUID()
let ckReason = CXCallEndedReason(rawValue: reason) ?? .remoteEnded
provider.reportCall(with: uuid, endedAt: Date(), reason: ckReason)
}

Android 백그라운드 푸시 흐름 구현

Android의 백그라운드 푸시 흐름 구현은 조금 더 복잡합니다. 앱이 실행 중인 상태가 아니면 Firebase가 별도 Isolate를 시작하기 때문입니다. 이 Isolate는 자신만의 Dart 메모리 공간을 갖고 있으며 여러분의 앱이 main()에서 초기화한 메모리 공간에 접근할 수 없습니다. Isolate 간에는 Dart 메모리를 공유하지 않으므로 메인 Isolate의 Dart 객체나 싱글톤을 사용할 수 없기 때문에 PlanetKit을 독립적으로 초기화해야 합니다. 생성된 통화 ID는 두 Isolate가 모두 접근할 수 있는 Kotlin(네이티브) 레이어에 저장하며, 사용자가 앱을 열 때 메인 isolate가 이를 가져옵니다.

1단계: 최상위 백그라운드 메시지 핸들러 등록

핸들러는 @pragma('vm:entry-point')가 붙은 최상위 Dart 함수여야 합니다. 또한 main()에서 runApp() 전에 등록해야 합니다.

// Dart
('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Re-initialize Firebase — required in every isolate
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

// Initialize PlanetKit in this background isolate
await _initializePlanetKit();

// Handle the incoming call push
await _handleIncomingCallPush(message.data);
}

// In main():
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await _initializePlanetKit();
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
runApp(const MyApp());
}

2단계: 백그라운드 Isolate에서 PlanetKit 초기화

// Dart
Future<void> _initializePlanetKit() async {
// Replace with your actual server URL — obtain from the Planet team.
const serverUrl = 'https://your-server-url.example.com';

final param = PlanetKitInitParam(
logSetting: PlanetKitLogSetting(
enabled: true,
logLevel: PlanetKitLogLevel.detailed,
logSizeLimit: PlanetKitLogSizeLimit.unlimited,
),
serverUrl: serverUrl,
);
await PlanetKitManager.instance.initializePlanetKit(param);
}

3단계: verifyBackgroundCall 호출

// Dart
Future<void> _handleIncomingCallPush(Map<String, dynamic> data) async {
final ccParamStr = data['cc_param'] as String?;
final myUserId = data['my_user_id'] as String?;
final myServiceId = data['my_service_id'] as String?;

if (ccParamStr == null || myUserId == null || myServiceId == null) return;

final ccParam = await PlanetKitCcParam.createCcParam(ccParamStr);
if (ccParam == null) return;

final param = PlanetKitVerifyCallParamBuilder()
.setMyUserId(myUserId)
.setMyServiceId(myServiceId)
.setCcParam(ccParam)
.setRingtonePath('assets/ringtone.wav')
.setEndTonePath('assets/end.wav')
.setHoldTonePath('assets/hold.wav')
.setCallKitType(PlanetKitCallKitType.user)
.build();

await PlanetKitManager.instance.verifyBackgroundCall(param, _backgroundHandler);
}

final _backgroundHandler = PlanetKitBackgroundCallEventHandler(
onVerified: (call, peerUseResponderPreparation) async {
// Start the foreground service and store the call ID in the Kotlin layer.
// The main Dart isolate retrieves it via ForegroundService.instance.getCallId()
// when the user opens the app.
await ForegroundService.instance.startService(callId: call.backgroundCallId);
},
onDisconnected: (call, reason, source, userCode, byRemote) async {
await ForegroundService.instance.stopService();
},
onError: (call) async {
await ForegroundService.instance.stopService();
},
);

4단계: 앱 열릴 때 백그라운드 통화 인계

사용자가 알림을 눌러 앱을 실행하면 Kotlin 레이어에서 통화 ID를 읽어 메인 Isolate로 통화를 인계합니다.

// Dart
// On app startup (or when returning to foreground), check for an active background call.
// getCallId() queries the Kotlin layer — works correctly even though the call ID
// was stored by a different Dart isolate.
final callId = await ForegroundService.instance.getCallId();

if (callId != null) {
final call = await PlanetKitManager.instance
.adoptBackgroundCall(callId, _callEventHandler());

if (call == null) {
// Caller hung up before user could answer
await ForegroundService.instance.stopService();
return;
}

currentCall = call; // PlanetKitCall
// Call is now under full control — show incoming call UI (see section below)
}

Android 포그라운드 푸시 흐름 구현

포그라운드 케이스는 훨씬 간단합니다. 앱이 이미 실행 중이라면 Firebase가 FirebaseMessaging.onMessage를 통해 메인 Isolate로 메시지를 바로 전달합니다. 따라서 iOS에서 했던 것처럼 verifyCall을 바로 사용합니다. verifyBackgroundCall이나 adoptBackgroundCall은 필요 없습니다.

// Dart
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
final ccParamStr = message.data['cc_param'] as String?;
final myUserId = message.data['my_user_id'] as String?;
final myServiceId = message.data['my_service_id'] as String?;

if (ccParamStr == null || myUserId == null || myServiceId == null) return;

final ccParam = await PlanetKitCcParam.createCcParam(ccParamStr);
if (ccParam == null) return;

final param = PlanetKitVerifyCallParamBuilder()
.setMyUserId(myUserId)
.setMyServiceId(myServiceId)
.setCcParam(ccParam)
.setRingtonePath('assets/ringtone.wav')
.setEndTonePath('assets/end.wav')
.setHoldTonePath('assets/hold.wav')
.setCallKitType(PlanetKitCallKitType.user)
.build();

final result = await PlanetKitManager.instance.verifyCall(param, PlanetKitCallEventHandler(
onVerified: (call, peerUseResponderPreparation) async {
// Start the foreground service to keep the process alive during the call
await ForegroundService.instance.startService(callId: call.callId);
},
onConnected: (call, isInResponderPreparation, shouldFinishPreparation) {
// Show active call UI
},
onDisconnected: (call, reason, source, userCode, byRemote) async {
await ForegroundService.instance.stopService();
},
));

if (result.reason != PlanetKitStartFailReason.none) return;

currentCall = result.call;
// Call is verified and ringing — show incoming call UI (see section below)
});

수락과 거절: 마지막 단계

이 시점에서 통화는 검증이 완료된 뒤 벨을 울리며 사용자의 응답을 기다리고 있는 상태입니다. 마지막 단계는 이 응답을 처리하는 것입니다.

verifyCall(iOS 및 Android 포그라운드) 또는 adoptBackgroundCall(Android 백그라운드)이 성공하면 여러분은 PlanetKitCall 인스턴스를 갖게 됩니다. 이 API는 아주 단순하며, 핵심 메서드는 두 가지입니다.

// Dart
// call is a PlanetKitCall instance returned by verifyCall or adoptBackgroundCall.

Future<void> answerCall(PlanetKitCall call) async {
await call.acceptCall();
}

Future<void> declineOrEndCall(PlanetKitCall call) async {
await call.endCall();
}

iOS에서 CallKit이 수락과 거절을 처리하는 방식

iOS에서는 사용자가 푸시 수신 시 reportNewIncomingCall이 호출되면 자동으로 표시되는 CallKit 기본 화면에서 통화를 수락 혹은 거절합니다. 따라서 앱에서 별도 수신 통화 UI를 표시할 필요가 없습니다.

iOS 구현 섹션에서 설정한 CXProviderDelegate_onCallKitEvent가 전체 흐름을 자동으로 처리합니다.

사용자 동작CallKit delegateDart 이벤트PlanetKit 통화
수락 탭CXAnswerCallActionacceptcall.acceptCall()
거절/종료 탭CXEndCallActionendedcall.endCall()

마치며

이번 글에서는 PlanetKit Flutter의 푸시 알림 연동 과정을 전체적으로 살펴봤습니다. cc_param이 왜 중요한지부터 플랫폼별 통화 흐름과 각 단계를 작동하는 구현 코드까지 다뤘습니다.

핵심 요점을 정리하면 다음과 같습니다.

  • 푸시는 모든 수신 통화의 시작입니다. 앱 서버가 푸시를 전송하면, 앱은 cc_param을 추출하고, PlanetKit이 이를 기반으로 통화를 검증합니다.
  • iOS는 PushKit과 CallKit을 사용합니다. PushKit은 앱이 즉시 실행되는 것을 보장하며, 약 5초 내에 CallKit에 보고하지 않으면 iOS는 앱을 종료합니다. iOS에는 포그라운드/백그라운드 구분이 없고 항상 verifyCall을 사용합니다.
  • Android는 FCM과 포그라운드 서비스를 사용합니다. 포그라운드 서비스는 통화가 울리는 동안 프로세스를 유지합니다. 푸시 수신 시 앱이 실행 중이 아니면 Firebase가 백그라운드 Isolate를 시작하며, 이 경우 verifyBackgroundCall을 사용해야 합니다.
  • Android의 백그라운드에서 포그라운드 전환 처리는, 포그라운드 서비스(즉 Kotlin 메모리)에 통화 ID를 저장하고 메인 Isolate 시작 시 adoptBackgroundCall을 호출하는 방식으로 이뤄집니다.
  • iOS에서는 notifyCallKitAudioActivation()을 꼭 호출해야 합니다. 누락하면 통화가 연결되더라도 오디오가 나오지 않습니다.
  • 이 글의 모든 예시 코드는 특정 푸시 페이로드 구조(cc_param, my_user_id 등)를 기준으로 작성했습니다. 실제 필드명은 여러분의 앱 서버에서 정의합니다.

다음은 추가로 참고할 만한 자료입니다.

이 글과 관련해 문의 사항이나 피드백이 있다면 dl_planet_help@linecorp.com으로 연락해 주시기 바랍니다.