Notifications push sur Android

Vue d'ensemble

Les notifications push VoIP sur Android nécessitent la collaboration de deux intégrations système :

  1. Firebase Cloud Messaging (FCM) — transmet des messages de données hautement prioritaires pour réactiver l'application, même lorsqu'elle a été fermée
  2. Jetpack Core-Telecom (CallsManager) — enregistre votre appel auprès de la pile télécom Android afin qu'il soit traité de manière native : interface utilisateur des appels système, acheminement audio et priorité d'exécution en avant-plan

Responsabilités liées aux composants

Composant Rôle
FirebaseMessagingService Reçoit les messages FCM et les transmet à votre gestionnaire d'appels
VoiceClient SDK Vonage — gestion des sessions, signalisation des appels, médias
CallsManager Jetpack Core-Telecom — enregistre l'appel auprès du système d'exploitation via addCall
CallControlScope La poignée addCall retours — commande (réponse / mise en attente / coupure / acheminement audio) et observation de l’appel du système d’exploitation
Lancé Service Est propriétaire de la CallStyle notification, gère l'enregistrement et maintient la communication active lorsque l'application passe en arrière-plan ou qu'on fait glisser l'écran

Ce guide utilise CallsManager (le androidx.core.telecom (bibliothèque Jetpack), l'API recommandée par Google pour les appels VoIP et ce que la application exemple de référence utilisations. L'héritage android.telecom.ConnectionService ça fonctionne toujours — voir Ancien : ConnectionService pour savoir pourquoi CallsManager est préférable.

Principale différence par rapport à iOS

Contrairement à PushKit sur iOS, FCM sur Android dispose de pas de date limite stricte pour afficher l'interface utilisateur d'appel. Vous pouvez effectuer en toute sécurité des tâches asynchrones (restauration de session, actualisation du jeton) avant d'afficher l'appel entrant. Cela élimine les conditions de concurrence qui posent problème dans les implémentations iOS.

Configuration du projet

Dépendances 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

Téléchargez-le depuis la console Firebase et placez-le dans app/google-services.json. Ajoutez-le à .gitignore — il contient les identifiants de votre projet Firebase.

Entrées du manifeste

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

Permissions d'exécution

Demandez-les au moment de l'exécution avant d'effectuer tout appel :

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 et CALL_PHONE Il s'agit d'autorisations standard : elles sont accordées automatiquement lors de l'installation et ne nécessitent pas de boîtes de dialogue lors de l'exécution.

Avertissement : RECORD_AUDIO doit être accordée avant toute tentative d'appel. En cas de refus, le SDK établira la connexion, mais votre interlocuteur n'entendra rien.

Initialisation du SDK

Initialiser VoiceClient au moins une fois dans votre Application.onCreate() ou un singleton. Ne le recréez jamais : cela effacerait tous les écouteurs et entraînerait la perte de l'état de la session.

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

Intégration FCM

FirebaseMessagingService

Veillez à ce que le service reste léger : déléguez toute la logique à votre classe gestionnaire.

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

Gestion des jetons

FCM peut renouveler les jetons à tout moment. onNewToken est le seulement notification fiable. Quand elle se déclenche :

  1. Enregistrez le nouveau jeton localement
  2. Si une session est déjà active, appelez registerDevicePushToken immédiatement

Un jeton périmé signifie que Vonage ne peut pas accéder à l'appareil : toutes les notifications push futures ne pourront pas être transmises, sans qu'aucun message d'erreur ne s'affiche.

Enregistrement du jeton auprès de 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;
 });

Enregistrez le jeton FCM auprès de client.registerDevicePushToken(token) une fois la session créée. Le serveur génère un identifiant unique deviceId pour chaque enregistrement ; évitez donc d'appeler cette méthode lorsque le jeton n'a pas changé — sinon, les enregistrements en double s'accumulent, ce qui entraîne plusieurs envois par appel.

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

Messages hautement prioritaires et mode Doze Le système d'arrière-plan de Vonage envoie des messages FCM à priorité absolue. Les messages hautement prioritaires contournent le mode Doze d'Android et réactivent immédiatement l'application. Vous n'avez pas besoin de configurer cette option dans votre application : il s'agit d'un paramètre côté serveur de la plateforme Vonage.

Gestion des sessions

Une session Vonage (client.createSession(jwt)) doit être active pour que le SDK puisse traiter les invitations d'appel ou effectuer des actions liées aux appels. Lorsqu'une notification push arrive et que l'application est fermée ou que la session a expiré, la restauration doit d'abord avoir lieu.

