Androidでのプッシュ通知

概要

AndroidのVoIPプッシュ通知を実現するには、以下の2つのシステム連携が必要です:

  1. Firebase Cloud Messaging (FCM) — アプリが強制終了された場合でも、優先度の高いデータメッセージを配信してアプリを起動させます
  2. Jetpack Core-Telecom (CallsManager) — Androidのテレコムスタックにこの通話を登録し、ネイティブな通話処理(システムコールUI、音声ルーティング、フォアグラウンド実行優先度)を実行します

コンポーネントの役割

コンポーネント 役割
FirebaseMessagingService FCMメッセージを受信し、コールマネージャーに委譲します
VoiceClient Vonage SDK — セッション管理、コールシグナリング、メディア
CallsManager Jetpack Core-Telecom — 以下の方法で OS に通話を登録します addCall
CallControlScope 取っ手 addCall 戻り値 — ドライブ(応答/保留/切断/音声転送)および OS 呼び出しの監視
開始しました Service 所有している CallStyle 通知を表示し、登録を処理し、バックグラウンドに移行したり画面をスワイプしたりした際も通話を維持します

このガイドでは、 CallsManager (その androidx.core.telecom (Jetpack ライブラリ)、Google が VoIP 通話用に推奨する API、そしてその 参考サンプルアプリ 用途。従来の android.telecom.ConnectionService まだ動作します — 参照 レガシー:ConnectionService その理由は CallsManager が望ましい。

iOSとの主な違い

iOSのPushKitとは異なり、AndroidのFCMには 厳格な締め切りはない 着信UIを表示するためです。着信を表示する前に、非同期処理(セッションの復元、トークンの更新)を安全に実行できます。これにより、iOSの実装で問題となるレースコンディションを解消できます。

プロジェクトの設定

Gradleの依存関係

dependencies {
    // Firebase
    implementation(platform("com.google.firebase:firebase-bom:33.0.0"))
    implementation("com.google.firebase:firebase-messaging")

    // Jetpack Core-Telecom — CallsManager
    implementation("androidx.core:core-telecom:1.0.1")

    // Vonage Client SDK
    implementation("com.vonage:client-sdk-voice:latest.release")
}
plugins {
    id("com.google.gms.google-services") version "4.4.2" apply false
}
plugins {
    id("com.google.gms.google-services")
}

Google-Services.json

Firebase コンソールからダウンロードし、次の場所に配置してください app/google-services.json. これを追加してください .gitignore — ここには、Firebase プロジェクトの認証情報が含まれています。

マニフェストの項目

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Permissions -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
    <uses-permission android:name="android.permission.CALL_PHONE" />
    <uses-permission android:name="android.permission.READ_PHONE_STATE" />
    <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.POST_NOTIFICATIONS" />
    <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
    <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />

    <application ...>

        <!-- FCM Service -->
        <service
            android:name=".services.PushNotificationService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>

        <!-- Started (NOT foreground) service: owns the CallStyle notification,
             hosts the CallsManager registration, and keeps the call alive when
             backgrounded/swiped. Core-Telecom provides the foreground service
             itself, so you do NOT declare one of your own. -->
        <service
            android:name=".telecom.CallService"
            android:exported="false" />

        <!-- Delivers the CallStyle notification's Decline / Hang up actions. -->
        <receiver
            android:name=".telecom.CallActionReceiver"
            android:exported="false" />

        <!-- Incoming call activity (shows over lock screen) -->
        <activity
            android:name=".ui.CallActivity"
            android:showOnLockScreen="true"
            android:turnScreenOn="true"
            android:showWhenLocked="true"
            android:exported="false" />

    </application>
</manifest>

ランタイム権限

呼び出しを行う前に、実行時に以下をリクエストしてください:

private val requiredPermissions = arrayOf(
    Manifest.permission.RECORD_AUDIO,
    Manifest.permission.READ_PHONE_STATE,
)

// For API 33+
private val api33Permissions = arrayOf(
    Manifest.permission.POST_NOTIFICATIONS,
)

MANAGE_OWN_CALLS そして CALL_PHONE これらは通常の権限です。インストール時に自動的に付与されるため、実行時にダイアログが表示されることはありません。

警告だ: RECORD_AUDIO 通話試行の前に、必ず許可を得る必要があります。許可が得られない場合、SDKは通話を接続しますが、相手側には無音状態として聞こえます。

SDKの初期化

初期化 VoiceClient 人生で一度は Application.onCreate() またはシングルトンとして使用してください。決して再作成しないでください。再作成すると、すべてのリスナーがクリアされ、セッションの状態が失われます。

class CoreContext(context: Context) {
    // Immutable snapshot of the in-progress call — the single source of truth
    // for the UI and the call notification (replaces a Connection subclass).
    val activeCall = MutableStateFlow<ActiveCall?>(null)

    var pushToken: String? = null
    var lastRegisteredPushToken: String? = null
    var deviceId: String? = null
    var authToken: String? = null
    var refreshToken: String? = null
}
class App : Application() {
    lateinit var voiceClientManager: VoiceClientManager
        private set
    lateinit var coreContext: CoreContext
        private set

    override fun onCreate() {
        super.onCreate()
        instance = this
        coreContext = CoreContext(this)
        val config = VGClientInitConfig(LoggingLevel.Info)
        val client = VoiceClient(this, config)
        voiceClientManager = VoiceClientManager(client, this)
    }

    companion object {
        lateinit var instance: App
            private set
    }
}

FCMとの連携

FirebaseMessagingService

サービスはスリムに保つこと――すべてのロジックはマネージャークラスに委譲してください。

class PushNotificationService : FirebaseMessagingService() {

    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)
        // Delegate immediately — keep the service class thin
        App.instance.voiceClientManager.processVoipPush(remoteMessage)
    }

    override fun onNewToken(token: String) {
        super.onNewToken(token)
        // Store and re-register if session is active
        App.instance.coreContext.pushToken = token
        App.instance.voiceClientManager.onPushTokenRefreshed(token)
    }

    companion object {
        fun requestToken(callback: (String) -> Unit) {
            FirebaseMessaging.getInstance().token
                .addOnSuccessListener { callback(it) }
                .addOnFailureListener { println("FCM token request failed: $it") }
        }
    }
}

