本文にスキップする

How to integrate push notifications with PlanetKit Flutter

Juheon Jeon

Hello, I'm Juheon Jeon, responsible for iOS/macOS and Flutter PlanetKit at LINE Planet.

LINE Planet is a real-time communications platform as a service (CPaaS) that provides the cloud platform and client SDKs needed to integrate voice and video calling into your service. PlanetKit Flutter is the Flutter SDK for LINE Planet, and in this post I'll walk you through one of its most critical integration points: push notifications and the incoming call flow.

By the end of this post, you'll understand:

  • Why push notifications are the entry point for every incoming call, and what cc_param does
  • The end-to-end call flow on the Flutter platform:
    • iOS: VoIP push → CallKit → PlanetKit
    • Android: Firebase Cloud Messaging (FCM) → foreground service → PlanetKit
  • How to configure the Android foreground service that keeps your app alive while a call rings
  • How to implement the push handlers for each platform and app state
  • How to handle accepting and declining calls in PlanetKit
Note

The guide assumes you already have user credentials (user ID, service ID, access token) available and focuses entirely on push handling and call setup. For how to prepare credentials, see the following documents:

The role of push in PlanetKit Flutter

Before we dive into code, let me explain the role push notifications play in PlanetKit Flutter and why they are essential for incoming calls.

PlanetKit itself does not manage push delivery. When a call comes in for your user, your app server sends a push notification to the target device. Your app is then responsible for:

  1. Receiving the push
  2. Extracting the cc_param field from the payload
  3. Creating a PlanetKitCcParam object from the extracted cc_param string
  4. Passing the PlanetKitCcParam to PlanetKitManager to verify and set up the call

Here, cc_param can be thought of as a boarding pass for the call. It is a PlanetKit-specific encoded string that contains just the information PlanetKit needs to establish the call session.

Warning

The push payload structure — including the field names used to carry cc_param and caller information — is defined by your app server, not PlanetKit. You can configure it freely. See the app server role documentation for details. The examples in this guide use a specific field structure (cc_param, my_user_id, my_service_id, from_user_id) as illustration only.

Push also serves a platform-specific purpose on iOS and Android. Let's look at each:

PlatformPush typeWhat push must trigger
iOSAPNs VoIP (PushKit)CallKit incoming call report — shows native call UI
AndroidFCM (data message)Foreground service — keeps process alive while ringing and during call

Understanding the call flow

Now that we understand the role of push, let's trace the full call flow on each platform. The two platforms take very different approaches, and it's worth understanding why before writing any code.

iOS: From VoIP push to CallKit to PlanetKit

First, PushKit is Apple's framework for delivering VoIP push notifications. Unlike regular APNs, PushKit wakes your app immediately — even if it has been terminated — and guarantees that your PKPushRegistryDelegate runs before any user interaction. This reliability is what makes it suitable for incoming call delivery. For more details, see Apple's PushKit documentation.

Next, CallKit is Apple's framework for integrating third-party VoIP calls with the native iOS phone experience. It provides the system-level incoming call screen (with the caller name, Accept, and Decline buttons). Importantly, Apple requires that any VoIP push received via PushKit must be processed by reporting an incoming call to CallKit — this is enforced at the OS level and cannot be skipped. For more details, see Apple's CallKit documentation.

Here is the full iOS call flow:

APNs delivers VoIP push

▼ PKPushRegistryDelegate (native Swift — Dart not yet running)
Generate CallKit UUID ← UUID required to identify call in CallKit
CallKit.reportNewIncomingCall(uuid, update) ← MUST happen here, within ~5s

▼ Forward VoIP push payload to Dart via MethodChannel
Dart receives { 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:)

In plain terms: when the push arrives, native Swift code immediately shows the CallKit incoming call UI and generates a UUID to identify the call. It then forwards the payload to Dart, where verifyCall handles the asynchronous PlanetKit verification. Because CallKit and PlanetKit use different identifiers, we maintain a small UUID-to-callId map to keep them in sync — more on that in the implementation section.

Warning

iOS terminates your app if the CallKit incoming call report is not made within approximately 5 seconds of push delivery. This must happen in native Swift code — never route the push through Dart first.

Android: From FCM push to foreground service to PlanetKit

FCM is Google's cross-platform messaging service. It lets your app server push data messages to Android clients, waking or notifying the app without requiring user interaction. FCM messages can arrive while the app is completely stopped — in which case, Firebase starts a background Dart isolate to deliver the message. For more details, see the Firebase Cloud Messaging documentation.