Contrairement à iOS, le chaînage est sans danger sur Android

FCM sur Android n'impose pas de délai, comme c'est le cas avec PushKit, pour l'affichage de l'interface utilisateur système. Vous pouvez attendre en toute sécurité la restauration de la session avant d'appeler client.processPushCallInvite. Cela permet d'éliminer la condition de concurrence dans laquelle l'utilisateur répond avant que la session ne soit prête.

Procédure recommandée : commencez par restaurer la session. Une fois son activité confirmée, appelez processPushCallInvite. L'existence de la session est garantie lorsque answer() / reject() sont appelées.

À éviter : traiter la notification immédiatement et lancer la restauration de la session en parallèle. L'utilisateur pourrait répondre avant que la session ne soit prête, ce qui entraînerait client.answer() échouer en silence.

Création de la session

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

Chaîne de priorité pour la restauration de session

Priorité État Action Latence typique
1er Une session est en cours (sessionId != null) Revenez immédiatement 0 ms
2e JWT enregistré disponible (authToken) client.createSession(storedJWT) 100 à 400 ms
3e Le JWT a expiré, un jeton d'actualisation est disponible Actualiser le point de terminaison -> nouveau JWT -> createSession 300 à 1 000 ms
4e Aucun identifiant Retour null — Le push ne peut pas être traité 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
}

Conserver le JWT lors de la connexion. Si createSession nécessite un appel en direct vers le backend pour obtenir un JWT à chaque fois qu’une notification push arrive ; vous ajoutez ainsi un saut réseau supplémentaire avant que la session ne soit prête. Sur les connexions lentes, cela aggrave la latence de rétablissement et nuit à la fiabilité de la réponse à l’appel.

Empêcher l'accumulation de jetons lors d'une nouvelle connexion

Lors d'une nouvelle connexion initiée par l'utilisateur, veillez à toujours désenregistrer l'appareil précédemment enregistré avant de créer une nouvelle session. Si cette étape n'est pas respectée, Vonage accumule plusieurs enregistrements d'appareils, ce qui entraîne la réception de notifications push en double pour un même appel.

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

Traitement des charges utiles « push »

Le parcours complet depuis la transmission par FCM jusqu’à l’interface utilisateur des appels entrants du système :

Mise en œuvre

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

Gestion de l'invitation à l'appel

Lorsque le SDK se déclenche setCallInviteListener, enregistrez l'appel en tant qu'état de votre application et lancez le CallService — c'est ce qui permet d'enregistrer l'appel auprès de Telecom (voir Intégration des télécommunications (pour expliquer pourquoi c'est au service de s'en charger).

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

Intégration des services de télécommunications avec CallsManager

L'enregistrement de l'appel auprès du système d'exploitation s'effectue via Core-Telecom's CallsManager. Vous enregistrez votre application une seule fois, puis vous appelez addCall par appel : elle transmet à Telecom une description de l'appel (CallAttributesCompat) et suspend pendant toute la durée de l'appel, ce qui vous offre un CallControlScope pour le piloter (répondre / mettre en attente / raccrocher / acheminer le son) et le surveiller (état de sourdine, terminaux audio).