トークンの取り扱い

FCMはいつでもトークンをローテーションすることができます。 onNewToken は のみ 確実な通知。発火したときは:

  1. 新しいトークンをローカルに保存する
  2. セッションがすでにアクティブな場合は、以下を呼び出します。 registerDevicePushToken 直ちに

トークンが期限切れになると、Vonageはそのデバイスに接続できなくなります。それ以降、すべてのプッシュ通知は、通知されることなく配信不能となります。

Vonageへのトークンの登録

 client.registerDevicePushToken(token) { error, deviceId ->
     if (error != null) {
         println("Failed to register push token: $error")
         return@registerDevicePushToken
     }

     println("Push token registered successfully, deviceId: $deviceId")
 }
 client.registerDevicePushToken(token, (error, deviceId) -> {
     if (error != null) {
         System.out.println("Failed to register push token: " + error);
         return null;
     }

     System.out.println("Push token registered successfully, deviceId: " + deviceId);
     return null;
 });

FCMトークンを以下に登録してください client.registerDevicePushToken(token) セッションが作成された後、サーバーは一意の deviceId 登録ごとに実行されるため、トークンが変更されていない場合はこの呼び出しを避けてください。そうしないと、重複した登録が蓄積され、1回の呼び出しにつき複数のプッシュ配信が行われてしまいます。

private fun registerDevicePushTokenIfNeeded() {
    val registerCallback: (String) -> Unit = { token ->
        // Skip registration if the token is the same as the one we last registered
        if (token == coreContext.lastRegisteredPushToken && coreContext.deviceId != null) {
            println("Push token unchanged — skipping re-registration")
            return@registerCallback
        }
        client.registerDevicePushToken(token) { err, deviceId ->
            err?.let {
                println("Failed to register push token: $err")
            } ?: deviceId?.let {
                coreContext.deviceId = deviceId
                coreContext.lastRegisteredPushToken = token
                println("Push token registered, deviceId: $deviceId")
            }
        }
    }

    coreContext.pushToken?.let { registerCallback(it) }
        ?: PushNotificationService.requestToken { registerCallback(it) }
}

fun unregisterDeviceToken(completion: () -> Unit) {
    val deviceId = coreContext.deviceId ?: run { completion(); return }

    client.unregisterDevicePushToken(deviceId) { error ->
        error?.let { println("Unregister error: $it") }
        coreContext.deviceId = null
        completion()
    }
}

