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で通話を承諾/拒否する方法
この記事では、ユーザー認証情報(ユーザーID、サービスID、アクセストークン)を事前に準備したと仮定し、プッシュ処理とコールセットアップに焦点を当てます。認証情報の準備方法については、次のドキュメントを参照してください。
PlanetKit Flutterにおけるプッシュの役割
コードに入る前に、PlanetKit Flutterでプッシュ通知がどのような役割を果たし、なぜ受信通話に必須なのかを見てみましょう。
まず、PlanetKitはプッシュ転送を管理しません。ユーザーに電話がかかってくると、アプリサーバーがターゲットの端末にプッシュ通知を送ります。このために、アプリは次のことを行う必要があります。
- プッシュ受信
- ペイロードから
cc_paramフィールド抽出 - 抽出した
cc_param文字列からPlanetKitCcParamオブジェクト作成 PlanetKitManagerにPlanetKitCcParamを転送して通話の検証およびセットアップ
ここでのcc_paramは、通話のための搭乗券と考えてください。PlanetKitが通話セッションを設定するために必要な最小限の情報のみを含むPlanetKit専用のエンコード文字列です。
プッシュペイロード構造(cc_paramと発信者情報を含めるフィールド名を含む)は、PlanetKitではなくアプリサーバーで定義する必要があり、それぞれの状況に合わせて自由に設計できます。詳しくは、アプリサーバーの役割ドキュメントを参照してください。この記事の例では、特定のフィールド構造(cc_param、my_user_id、my_service_id、from_user_id)を使用します。
プッシュは、iOSとAndroidで、次のように各プラットフォームに限定された役割も果たします。
| プラットフォーム | プッシュタイプ | プッシュが何をトリガーすべきか |
|---|---|---|
| iOS | APNs VoIP(PushKit) | CallKit受信通話の報告:ネイティブ通話UIの表示 |
| Android | FCM(データメッセージ) | フォアグラウンドサービス:着信音が鳴っている間や通話中にプロセスを維持 |
通話フローを理解する
プッシュの役割を理解したら、次は各プラットフォーム別に全体の通話フローを見ていきましょう。2つのプラットフォームは、アプローチがかなり異なります。コードを作成する前に、なぜ異なるのかについて理解する必要があります。
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をマッピングする小さなマップを維持して同期します(この部分は実装セクションで詳しく説明します)。
プッシュを受信してから、およそ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の重要な特徴の1つは、Isolate間でDartメモリー空間を共有しないという点です。これにより、Firebaseがプッシュを処理するためにバックグラウンドIsolateを起動する際、メインIsolateで生成されたオブジェクト、シングルトン、状態にアクセスできません。ただし、Flutterのネイティブ(プラットフォーム)メモリーレイヤーは共有するため、フォアグラウンドサービスがKotlinメモリーに通話IDを保存すると、両方のIsolateがここにアクセスできます。なお、このような特性はAndroid FCMにのみ該当し、iOSではPushKitが常にアプリを直接起動させるため、別途バックグラウンドIsolateは存在しません。
上記の理由により、Androidの実行フローはアプリの実行状態に応じて2つに分かれます。
まず、アプリが実行中ではない場合のバックグラウンドプッシュフローは、次のとおりです。
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レイヤー(フォアグラウンドサービス)に保存されます。このプロセスは実装セクションでステップごとに説明します。
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)
}
}
}
上記の実装は、説明のために最小限に作成した例です。実際のサービスでは、包括的なエラー処理、ユーザー定義の通知アクション(例:拒否ボタン)、バッテリー最適化の許可案内、ブランドに合わせた通知チャンネルの管理などを含める必要があります。
次に、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では2つのケースを扱います。
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を経由しないでください。
もう1つ注意すべき点は、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();
}
}
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を紐づける
このステップでは、重要な詳細タスクが1つあります。CallKitはプッシュ時点でUUIDが必要ですが、PlanetKitの通話IDはverifyCallが非同期で完了した後にしか得られないという点です。この2つを小さなマップで紐づけるプロセスは、次のとおりです。
// 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 から呼び出すreportCallConnectedとreportCallEndedを受け取って、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は、2つの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は非常に単純で、コアのメソッドは2つです。
// 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 delegate | Dartイベント | PlanetKit通話 |
|---|---|---|---|
| 承諾をタップ | CXAnswerCallAction | accept | call.acceptCall() |
| 拒否/終了をタップ | CXEndCallAction | ended | call.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など)を基準に作成しました。実際のフィールド名は、アプリサーバーによって定義されます。
以下は、参考になる追加資料です。
- アプリサーバーの役割
- PlanetKit Flutter SDKドキュメント
- Apple PushKitドキュメント
- Apple CallKitドキュメント
- Firebase Cloud Messagingドキュメント
この記事に関する質問やフィードバックがある場合は、dl_planet_help@linecorp.comまでご連絡ください。