La forme condensée (voir CallManager.kt (pour le wrapper complet) :

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

Héberger le service d'enregistrement dans un service déjà lancé

Appeler addCall d'un a lancé ses services portée, et non une portée « singleton » d'application. Le service lancé confère au processus le statut nécessaire à Telecom pour conserver l'appel ; un appel ajouté à partir d'un processus d'arrière-plan lancé à froid et ne disposant d'aucun point d'ancrage de service est ajouté puis immédiatement détruit (CallException.ERROR_CALL_IS_NOT_BEING_TRACKED), après quoi toutes les opérations de contrôle échouent. C'est pourquoi le gestionnaire d'invitations se lance CallService, et le service se charge de l'enregistrement. Voir CallService.kt.

Fixer la cause de la coupure

Core-Telecom's disconnect accepte seulement LOCAL, REMOTE, MISSEDet REJECTED — tout autre DisconnectCause provoque une erreur et interrompt la session. Carte à la limite :

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

Une source de vérité : ActiveCall

Au lieu de lier l'état de l'application à un Connection sous-classe, conserver un élément immuable ActiveCall que l'interface utilisateur et les notifications respectent ; le SDK et les callbacks Telecom le mettent tous deux à jour. Le CallManager Le wrapper s'intègre au système d'exploitation selon le principe « fire-and-forget » et ne renvoie jamais d'informations sur l'état de l'appel. Voir ActiveCall.kt.

Exécution au premier plan et notification « CallStyle »

C'est toi qui... pas Créer un service de premier plan pour le microphone. Lorsqu'un appel est enregistré auprès de CallsManager et a CallStyle Une fois la notification publiée, Core-Telecom traite l'appel comme un phoneCall|microphone un service au premier plan dédié à votre application — c’est ce qui garantit le son en direct du micro et la priorité d’exécution au premier plan, même si le service a été arrêté ou mis en arrière-plan. Un service au premier plan configuré manuellement est superflu et peut entraîner des conflits.

Les CallStyle La notification est donc obligatoire, et non pas une simple formalité. Il y a deux points à respecter (voir CallNotifier.kt) :

  • Intention de passer en plein écran pour les appels entrants active l'application « Wakes Your Call » par-dessus le masquage du clavier lorsqu'une notification est reçue sur un appareil verrouillé.
  • La réponse doit être une activité. PendingIntent, et non une diffusion qui appelle ensuite startActivity — ce dernier est un « trampoline » de notifications, interdit depuis Android 12. Les actions « Refuser » et « Raccrocher » sont des diffusions vers un BroadcastReceiver.

Synchronisez le chronomètre de la notification active avec l'heure réelle de connexion et ne réaffichez la notification que lorsque ses données changent, afin que les actions de mise en sourdine ou de mise en attente ne réinitialisent pas le chronomètre de durée de l'appel.

Écouteurs SDK

Définir tous les écouteurs en une seule fois, immédiatement après l'initialisation VoiceClient, avant de créer une session. Associez chaque événement du SDK à ActiveCall définir l'état et acheminer chaque événement de terminaison via un chemin de nettoyage unique, de sorte que l'appel Telecom soit toujours déconnecté et que la notification soit toujours annulée. (Ensemble complet dans VoiceClientManager.kt.)

Actif / Se reconnecter

// 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() ensembles ActiveCall.state = ACTIVE (fixation de l'heure de connexion pour le chronomètre de notification) et les appels telecom.setActive().

Événements terminaux

Associer la raison du SDK à un DisconnectCause, puis exécutez-en un cleanUp chemin (qui appelle telecom.disconnect(cause), affiche brièvement l'état « déconnecté », puis s'efface 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)
}

Parce que cleanUp séries telecom.disconnect, le DisconnectCause Les codes ci-dessus sont limités aux quatre codes acceptés par Core-Telecom (voir Fixez la cause de la coupure).

Appels sortants

Plus simple qu'avec ConnectionService — non placeCall / onCreateOutgoingConnection dance et pas de temporisateur de secours OEM. Lancez l'appel SDK, enregistrez-le en tant qu'appel actif, puis démarrez le service pour l'enregistrer auprès de Telecom en tant que 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
    }
}

Gestion des erreurs

Le SDK se déclenche setSessionErrorListener lorsqu'une connexion WebSocket est interrompue au cours d'une session active. Tenter automatiquement de rétablir la connexion en utilisant le motif de l'erreur pour choisir la stratégie appropriée.

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

Cas d'erreurs courants

Erreur Cause Rétablissement
TokenExpired Délimite de validité (TTL) du JWT dépassée Utilisez le jeton d'actualisation pour obtenir un nouveau JWT
TransportClosed Interruption du réseau Réessayer avec le JWT enregistré
PingTimeout Serveur inaccessible Réessayer avec un délai d'attente exponentiel
registerDevicePushToken échoue Session inactive Garantir createSession à effectuer avant l'inscription
processPushCallInvite silencieux Aucune session Commencez toujours par restaurer la session
answer() échoue La session a expiré entre l'envoi de la requête et la réponse Réinitialisez la session, puis répondez

Ancien : ConnectionService

Avant Jetpack Core-Telecom, les appels VoIP gérés en interne utilisaient android.telecom.ConnectionService directement : vous vous êtes inscrit à un PhoneAccount, sous-classé ConnectionService, renvoyé Connection objets provenant de onCreateIncomingConnection / onCreateOutgoingConnection, et vous avez géré votre propre microphone service en premier plan. Il fonctionne toujours et est n'est pas obsolète, les intégrations existantes n'ont donc pas besoin d'être migrées dans l'urgence.

Pour nouveau intégrations, CallsManager est préférable :