優先度の高いメッセージとDozeモード Vonageのバックエンドは、FCMメッセージを 最優先. 優先度の高いメッセージは、AndroidのDozeモードをバイパスして、アプリを即座に起動させます。アプリ側で設定を行う必要はありません。これはVonageプラットフォームのサーバーサイド設定です。

セッション管理

Vonageのセッション(client.createSession(jwt)) SDKが通話招待を処理したり、通話操作を実行したりするには、これが有効になっている必要があります。プッシュ通知が届いた時点でアプリが終了していたり、セッションの有効期限が切れていたりする場合は、まず復元を行う必要があります。

iOSとは異なり、Androidではチェーン処理は安全です

AndroidのFCMには、PushKitのようなシステムUIを表示するためのタイムリミットはありません。セッションの復元が完了するのを待ってから呼び出しても問題ありません。 client.processPushCallInvite. これにより、セッションの準備が整う前にユーザーが回答してしまうという競合状態が解消されます。

推奨される手順:まずセッションを復元してください。アクティブであることが確認できたら、 processPushCallInvite. 以下の条件を満たす場合、そのセッションは確実に存在します。 answer() / reject() が呼び出されます。

避けるべきこと:プッシュを直ちに処理し、セッションの復元を並行して実行すること。セッションの準備が整う前にユーザーが応答してしまう可能性があり、その結果、 client.answer() 何の前触れもなく失敗する。

セッション作成

 do {
     let sessionId = try await client.createSession(jwt)
     print("Session created: \(sessionId)")
 } catch {
     print("Failed to create session: \(error.localizedDescription)")
 }
 [client createSession:jwt callback:^(NSError * _Nullable error, NSString * _Nullable sessionId) {
     if (error != nil || sessionId == nil) {
         NSLog(@"Failed to create session: %@", error);
         return;
     }
     NSLog(@"Session created: %@", sessionId);
 }];
fun login(
    token: String,
    isUserInitiated: Boolean = true,
    onError: ((Exception) -> Unit)? = null,
    onSuccess: ((String) -> Unit)? = null
) {
    if (isUserInitiated) {
        // On user-initiated login: clean up any stale device registration first,
        // then create session and register the push token (always needed after unregister)
        unregisterExistingDeviceIfNeeded {
            createSession(token, registerPush = true, onError, onSuccess)
        }
    } else {
        // Session restore: skip cleanup (same user, just reconnecting).
        // Only re-register push token if the FCM token has changed since last registration.
        createSession(token, registerPush = false, onError, onSuccess)
    }
}

private fun createSession(
    token: String,
    registerPush: Boolean = true,
    onError: ((Exception) -> Unit)? = null,
    onSuccess: ((String) -> Unit)? = null
) {
    client.createSession(token) { error, sessionId ->
        sessionId?.let { sid ->
            coreContext.authToken = token       // persist for future restoration
            if (registerPush) {
                registerDevicePushTokenIfNeeded()
            }
            onSuccess?.invoke(sid)
            _sessionId.value = sid
        } ?: error?.let { onError?.invoke(it) }
    }
}

セッション復元優先順位チェーン

優先順位 状態 アクション 一般的なレイテンシ
第1位 アクティブなセッションが存在します (sessionId != null) 直ちに帰国してください 0 ms
第2位 保存済みのJWTが利用可能です (authToken) client.createSession(storedJWT) 100~400ミリ秒
第3位 JWTの有効期限が切れましたが、リフレッシュトークンは利用可能です エンドポイントを更新 → 新しいJWT → createSession 300~1000ミリ秒
4番目 認証情報なし 戻る null — push を処理できません 0 ms
private fun restoreSessionIfNeeded(completion: (String?) -> Unit) {
    _sessionId.value?.let { completion(it); return }  // already active

    coreContext.authToken?.let { token ->
        restoreSessionWithToken(token, completion)
    } ?: coreContext.refreshToken?.let { refresh ->
        restoreSessionWithRefreshToken(refresh, completion)
    } ?: completion(null)   // no credentials
}

ログイン時にJWTを永続化します。もし createSession プッシュ通知が届くたびにJWTを取得するためにバックエンドへのリアルタイム呼び出しが必要となるため、セッションの準備が整う前に余分なネットワークホップが追加されてしまいます。接続速度が遅い場合、これにより復旧の遅延がさらに増大し、呼び出しに対する応答の信頼性が低下します。