Here, let’s briefly go over the concept of an isolate and its characteristics. An isolate is an independent execution context with its own memory heap, and in Flutter, all Dart code runs inside an isolate. One important characteristic of isolates — especially when integrating push notifications — is that isolates do not share Dart memory space with each other. As a result, when Firebase starts a background isolate to handle a push, it cannot access objects, singletons, or state created in the main isolate. However, Flutter’s native (platform) memory layer is shared, so if a foreground service stores a call ID in Kotlin memory, it can be accessed from both isolates. For reference, this behavior applies only to Android FCM; on iOS, PushKit always wakes the app directly, so there is no separate background isolate.

For the reasons described above, the Android execution flow is divided into two cases depending on whether the app is running.

First, the background push flow when the app is not running is as follows:

FCM push received

▼ Top-level Dart function in a background isolate
Initialize PlanetKit in background isolate
PlanetKitManager.instance.verifyBackgroundCall(param, handler)

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

User taps notification → app launches


callId = await ForegroundService.instance.getCallId()
PlanetKitManager.instance.adoptBackgroundCall(callId, handler)
└─ Full call control in UI isolate

The foreground push flow when the app is already running is as follows:

FCM push received

▼ FirebaseMessaging.onMessage (main isolate — app is running)
PlanetKitManager.instance.verifyCall(param, handler)

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

The background case introduces extra complexity: the background Dart isolate has no access to your app's normal memory space, so PlanetKit must be initialized fresh in that isolate. The call ID is stored in the Kotlin layer (via the foreground service) so the main isolate can retrieve it when the user opens the app. We'll cover all of this step by step in the implementation sections.

Note

On iOS, verifyCall always works because PushKit guarantees the app is running before your delegate is called. On Android, FCM data messages can arrive while the app is completely stopped — verifyBackgroundCall must be used in that case, and the call ID is stored in the Kotlin layer so the main isolate can retrieve it when the app opens. If the app is already running, verifyCall is used directly, exactly like iOS.

Setting up the prerequisites

Before we implement push handling on Android, we need to set up a foreground service. Let me explain why this is necessary, then walk through the setup.

Android: Configuring the foreground service

On Android, background processes can be killed by the OS to free memory. A foreground service is a special type of Android service that runs at elevated priority and displays a persistent notification, preventing the OS from terminating it during time-sensitive operations like an incoming call ringing. Without it, your app process could be killed before the user has a chance to answer, so you must set it up before implementing push handling.

Declare the required permissions and the service in 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>

Now implement the service. The call ID received from Dart is stored in a companion object so that the MethodChannel handler (running on the main thread) can return it to Dart when queried — including from a different Dart isolate:

// 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

This is a minimal implementation for illustration purposes. A production app should add comprehensive error handling, custom notification actions (such as an inline Decline button), battery optimization allowlist guidance, and notification channel management appropriate for your branding.

Next, expose start, stop, and getCallId to Dart via a MethodChannel in MainActivity. The key insight here is that because currentCallId 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:

// 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)
}
}
}

Finally, wrap the MethodChannel in a Dart singleton ForegroundService for convenient access from anywhere in your app:

// 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');
}

Turning a push notification into a ringing call

With the prerequisites in place, let's implement the core logic: receiving a push notification and turning it into a verified, ringing call. We'll cover iOS first, then the two Android cases.

Implementing the iOS push flow

On iOS, PushKit VoIP pushes always wake the app and execute PKPushRegistryDelegate immediately. By the time the push payload reaches Dart via MethodChannel, the Flutter engine is already running. verifyCall is always used — there is no foreground/background distinction on iOS.

Step 1: Register for VoIP pushes (native Swift)

Set up PKPushRegistry in your AppDelegate or a dedicated handler:

// Swift
import PushKit

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

When the system issues a token, forward it to Dart so your server can register the device:

// 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)
}

Step 2: Configure CallKit (native Swift)

Set up CXProvider once at app startup:

// 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

Add background mode keys to 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>

Enable Push Notifications, Background Modes > Voice over IP, and Background Modes > Audio, AirPlay, and Picture in Picture in Xcode Signing & Capabilities.

Step 3: Report to CallKit immediately on push arrival (native Swift)

This is the most important step on iOS. When a VoIP push arrives, you must report to CallKit before the delegate method returns — within approximately 5 seconds. Do not route through Dart first.

