Notifications push sur Android
Vue d'ensemble
Les notifications push VoIP sur Android nécessitent la collaboration de deux intégrations système :
- 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
- 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 :
- Enregistrez le nouveau jeton localement
- Si une session est déjà active, appelez
registerDevicePushTokenimmé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 ensuitestartActivity— ce dernier est un « trampoline » de notifications, interdit depuis Android 12. Les actions « Refuser » et « Raccrocher » sont des diffusions vers unBroadcastReceiver.
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é (
onNewTokenlicencié) coreContext.deviceIdest 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.jsonprésent dansapp/et exclues du contrôle de version - [ ]
androidx.core:core-telecomdé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_AUDIOautorisation d'exécution demandée et accordée avant toute tentative d'appel - [ ]
PushNotificationService,CallServiceetCallActionReceiverdéclaré dans le manifeste (pas de service manuel de mise au premier plan du microphone) - [ ]
VoiceClientinitialisé 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
- [ ]
registerDevicePushTokenappelée lors de la connexion initiée par l'utilisateur aprèscreateSessionréussit ; ignoré lors de la restauration de session, sauf si le jeton FCM a changé depuis le dernier enregistrement - [ ]
unregisterDevicePushTokenappelé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 - [ ]
addCallhébergé à l'intérieur d'un processus lancéService(il ne s'agit pas d'un singleton d'application) - [ ]
CallStylenotification affichée pendant toute la durée de l'appel, avec un mode plein écran prévu pour les appels entrants - [ ]
DisconnectCausefixé àLOCAL/REMOTE/MISSED/REJECTEDavantdisconnect - [ ] 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 ;
TokenExpiredignore 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
- Exemple d'application de référence — android-voice (Core-Telecom)
- Documentation sur Firebase Cloud Messaging
- Guide sur la VoIP dans le cadre du framework Android Telecom
- Documentation de CallsManager (Jetpack Core-Telecom)
- Documentation de ConnectionService (ancienne version)
- Configuration du Vonage Client SDK pour Android