CallsManager (Core-Telecom) ConnectionService (ancienne version)
Android 14 : type FGS + autorisation Nous nous en sommes chargés pour vous C'est vous qui déclarez et lancez le FGS
Service d'avant-plan Fourni par la bibliothèque C'est vous qui le rédigez et le gérez
Multifonction (monture / auto / Bluetooth) Intégré Manuel
Forme de l'API Un addCall + a CallControlScope PhoneAccount + Connection sous-classe + rappels système
Particularités des appels sortants OEM Géré par la bibliothèque Minuteries de secours manuelles (Samsung/Xiaomi)
API minimale 26 23

Le point concernant Android 14 est d'ordre pratique : à partir de l'API 34, le système d'exploitation vérifie le type de service en avant-plan et l'autorisation correspondante (par exemple, RECORD_AUDIO) au moment où le service démarre. CallsManager cela vous convient ; un cigare roulé à la main ConnectionService L'intégration doit être parfaitement au point, sinon les appels échouent sur les appareils modernes.

Voir le Référence du service de connexion si vous gérez une intégration existante.

Pièges et anti-modèles

1. Traitement des notifications push avant la reprise de la session

À ne pas faire :

Appeler processPushCallInvite dès la réception de la notification push, sans vérifier si une session est ouverte. L'écouteur d'invitation d'appel risque de ne pas se déclencher, ce qui entraînerait un appel manqué sans interface utilisateur.

À faire :

Restauration de la session en chaîne avant le traitement : restoreSessionIfNeeded { processPushCallInvite(data) }.

2. Récupération du JWT depuis le backend à chaque push

À ne pas faire :

Exiger un appel au backend en temps réel avant createSession à chaque envoi. Cela double la latence lors des envois en démarrage à froid.

À faire :

Conserver le JWT au moment de la connexion afin que la restauration ne nécessite qu'une seule createSession appel.

3. Non-enregistrement du jeton Push après la création de la session

À ne pas faire :

Enregistrez le jeton de notification une seule fois au lancement de l'application, et plus jamais par la suite. Le jeton doit être enregistré auprès de Vonage après chaque connexion effectuée par l'utilisateur (car l'enregistrement précédent de l'appareil a été supprimé lors de la déconnexion).

À faire :

Appeler registerDevicePushToken à l'intérieur du createSession Rappel de réussite pour les connexions initiées par l'utilisateur. Pour les restaurations de session, ignorer l'enregistrement sauf si le jeton FCM a changé (voir piège n° 5).

4. Non-désinscription lors de la déconnexion

À ne pas faire :

Passer unregisterDevicePushToken lors de la déconnexion. Les appels destinés à l'ancien utilisateur continueront d'arriver sur cet appareil.

À faire :

Appelez toujours unregisterDevicePushToken avant deleteSession lors de la déconnexion.

5. Enregistrement des jetons de périphérique en double

À ne pas faire :

Appeler registerDevicePushToken sur chaque createSession que le jeton ait changé ou non. Le serveur Vonage émet un nouveau deviceId à chaque appel d'enregistrement — les appels répétés avec le même jeton entraînent l'accumulation d'enregistrements en double, ce qui provoque plusieurs livraisons push par appel.

À faire :