再ログイン時のトークンの蓄積を防ぐ

ユーザーが新たにログインする際は、新しいセッションを作成する前に、必ず以前に登録されたデバイスの登録を解除してください。この手順を省略すると、Vonage に複数のデバイス登録が蓄積され、同じ通話に対してプッシュ通知が重複して送信される原因となります。

private fun unregisterExistingDeviceIfNeeded(completion: () -> Unit) {
    val existingDeviceId = coreContext.deviceId ?: run { completion(); return }
    val authToken = coreContext.authToken ?: run {
        coreContext.deviceId = null; completion(); return
    }
    // Create a temporary session just to perform the unregistration
    client.createSession(authToken) { _, sessionId ->
        sessionId ?: run { coreContext.deviceId = null; completion(); return@createSession }
        unregisterDeviceToken {
            client.deleteSession { completion() }
        }
    }
}

プッシュペイロードの処理

FCM配信からシステムの着信UIに至るまでの全プロセス:

実施

fun processVoipPush(remoteMessage: RemoteMessage) {
    // Step 1: ensure a session exists before touching the push payload
    restoreSessionIfNeeded { sessionId ->
        sessionId ?: run {
            println("Session restoration failed — cannot process push")
            return@restoreSessionIfNeeded
        }
        // Step 2: now safe to process — session is guaranteed active
        processIncomingPush(remoteMessage)
    }
}

fun processIncomingPush(remoteMessage: RemoteMessage) {
    val dataString = remoteMessage.data.toString()
    val type = VoiceClient.getPushNotificationType(dataString)
    if (type == PushType.INCOMING_CALL) {
        // Triggers setCallInviteListener callback
        client.processPushCallInvite(dataString)
    }
}

コール招待の処理