A UUID must be generated here to identify this call within CallKit. CallKit requires a UUID to track the call through its lifecycle (incoming, connected, ended). The UUID is generated in native code at push time — before Dart is running — and then passed to Dart so both sides can refer to the same call.

// 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)
}

Step 4: Forward and route CallKit UI events

CXProviderDelegate fires when the user taps Accept, End, Mute, etc. Forward these to Dart via an EventChannel (Swift side), then route them to PlanetKit in your event handler (Dart side).

// 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"])
}

When these events arrive in Dart, route them to 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

notifyCallKitAudioActivation() must be called when the audioSessionActivated event fires. PlanetKit does not start using the microphone until this method is called.

Step 5: Receive the push in Dart and call 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);
}

Bridging CallKit UUIDs and PlanetKit call IDs

Here's an important subtlety: CallKit needs a UUID at push time, but PlanetKit's call ID is only available after the asynchronous verifyCall completes. We bridge them with a small map:

// 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;
}

The Swift side registers a callChannel MethodChannel handler that receives reportCallConnected and reportCallEnded invocations from Dart and translates them to CXProvider API calls. Note the parameter is named callKitUuid — not callId — to make clear it carries a CallKit UUID, not a PlanetKit call ID:

// 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)
}

Implementing the Android background push flow

On Android, the background case is more involved because Firebase starts a separate Dart isolate when the app isn't running. This isolate has its own Dart memory space — it has no access to your app's memory space initialized in main(). Because isolates do not share Dart memory, no Dart objects or singletons from the main isolate are available here, which is why PlanetKit must be initialized independently. The resulting call ID is stored in the Kotlin (native) layer — the one memory space both isolates can reach — and retrieved by the main isolate when the user opens the app.

Step 1: Register a top-level background message handler

The handler must be a top-level Dart function annotated with @pragma('vm:entry-point'). Register it in main() before 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());
}

Step 2: Initialize PlanetKit in the background isolate

// 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);
}

Step 3: Call 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();
},
);

Step 4: Adopt the background call when the app opens

When the user taps the notification and the app launches, retrieve the call ID from the Kotlin layer and hand the call off to the main 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)
}

Implementing the Android foreground push flow

The foreground case is much simpler. When your app is already running, Firebase delivers the message directly to the main isolate via FirebaseMessaging.onMessage. There is no background isolate — use verifyCall directly, the same as iOS. verifyBackgroundCall and adoptBackgroundCall are not needed.

// 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)
});

Answering and declining: the final step

At this point, the call is verified and ringing — the ringtone is playing and the call is waiting for the user's response. The final step is handling that response.

After verifyCall (iOS and Android foreground) or adoptBackgroundCall (Android background) completes successfully, you hold a PlanetKitCall instance. The API is intentionally simple — just two methods:

// 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();
}

How CallKit handles Accept and Decline on iOS

On iOS, the user answers or declines via the native CallKit screen shown automatically when reportNewIncomingCall is called at push time. Your app does not need to present its own incoming call UI.

The CXProviderDelegate and _onCallKitEvent set up in the iOS implementation section handle the full cycle automatically:

User actionCallKit delegateDart eventPlanetKit call
Tap AcceptCXAnswerCallActionacceptcall.acceptCall()
Tap Decline/EndCXEndCallActionendedcall.endCall()

Closing

In this post, we walked through the full push notification integration for PlanetKit Flutter — from why cc_param is essential, through the platform-specific call flows, down to the code that makes each step work.

Here's a summary of the key points:

  • Push is the entry point for every incoming call. Your app server sends a push, your app extracts cc_param, and PlanetKit verifies the call from there.
  • iOS uses PushKit + CallKit. PushKit guarantees immediate app wake; CallKit must be reported within ~5 seconds or iOS terminates your app. There is no foreground/background distinction on iOS — verifyCall always applies.
  • Android uses FCM + a foreground service. The foreground service keeps your process alive while the call rings. If the app isn't running when the push arrives, Firebase starts a background Dart isolate — use verifyBackgroundCall in that case.
  • Background-to-foreground handoff on Android is done by storing the call ID in Kotlin memory (via the foreground service) and calling adoptBackgroundCall when the main isolate starts.
  • notifyCallKitAudioActivation() is required on iOS. Missing it results in a connected call with no audio.
  • All code blocks in this guide use a specific push payload structure (cc_param, my_user_id, etc.) as illustration. Your actual field names are defined by your own app server.

For further reading:

If you have any questions or feedback about this guide, please contact us at dl_planet_help@linecorp.com.