Suivre le dernier jeton enregistré avec succès (par exemple, coreContext.lastRegisteredPushToken). N'appelez que registerDevicePushToken Quand :

  • Il s'agit d'une nouvelle connexion effectuée par l'utilisateur (l'appareil précédent n'était pas enregistré).
  • Le jeton FCM a changé (onNewToken licencié)
  • coreContext.deviceId est nul (aucune inscription préalable n'existe)

Ignorer l'enregistrement lors de la restauration de la session si le jeton correspond à celui enregistré précédemment.

6. Jeton FCM périmé non réenregistré

À ne pas faire :

Ignorer onNewToken. FCM remplace les jetons sans préavis — tous les envois futurs deviendront non livrables.

À faire :

En onNewToken, enregistrez le nouveau jeton et appelez registerDevicePushToken si une session est active.

7. Enregistrement de l'appel en dehors d'un service déjà lancé

À ne pas faire :

Appeler callsManager.addCall à partir d'un singleton d'application ou d'une coroutine à exécution unique. Lors d'un démarrage à froid en arrière-plan, Telecom ajoute puis détruit immédiatement l'appel (ERROR_CALL_IS_NOT_BEING_TRACKED).

À faire :

Lancer un Service et exécuter addCall étant exclu de son champ d'application, la procédure permet à Telecom de conserver l'appel.

8. Ne pas publier de notification CallStyle

À ne pas faire :

Enregistrez l'appel, mais ignorez le CallStyle notification. Core-Telecom n'accorde la priorité au premier plan/au microphone que tant qu'une telle notification est affichée ; sans celle-ci, l'appel ne dispose pas d'un accès fiable au microphone en arrière-plan.

À faire :

Publier un CallStyle une notification pour l'ensemble de l'appel, avec un affichage en plein écran pour les appels entrants.

9. Ajouter votre propre service de premier plan pour microphone

À ne pas faire :

Déclarer et démarrer un manuel foregroundServiceType="microphone" service. Avec Core-Telecom, il est redondant et peut entraîner des conflits.

À faire :

Laissez Core-Telecom fournir le service de premier plan ; votre propre service est un simple a commencé service dédié à la notification et à l'ancrage des processus.

10. Transmission d'une cause de déconnexion non prise en charge

À ne pas faire :

Transmettre n'importe quoi DisconnectCause codes vers Core-Telecom’s disconnect — tout ce qui se trouve à l'extérieur LOCAL/REMOTE/MISSED/REJECTED provoque l'arrêt et la fermeture de la session.

À faire :

Contenez le problème à sa source avant d'appeler disconnect.

11. Recréer VoiceClient à chaque notification push

À ne pas faire :

Instancier VoiceClient dans votre FirebaseMessagingService ou un gestionnaire par « push ». Cela efface tous les écouteurs et entraîne la perte de l'état de la session.

À faire :

Initialiser VoiceClient une fois dans Application.onCreate() ou un singleton. Réutilisez-le tout au long du cycle de vie de l'application.

Liste de contrôle de production

Avant de publier votre application, vérifiez chacun des éléments suivants :

  • [ ] google-services.json présent dans app/ et exclues du contrôle de version
  • [ ] androidx.core:core-telecom dépendance ajoutée
  • [ ] Autorisations requises déclarées dans le fichier AndroidManifest.xml : RECORD_AUDIO, MANAGE_OWN_CALLS, FOREGROUND_SERVICE, FOREGROUND_SERVICE_PHONE_CALL, FOREGROUND_SERVICE_MICROPHONE, POST_NOTIFICATIONS, USE_FULL_SCREEN_INTENT
  • [ ] RECORD_AUDIO autorisation d'exécution demandée et accordée avant toute tentative d'appel
  • [ ] PushNotificationService, CallServiceet CallActionReceiver déclaré dans le manifeste (pas de service manuel de mise au premier plan du microphone)
  • [ ] VoiceClient initialisé une seule fois (dans Application.onCreate ou via un singleton) avec tous les écouteurs configurés avant la première session
  • [ ] Restauration de la session en attente avant processPushCallInvite — Le chaînage ne présente aucun risque sur Android
  • [ ] Le JWT (et le jeton de rafraîchissement) sont conservés localement au moment de la connexion — leur restauration ne devrait pas nécessiter d'appel en temps réel au serveur backend
  • [ ] registerDevicePushToken appelée lors de la connexion initiée par l'utilisateur après createSession réussit ; ignoré lors de la restauration de session, sauf si le jeton FCM a changé depuis le dernier enregistrement
  • [ ] unregisterDevicePushToken appelée lors de la déconnexion explicite de l'utilisateur
  • [ ] La rotation des jetons FCM est gérée dans onNewToken — enregistrer et se reconnecter si la session est active
  • [ ] addCall hébergé à l'intérieur d'un processus lancé Service (il ne s'agit pas d'un singleton d'application)
  • [ ] CallStyle notification affichée pendant toute la durée de l'appel, avec un mode plein écran prévu pour les appels entrants
  • [ ] DisconnectCause fixé à LOCAL/REMOTE/MISSED/REJECTED avant disconnect
  • [ ] Tout raccrochage, annulation ou coupure de la connexion multimédia entraîne l'annulation de la notification et la fin de l'appel téléphonique.
  • [ ] La gestion des erreurs de session rétablit automatiquement la session ; TokenExpired ignore les JWT stockés
  • [ ] Testé sur un appareil physique après avoir complètement fermé l'application avant l'appel entrant
  • [ ] Testé sur au moins un appareil Samsung ou un autre appareil d'un autre fabricant (pas uniquement sur un Pixel ou un émulateur)

Pour en savoir plus