SDKが実行されたとき setCallInviteListener, 通話をアプリの状態として記録し、 CallService — これが、テレコムへの通話を登録する仕組みです(参照: 通信システムの統合 (なぜこれをサービス側で行う必要があるのかについては)。

// In setClientListeners()
client.setCallInviteListener { callId, from, type ->
    // One call at a time — ignore a second invite
    coreContext.activeCall.value?.let { return@setCallInviteListener }
    coreContext.setActiveCall(
        ActiveCall(callId = callId, displayName = from, isIncoming = true, state = CallState.RINGING)
    )
    CallService.register(context)   // starts the service, which runs callsManager.addCall
}

CallsManagerとの通信システム連携

OSへの通話の登録は、Core-Telecomの CallsManager. アプリを一度登録すれば、あとは addCall 通話ごと:テレコムにその通話の詳細情報を提供します(CallAttributesCompatそして その呼び出しの存続期間全体にわたって一時停止する, これにより、 CallControlScope 操作(応答/保留/切断/音声のルーティング)や監視(ミュート状態、音声エンドポイント)を行うため。

凝縮した形状(参照: CallManager.kt (完全なラッパーについては):

private val callsManager = CallsManager(context).apply {
    registerAppWithTelecom(CallsManager.CAPABILITY_BASELINE)
}

suspend fun registerCall(displayName: String, isIncoming: Boolean) {
    val attributes = CallAttributesCompat(
        displayName = displayName,
        address = Uri.fromParts("sip", displayName, null),
        direction = if (isIncoming) CallAttributesCompat.DIRECTION_INCOMING
                    else CallAttributesCompat.DIRECTION_OUTGOING,
    )
    callsManager.addCall(
        attributes,
        onAnswer = { onTelecomAnswer() },          // answered from system UI / watch / Auto
        onDisconnect = { onTelecomDisconnect() },  // ended from system UI / watch / Auto
        onSetActive = {},
        onSetInactive = {},
    ) {
        // CallControlScope — drive + observe the OS call for its whole lifetime
        launch { answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL) } // or setActive() / disconnect(...)
        launch { currentCallEndpoint.collect { /* active audio route */ } }
        launch { availableEndpoints.collect { /* route options */ } }
        launch { isMuted.collect { muted -> onMuteChanged(muted) } }
    }
}

起動済みのサービスで登録処理を実行する

電話 addCall より サービスの提供を開始しました スコープであり、アプリのシングルトンスコープではありません。起動されたサービスは、プロセスにTelecomが 維持する その呼び出し;サービスアンカーを持たない、コールドスタートされたバックグラウンドプロセスから追加された呼び出しが、追加された直後に破棄される(CallException.ERROR_CALL_IS_NOT_BEING_TRACKED)、その後、すべての制御操作が失敗します。これが、招待ハンドラーが開始される理由です。 CallService、そしてこのサービスが登録処理を実行します。詳細は CallService.kt.

切断の原因を特定する

Core-Telecomの disconnect 受け入れる のみ LOCAL, REMOTE, MISSEDそして REJECTED — その他 DisconnectCause セッションを投げ捨て、引き裂く。境界上のマップ:

private val VALID_CAUSES = setOf(
    DisconnectCause.LOCAL, DisconnectCause.REMOTE,
    DisconnectCause.MISSED, DisconnectCause.REJECTED,
)
fun disconnect(cause: Int) {
    val safe = if (cause in VALID_CAUSES) cause else DisconnectCause.LOCAL
    onControl { disconnect(DisconnectCause(safe)) }
}

唯一の真実の源: ActiveCall

アプリのステートを Connection サブクラスでは、不変の ActiveCall UIと通知がこれを参照しており、SDKとTelecomのコールバックの両方がこれを更新します。この CallManager wrapper は「ファイア・アンド・フォーゲット」型の OS 統合であり、呼び出しの状態を一切書き戻しません。詳細は以下を参照してください。 ActiveCall.kt.

フォアグラウンド実行とCallStyle通知

そうね 違う マイクのフォアグラウンドサービスを作成します。通話登録が行われている間、 CallsManager そして a CallStyle 通知が掲載されると、Core-Telecomはその通話を phoneCall|microphone ユーザー向けのフォアグラウンドサービス――これこそが、サービスが強制終了されたりバックグラウンドに移行したりした後でも、ライブマイク音声とフォアグラウンド実行の優先権を保証するものです。手動で起動するフォアグラウンドサービスは冗長であり、競合を引き起こす可能性があります。

について CallStyle したがって、通知は単なる形式的なものではなく、必須のものです。正しく行うべき点が2つあります(参照: CallNotifier.kt):

  • 着信時の全画面表示インテント ロック状態のデバイスでプッシュ通知が届くと、キーガード上の「wakes your call」アクティビティが起動します。
  • 答えは「アクティビティ」でなければなりません PendingIntent、その後呼び出しを行うような放送ではなく startActivity — 後者は通知トランポリンであり、Android 12以降で使用が禁止されています。「拒否」や「通話終了」は、ある BroadcastReceiver.

進行中の通知のタイマーを実際の接続時間に固定し、入力が変更された場合にのみ通知を再投稿するようにします。これにより、ミュートや保留の切り替えによって通話時間のタイマーがリセットされるのを防ぎます。

SDK リスナー

初期化直後に、すべてのリスナーを一度だけ設定する VoiceClient、セッションを作成する前に。各SDKイベントを ActiveCall 状態を保持し、すべてのターミナルイベントを単一のクリーンアップパス経由で処理することで、テレコム通話が常に切断され、通知が常にキャンセルされるようにする。(完全なセットは VoiceClientManager.kt.)

アクティブ / 再接続中

// Outbound leg answered, or media (re)connected -> active
client.setOnLegStatusUpdate { callId, _, status ->
    if (currentIfMatches(callId) != null && status == LegStatus.answered) markActive()
}
client.setOnCallMediaReconnectingListener { callId ->
    if (currentIfMatches(callId) != null) updateActiveCall { copy(state = CallState.RECONNECTING) }
}
client.setOnCallMediaReconnectionListener { callId ->
    if (currentIfMatches(callId) != null) markActive()
}

markActive() セット ActiveCall.state = ACTIVE (通知クロノメーターの接続時間を固定し)、そして呼び出しを行います telecom.setActive().

終末事象

SDKの理由を DisconnectCause, その後、1つを実行する cleanUp path(以下を呼び出す) telecom.disconnect(cause), 切断状態を短時間表示し、その後クリアします ActiveCall).

client.setOnCallHangupListener { callId, _, reason ->
    if (currentIfMatches(callId) != null) {
        val cause = when (reason) {
            HangupReason.remoteReject          -> DisconnectCause.REJECTED
            HangupReason.remoteHangup          -> DisconnectCause.REMOTE
            HangupReason.localHangup           -> DisconnectCause.LOCAL
            HangupReason.mediaTimeout          -> DisconnectCause.BUSY
            HangupReason.remoteNoAnswerTimeout -> DisconnectCause.CANCELED
        }
        cleanUp(cause)
    }
}

client.setCallInviteCancelListener { callId, reason ->
    if (currentIfMatches(callId) != null) {
        val cause = when (reason) {
            VoiceInviteCancelReason.AnsweredElsewhere -> DisconnectCause.ANSWERED_ELSEWHERE
            VoiceInviteCancelReason.RejectedElsewhere -> DisconnectCause.REJECTED
            VoiceInviteCancelReason.RemoteCancel      -> DisconnectCause.CANCELED
            VoiceInviteCancelReason.RemoteTimeout     -> DisconnectCause.MISSED
        }
        cleanUp(cause)
    }
}

client.setOnCallMediaDisconnectListener { callId, _ ->
    if (currentIfMatches(callId) != null) cleanUp(DisconnectCause.ERROR)
}

なぜなら cleanUp 実行 telecom.disconnectその DisconnectCause 上記のコードは、Core-Telecomが許容する4つの値の範囲内に制限されています(参照: 切断の原因を特定する).

発信

~を使う場合よりも簡単です ConnectionService — いいえ placeCall / onCreateOutgoingConnection dance であり、OEM フォールバックタイマーは使用しません。SDK 呼び出しを開始し、それをアクティブな呼び出しとして記録した後、Telecom に登録するためにサービスを開始します。 DIRECTION_OUTGOING:

fun startOutboundCall(callContext: Map<String, String>? = null) {
    client.serverCall(callContext) { err, callId ->
        callId ?: run { err?.let { println("Outbound call error: $it") }; return@serverCall }
        coreContext.setActiveCall(
            ActiveCall(callId = callId, displayName = callee, isIncoming = false, state = CallState.DIALING)
        )
        CallService.register(context)   // registers the outgoing call with Telecom
    }
}

エラー処理

SDKが起動します setSessionErrorListener アクティブなセッション中にWebSocketが切断された場合、エラーの原因に基づいて適切な復旧戦略を選択し、自動的に復旧を試みます。

client.setSessionErrorListener { reason ->
    when (reason) {
        SessionErrorReason.TokenExpired ->
            // Stored JWT is invalid — skip it, go straight to refresh token
            attemptSessionRestoration(skipAuthToken = true)

        SessionErrorReason.TransportClosed,
        SessionErrorReason.PingTimeout ->
            // Transient network issue — try stored JWT first
            attemptSessionRestoration(skipAuthToken = false)
    }
}

private fun attemptSessionRestoration(skipAuthToken: Boolean = false) {
    val handleFailure = {
        clearSession()
        // Prompt user to log in again
    }
    val fallbackToRefresh = {
        coreContext.refreshToken?.let { token ->
            restoreSessionWithRefreshToken(token) { it ?: handleFailure() }
        } ?: handleFailure()
    }

    if (!skipAuthToken) {
        coreContext.authToken?.let { token ->
            restoreSessionWithToken(token) { it ?: fallbackToRefresh() }
        } ?: fallbackToRefresh()
    } else {
        fallbackToRefresh()
    }
}

よくあるエラーの事例

エラー 原因 回復
TokenExpired JWTのTTLが期限切れになりました リフレッシュトークンを使用して新しいJWTを取得する
TransportClosed ネットワーク障害 保存済みのJWTを使って再試行する
PingTimeout サーバーに接続できません 指数関数的バックオフを用いた再試行
registerDevicePushToken 失敗する セッションが有効ではありません 確保する createSession 登録前に完了しておく
processPushCallInvite 無音 セッションなし 必ず最初にセッションを復元してください
answer() 失敗する プッシュと応答の間にセッションがタイムアウトしました セッションを再度復元してから、回答してください

レガシー:ConnectionService

Jetpack Core-Telecomが登場する前は、自己管理型のVoIP通話には android.telecom.ConnectionService 直接:あなたは PhoneAccount, サブクラス化された ConnectionService, 戻された Connection ~からのオブジェクト onCreateIncomingConnection / onCreateOutgoingConnection、そして自分自身の microphone フォアグラウンドサービス。これは現在も動作しており、 非推奨ではない, そのため、既存の連携機能については、急いで移行する必要はありません。

について 新しい 統合、 CallsManager が推奨されます:

CallsManager (Core-Telecom) ConnectionService (旧バージョン)
Android 14 FGS タイプ + 権限 お任せください FGSはご自身で宣言し、起動してください
フォアグラウンド・サービス 図書館提供 あなたが作成・管理します
マルチデバイス対応(ウェアラブル/自動車/Bluetooth) 内蔵 マニュアル
APIの形状 1 addCall + a CallControlScope PhoneAccount + Connection サブクラス + システムコールバック
OEMの発信に関する特有の挙動 ライブラリによって処理されます 手動フォールバックタイマー(サムスン/シャオミ)
Min API 26 23

Android 14におけるポイントは、実用的な要因にあります。API 34以降では、OSがフォアグラウンドサービスの種類と、それに対応する権限(例: RECORD_AUDIO) サービスが開始された時点で。 CallsManager これであなたの要件を満たします。手巻き式の ConnectionService 統合は正確に行わなければならず、そうしないと最新のデバイスでは呼び出しが失敗します。

参照 ConnectionService リファレンス 既存の連携を維持する場合。

落とし穴とアンチパターン

1. セッション復元前のプッシュ処理

してはいけないこと:

電話 processPushCallInvite セッションの存在を確認せずに、プッシュ通知の受信直後に処理が行われます。これにより、通話招待リスナーが起動せず、UIが表示されないまま着信を逃してしまう可能性があります。

行うこと:

処理前のチェーンセッションの復元: restoreSessionIfNeeded { processPushCallInvite(data) }.

2. プッシュのたびにバックエンドからJWTを取得する

してはいけないこと:

その前に、バックエンドへのライブ呼び出しを必須とする createSession プッシュのたびに。これにより、コールドスタート時のプッシュにおけるレイテンシが2倍になります。

行うこと:

ログイン時にJWTを永続化しておけば、復元には1回だけで済みます createSession コール。

3. セッション作成後にプッシュトークンを登録しない

してはいけないこと:

プッシュトークンは、アプリの起動時に一度だけ登録し、それ以降は登録する必要はありません。ユーザーがログインするたびに(ログアウト時に前回のデバイス登録が解除されるため)、Vonage にトークンを登録する必要があります。

行うこと:

電話 registerDevicePushToken 内 createSession ユーザーによるログイン時の成功コールバック。セッションの復元については、FCMトークンが変更されていない限り、登録処理をスキップしてください(落とし穴 #5 を参照)。

4. ログアウト時に登録解除されない

してはいけないこと:

スキップ unregisterDevicePushToken ログアウト時。以前のユーザーの着信は、引き続きこの端末に届きます。

行うこと:

必ず電話してください unregisterDevicePushToken 以前 deleteSession ログアウト中。

5. 重複するデバイストークンの登録

してはいけないこと:

電話 registerDevicePushToken すべての createSession トークンが変更されたかどうかに関係なく、Vonageサーバーは新しい deviceId 各登録呼び出しごとに — 同じトークンによる繰り返し呼び出しは重複登録となり、1回の呼び出しにつき複数のプッシュ配信が発生します。

行うこと:

最後に正常に登録されたトークンを追跡します(例: coreContext.lastRegisteredPushToken). 以下の場合のみ呼び出してください registerDevicePushToken 日時:

  • これは、ユーザーによる新規ログインです(以前のデバイスは登録解除されています)。
  • FCMトークンが変更されました(onNewToken (解雇された)
  • coreContext.deviceId null です(事前の登録がありません)

トークンが以前に登録されたものと一致する場合は、セッションの復元時に登録をスキップする。

6. 期限切れのFCMトークンが再登録されていない

してはいけないこと:

無視する onNewToken. FCMは予告なしにトークンをローテーションするため、今後のプッシュ通知はすべて配信不能となります。

行うこと:

で onNewToken, 新しいトークンを保存し、以下を呼び出します registerDevicePushToken セッションがアクティブな場合。

7. 起動済みのサービス外でのコールの登録

してはいけないこと:

電話 callsManager.addCall アプリシングルトンまたはワンオフのコルーチンスコープから。バックグラウンドからのコールドスタート時、Telecomはコールを追加した後、直ちにそれを破棄します(ERROR_CALL_IS_NOT_BEING_TRACKED).

行うこと:

「開始」 Service そして addCall その適用範囲外であるため、テレコムが当該通話を保持する法的根拠が当該手続きには存在する。

8. CallStyle通知を送信しない

してはいけないこと:

通話を登録するものの、 CallStyle 通知。Core-Telecomは、そのような通知が表示されている間のみ、フォアグラウンド/マイクの優先権を付与します。通知が表示されていない場合、バックグラウンドでは通話にマイクへの確実なアクセス権がありません。

行うこと:

投稿する CallStyle 通話全体に対する通知で、着信時にはフルスクリーン表示になります。

9. 独自のマイク・フォアグラウンド・サービスを追加する

してはいけないこと:

マニュアルを宣言して開始する foregroundServiceType="microphone" サービス。Core-Telecom では冗長化されており、競合する可能性があります。

行うこと:

フォアグラウンド・サービスはCore-Telecomに任せてください。御社のサービスは単なる 開始 通知およびプロセスの定着のためのサービス。

10. サポートされていない DisconnectCause の渡し

してはいけないこと:

任意に転送 DisconnectCause Core-Telecomのコード disconnect — 外部のあらゆるもの LOCAL/REMOTE/MISSED/REJECTED セッションを破棄し、終了させます。

行うこと:

呼び出す前に、境界で原因を封じ込める disconnect.

11. プッシュごとにVoiceClientを再作成する

してはいけないこと:

インスタンス化 VoiceClient あなたの FirebaseMessagingService またはプッシュごとのハンドラー。これにより、すべてのリスナーがクリアされ、セッションの状態が失われます。

行うこと:

初期化 VoiceClient 一度 Application.onCreate() あるいはシングルトンとして。アプリのライフサイクル全体を通じて再利用してください。

制作チェックリスト

アプリをリリースする前に、以下の各項目をVerifyしてください:

  • [ ] google-services.json ~に含まれる app/ かつ、バージョン管理の対象外となっている
  • [ ] androidx.core:core-telecom 依存関係が追加されました
  • [ ] AndroidManifest.xml で宣言されている必要な権限: RECORD_AUDIO, MANAGE_OWN_CALLS, FOREGROUND_SERVICE, FOREGROUND_SERVICE_PHONE_CALL, FOREGROUND_SERVICE_MICROPHONE, POST_NOTIFICATIONS, USE_FULL_SCREEN_INTENT
  • [ ] RECORD_AUDIO 呼び出しを試みる前に、実行時権限が要求され、付与された
  • [ ] PushNotificationService, CallServiceそして CallActionReceiver マニフェストで宣言済み(手動によるマイクのフォアグラウンドサービスはなし)
  • [ ] VoiceClient 初回セッションの前に、すべてのリスナーを設定した状態で一度だけ初期化される(Application.onCreate またはシングルトン)
  • [ ] 以前にセッションの復元を待機していた processPushCallInvite — Androidではチェーン処理は安全です
  • [ ] ログイン時にJWT(およびリフレッシュトークン)がローカルに保存されるため、復元時にバックエンドへのリアルタイム呼び出しは必要ないはずである
  • [ ] registerDevicePushToken その後、ユーザーによるログインが要求された createSession 成功する。前回の登録以降にFCMトークンが変更されていない限り、セッションの復元はスキップされる。
  • [ ] unregisterDevicePushToken 明示的なユーザーログアウトが呼び出された
  • [ ] FCMトークンのローテーションは以下で処理されます onNewToken — セッションが有効な場合は保存し、再登録する
  • [ ] addCall 起動済みのプロセス内でホストされている Service (アプリのシングルトンではない)
  • [ ] CallStyle 通話全体に対して通知が表示され、着信時には全画面表示になるよう設定されています
  • [ ] DisconnectCause ~に固定された LOCAL/REMOTE/MISSED/REJECTED 以前 disconnect
  • [ ] 通話終了、キャンセル、メディア切断のいずれの場合でも、通知がキャンセルされ、テレコム通話が切断されます。
  • [ ] セッションのエラー処理により、セッションは自動的に復元されます。 TokenExpired 保存済みのJWTをスキップする
  • [ ] 着信の前にアプリを完全に終了させた状態で、実機にてテストを実施しました
  • [ ] 少なくとも1台のSamsung製またはその他のOEM製デバイス(Pixelやエミュレーターだけでなく)でテスト済み

さらに読む