Notificaciones push en Android
Visión general
Las notificaciones push de VoIP en Android requieren que dos integraciones del sistema funcionen conjuntamente:
- Firebase Cloud Messaging (FCM) — envía mensajes de datos de alta prioridad para reactivar la aplicación, incluso cuando se ha cerrado
- Jetpack Core-Telecom (
CallsManager) — registra tu llamada en la pila de telecomunicaciones de Android para que se gestione de forma nativa: interfaz de usuario de llamadas del sistema, enrutamiento de audio y prioridad de ejecución en primer plano
Responsabilidades de los componentes
| Componente | Papel |
|---|---|
FirebaseMessagingService |
Recibe mensajes FCM y los transfiere a tu gestor de llamadas |
VoiceClient |
SDK de Vonage: gestión de sesiones, señalización de llamadas, medios |
CallsManager |
Jetpack Core-Telecom: registra la llamada en el sistema operativo a través de addCall |
CallControlScope |
El asa addCall respuestas: control (responder / poner en espera / desconectar / redirigir el audio) y observar la llamada del sistema operativo |
Iniciado Service |
Es propietario de la CallStyle notificación, gestiona el registro y mantiene la llamada activa cuando se minimiza o se desliza el dedo por la pantalla |
Esta guía utiliza CallsManager (el androidx.core.telecom (biblioteca Jetpack), la API recomendada por Google para llamadas VoIP y lo que la aplicación de ejemplo de referencia usos. El legado android.telecom.ConnectionService sigue funcionando — véase Legado: ConnectionService para saber por qué CallsManager es lo más recomendable.
Diferencia clave con respecto a iOS
A diferencia de PushKit de iOS, FCM de Android tiene sin plazo límite estricto para mostrar la interfaz de usuario de llamadas. Puedes realizar tareas asíncronas (restauración de la sesión, actualización del token) de forma segura antes de mostrar la llamada entrante. Esto elimina las condiciones de carrera que afectan a las implementaciones de iOS.
Configuración del proyecto
Dependencias de 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
Descárgalo desde la consola de Firebase y guárdalo en app/google-services.json. Añádelo a .gitignore — contiene las credenciales de tu proyecto de Firebase.
Entradas del manifiesto
<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>
Permisos de tiempo de ejecución
Solicítalas en tiempo de ejecución antes de intentar realizar cualquier llamada:
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 y CALL_PHONE Son permisos normales: se conceden automáticamente durante la instalación y no requieren que aparezcan cuadros de diálogo en tiempo de ejecución.
Advertencia: RECORD_AUDIO Debe concederse antes de cualquier intento de llamada. Si se deniega, el SDK establecerá la conexión, pero la persona al otro lado de la línea no oirá nada.
Inicialización del SDK
Inicializar VoiceClient al menos una vez en tu Application.onCreate() o un objeto único. Nunca lo vuelvas a crear, ya que al hacerlo se borran todos los oyentes y se pierde el estado de la sesión.
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
}
}
Integración de FCM
FirebaseMessagingService
Mantén el servicio ligero: delega toda la lógica a tu clase gestora.
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") }
}
}
}
Gestión de tokens
FCM puede rotar los tokens en cualquier momento. onNewToken es el solo Notificación fiable. Cuando se activa:
- Guarda el nuevo token de forma local
- Si ya hay una sesión activa, llama a
registerDevicePushTokende inmediato
Un token caducado significa que Vonage no puede conectarse al dispositivo; todas las notificaciones push futuras no se entregarán sin previo aviso.
Registro del token en 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;
});
Registra el token FCM en client.registerDevicePushToken(token) una vez creada la sesión. El servidor genera un identificador único deviceId para cada registro, así que evita llamar a esta función cuando el token no haya cambiado; de lo contrario, se acumularán registros duplicados, lo que provocará que se envíen varias actualizaciones por cada llamada.
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()
}
}
Mensajes de alta prioridad y modo Doze El servidor de Vonage envía mensajes FCM a alta prioridad. Los mensajes de alta prioridad eluden el modo Doze de Android y activan la aplicación de inmediato. No es necesario que configures esto en tu aplicación, ya que se trata de un ajuste del lado del servidor en la plataforma de Vonage.
Gestión de sesiones
Una sesión de Vonage (client.createSession(jwt)) debe estar activa para que el SDK pueda procesar invitaciones de llamada o realizar acciones relacionadas con las llamadas. Cuando llega una notificación push y la aplicación se ha cerrado o la sesión ha caducado, primero debe llevarse a cabo la restauración.
A diferencia de iOS, el encadenamiento es seguro en Android
FCM de Android no tiene un plazo límite, como el de PushKit, para mostrar la interfaz de usuario del sistema. Puedes esperar tranquilamente a que se restablezca la sesión antes de llamar a client.processPushCallInvite. De este modo se elimina la condición de carrera en la que el usuario responde antes de que la sesión esté lista.
Procedimiento recomendado: Restablece primero la sesión. Una vez confirmada su actividad, llama a processPushCallInvite. Se garantiza que la sesión existe cuando answer() / reject() se denominan.
Evita: procesar la notificación de inmediato y ejecutar la restauración de la sesión al mismo tiempo. El usuario podría responder antes de que la sesión esté lista, lo que provocaría client.answer() fallar sin dar señales.
Creación de sesiones
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) }
}
}
Cadena de prioridades de restauración de sesiones
| Prioridad | Condición | Acción | Latencia típica |
|---|---|---|---|
| 1.º | Hay una sesión activa (sessionId != null) |
Vuelve inmediatamente | 0 ms |
| 2.º | JWT almacenado disponible (authToken) |
client.createSession(storedJWT) |
100-400 ms |
| 3.º | El JWT ha caducado, hay un token de actualización disponible | Actualizar punto final -> nuevo JWT -> createSession |
300-1000 ms |
| 4.º | Sin credenciales | Devolución null — No se puede procesar la actualización |
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
}
Mantener el JWT en el momento del inicio de sesión. Si createSession requiere una llamada al backend en tiempo real para obtener un JWT cada vez que llega una notificación push, lo que supone añadir un salto de red adicional antes de que la sesión esté lista. En conexiones lentas, esto agrava la latencia de restablecimiento y reduce la fiabilidad de la respuesta a la llamada.
Cómo evitar la acumulación de tokens al volver a iniciar sesión
Al iniciar sesión por primera vez, hay que dar de baja siempre el dispositivo almacenado anteriormente antes de crear una nueva sesión. Si se omite este paso, Vonage acumula varios registros de dispositivos, lo que da lugar a notificaciones push duplicadas para la misma llamada.
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() }
}
}
}
Procesamiento de cargas útiles de Push
La ruta completa desde la entrega de FCM hasta la interfaz de usuario de llamadas entrantes del sistema:
Aplicación
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)
}
}
Gestión de la invitación a la llamada
Cuando se activa el SDK setCallInviteListener, guarda la llamada como estado de la aplicación e inicia el CallService — que es lo que registra la llamada en Telecom (véase Integración de telecomunicaciones (por qué tiene que ser el servicio el que lo haga).
// 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
}
Integración de telecomunicaciones con CallsManager
El registro de la llamada en el sistema operativo se realiza a través de Core-Telecom's CallsManager. Registra tu aplicación una vez y, a continuación, llama a addCall por llamada: proporciona a Telecom una descripción de la llamada (CallAttributesCompat) y se suspende durante todo el tiempo de vida de la llamada, lo que te ofrece un CallControlScope para controlarlo (responder / poner en espera / desconectar / redirigir el audio) y supervisarlo (estado de silencio, terminales de audio).
La forma condensada (véase CallManager.kt (para ver el código completo):
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) } }
}
}
Alojar el registro en un servicio ya en marcha
Llame a addCall de un servicios iniciados ámbito, no un ámbito de aplicación única. El servicio iniciado otorga al proceso la autorización para que Telecom mantener la llamada; una llamada añadida desde un proceso en segundo plano que se ha iniciado en frío y que carece de ancla de servicio se añade y, a continuación, se destruye inmediatamente (CallException.ERROR_CALL_IS_NOT_BEING_TRACKED), tras lo cual falla cada operación de control. Por eso se inicia el gestor de invitaciones CallService, y el servicio se encarga del registro. Véase CallService.kt.
Sujeta la causa de la desconexión
De Core-Telecom disconnect acepta solo LOCAL, REMOTE, MISSEDy REJECTED — cualquier otro DisconnectCause derriba y destroza la sesión. Mapa en el límite:
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)) }
}
Una única fuente de verdad: ActiveCall
En lugar de vincular el estado de la aplicación a un Connection subclase, mantener un inmutable ActiveCall que siguen la interfaz de usuario y las notificaciones; tanto el SDK como las llamadas de retorno de Telecom lo actualizan. El CallManager El envoltorio es una integración con el sistema operativo del tipo «fire-and-forget» y nunca devuelve el estado de la llamada. Véase ActiveCall.kt.
La ejecución en primer plano y la notificación «CallStyle»
Tú sí no Crear un servicio de primer plano para el micrófono. Mientras haya una llamada registrada con CallsManager y a CallStyle Una vez publicada la notificación, Core-Telecom considera la llamada como una phoneCall|microphone servicio en primer plano para ti: eso es lo que garantiza el audio en directo del micrófono y la prioridad de ejecución en primer plano, incluso si se ha cerrado o se ha pasado a segundo plano desde el inicio. Un servicio manual en primer plano es redundante y puede provocar conflictos.
El CallStyle Por lo tanto, la notificación es obligatoria, no meramente formal. Hay dos aspectos que hay que tener en cuenta (véase CallNotifier.kt):
- Intención de pantalla completa para las llamadas entrantes Activa la función «Wake on Call» al pulsar la tecla de bloqueo cuando se recibe una llamada en un dispositivo bloqueado.
- La respuesta debe ser una actividad.
PendingIntent, no una emisión que luego llame astartActivity— esta última es una «plataforma de notificaciones», prohibida desde Android 12. Las opciones «Rechazar» y «Colgar» son transmisiones a unBroadcastReceiver.
Fija el cronómetro de la notificación activa a la hora real de conexión y vuelve a publicar la notificación solo cuando cambien sus datos de entrada, de modo que al activar o desactivar el modo «silencio» o «en espera» no se reinicie el temporizador de duración de la llamada.
Escuchas del SDK
Configura todos los oyentes una sola vez, inmediatamente después de la inicialización VoiceClient, antes de crear cualquier sesión. Asigna cada evento del SDK a ActiveCall estado, y canalizar todos los eventos de los terminales a través de una única ruta de limpieza, de modo que la llamada de Telecom siempre se desconecte y la notificación siempre se cancele. (Conjunto completo en VoiceClientManager.kt.)
Activo / Volviendo a conectarse
// 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() conjuntos ActiveCall.state = ACTIVE (fijando el tiempo de conexión para el cronómetro de notificaciones) y realiza llamadas telecom.setActive().
Eventos terminales
Asigna el motivo del SDK a un DisconnectCause, y luego ejecuta uno cleanUp ruta (que llama a telecom.disconnect(cause), muestra brevemente el estado de desconexión y, a continuación, se borra 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)
}
Porque cleanUp carreras telecom.disconnectEl DisconnectCause Los códigos anteriores se limitan a los cuatro que acepta Core-Telecom (véase Sujeta con una abrazadera la causa de la desconexión).
Llamadas salientes
Más sencillo que con ConnectionService — no placeCall / onCreateOutgoingConnection dance y sin temporizador de reserva OEM. Inicia la llamada al SDK, regístrala como llamada activa e inicia el servicio para registrarla en Telecom como 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
}
}
Gestión de errores
El SDK se activa setSessionErrorListener cuando se interrumpe la conexión WebSocket durante una sesión activa. Intenta restablecerla automáticamente utilizando el motivo del error para elegir la estrategia adecuada.
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()
}
}
Situaciones en las que suelen producirse errores
| Error | Causa | Recuperación |
|---|---|---|
TokenExpired |
Se ha superado el TTL del JWT | Utiliza el token de actualización para obtener un nuevo JWT |
TransportClosed |
Interrupción de la red | Vuelve a intentarlo con el JWT guardado |
PingTimeout |
No se puede acceder al servidor | Reintentar con retraso exponencial |
registerDevicePushToken falla |
Sesión inactiva | Asegúrese createSession que se haya completado antes de registrarse |
processPushCallInvite silencioso |
No hay sesión | Restablece siempre la sesión primero |
answer() falla |
La sesión ha caducado entre el envío y la respuesta | Restablece de nuevo la sesión y, a continuación, responde |
Legado: ConnectionService
Antes de Jetpack Core-Telecom, las llamadas VoIP autogestionadas se realizaban mediante android.telecom.ConnectionService directamente: te has registrado en un PhoneAccount, con subclases ConnectionService, devuelto Connection objetos de onCreateIncomingConnection / onCreateOutgoingConnection, y dirigiste tu propio microphone servicio en primer plano. Sigue funcionando y es no está en desuso, por lo que no es necesario migrar con urgencia las integraciones existentes.
Para nuevo integraciones, CallsManager se prefiere:
CallsManager (Core-Telecom) |
ConnectionService (antiguo) |
|
|---|---|---|
| Android 14: tipo FGS + permiso | Ya nos hemos encargado de ello | Tú mismo debes dar de alta y poner en marcha el FGS |
| Servicio en primer plano | Facilitado por la biblioteca | Tú lo redactas y lo gestionas |
| Multipropósito (Wear / Auto / Bluetooth) | Integrado en | Manual |
| Forma de la API | Uno addCall + a CallControlScope |
PhoneAccount + Connection subclase + devoluciones de llamada del sistema |
| Peculiaridades de las llamadas salientes en los dispositivos OEM | A cargo de la biblioteca | Temporizadores manuales de reserva (Samsung/Xiaomi) |
| API mínima | 26 | 23 |
El punto clave de Android 14 es el controlador práctico: a partir de la API 34, el sistema operativo comprueba el tipo de servicio en primer plano y el permiso correspondiente (por ejemplo, RECORD_AUDIO) en el momento en que se inicia el servicio. CallsManager esto te sirve; un cigarrillo liado a mano ConnectionService La integración tiene que funcionar a la perfección; de lo contrario, las llamadas fallarán en los dispositivos modernos.
Véase el Referencia de ConnectionService si mantienes una integración ya existente.
Errores y antipatrones
1. Procesamiento de notificaciones push antes de la restauración de la sesión
No hagas lo siguiente:
Llame a processPushCallInvite inmediatamente al recibir la notificación push, sin comprobar si existe una sesión. Es posible que el listener de la invitación a la llamada no se active, lo que provocaría que se perdiera la llamada sin que apareciera ninguna interfaz de usuario.
Qué hacer:
Restablecimiento de la sesión en cadena antes del procesamiento: restoreSessionIfNeeded { processPushCallInvite(data) }.
2. Obtener el JWT del backend cada vez que se realiza un «push»
No hagas lo siguiente:
Requerir una llamada al backend en tiempo real antes de createSession en cada envío. Esto duplica la latencia en los envíos con arranque en frío.
Qué hacer:
Almacenar el JWT en el momento del inicio de sesión, de modo que la recuperación solo requiera uno createSession llamar.
3. No registrar el token push tras la creación de la sesión
No hagas lo siguiente:
Registra el token de notificaciones una sola vez al iniciar la aplicación y no vuelvas a hacerlo. El token debe registrarse en Vonage tras cada inicio de sesión iniciado por el usuario (ya que el registro anterior del dispositivo se canceló al cerrar sesión).
Qué hacer:
Llame a registerDevicePushToken dentro del createSession Llamada de retorno de éxito para los inicios de sesión iniciados por el usuario. En el caso de las restauraciones de sesión, omite el registro a menos que haya cambiado el token de FCM (véase el error n.º 5).
4. No se cierra la sesión al salir
No hagas lo siguiente:
Saltar unregisterDevicePushToken al cerrar sesión. Las llamadas del usuario anterior seguirán llegando a este dispositivo.
Qué hacer:
Llama siempre unregisterDevicePushToken antes deleteSession al cerrar sesión.
5. Registro de tokens de dispositivo duplicados
No hagas lo siguiente:
Llame a registerDevicePushToken en cada createSession independientemente de si el token ha cambiado o no. El servidor de Vonage emite un nuevo deviceId en cada llamada de registro: las llamadas repetidas con el mismo token acumulan registros duplicados, lo que provoca múltiples entregas push por llamada.
Qué hacer:
Realiza un seguimiento del último token registrado correctamente (por ejemplo, coreContext.lastRegisteredPushToken). Llama solo a registerDevicePushToken cuándo:
- Se trata de un nuevo inicio de sesión iniciado por el usuario (el dispositivo anterior no estaba registrado).
- El token FCM ha cambiado (
onNewTokendespedido) coreContext.deviceIdes nulo (no existe ningún registro previo)
Omitir el registro durante la restauración de la sesión si el token coincide con el registrado anteriormente.
6. El token FCM caducado no se ha vuelto a registrar
No hagas lo siguiente:
Ignorar onNewToken. FCM cambia los tokens sin previo aviso; todas las transmisiones futuras dejarán de poder entregarse.
Qué hacer:
En onNewToken, guarda el nuevo token y llama a registerDevicePushToken si hay una sesión activa.
7. Registro de la llamada fuera de un servicio ya iniciado
No hagas lo siguiente:
Llame a callsManager.addCall desde un ámbito de aplicación tipo «singleton» o de una corrutina de uso único. En un inicio en segundo plano en frío, Telecom añade la llamada y, a continuación, la elimina inmediatamente (ERROR_CALL_IS_NOT_BEING_TRACKED).
Qué hacer:
Iniciar un Service y ejecuta addCall queda fuera de su ámbito de aplicación, por lo que el proceso permite a Telecom retener la llamada.
8. No publicar una notificación de CallStyle
No hagas lo siguiente:
Registra la llamada, pero omite el CallStyle notificación. Core-Telecom solo concede prioridad al primer plano o al micrófono mientras se muestre dicha notificación; sin ella, la llamada no dispone de un acceso fiable al micrófono en segundo plano.
Qué hacer:
Publicar un CallStyle Notificación para toda la llamada, con una pantalla completa para las llamadas entrantes.
9. Cómo añadir tu propio servicio de primer plano de micrófono
No hagas lo siguiente:
Declarar y poner en marcha un manual foregroundServiceType="microphone" servicio. Con Core-Telecom resulta redundante y puede generar conflictos.
Qué hacer:
Deja que Core-Telecom se encargue del servicio principal; tu propio servicio es uno normal y corriente iniciado servicio para la notificación y la integración del proceso.
10. Pasarse un DisconnectCause no compatible
No hagas lo siguiente:
Reenviar cualquier DisconnectCause códigos a Core-Telecom's disconnect — cualquier cosa que esté fuera LOCAL/REMOTE/MISSED/REJECTED provoca un error y cierra la sesión.
Qué hacer:
Detén la causa en el límite antes de llamar disconnect.
11. Recreación de VoiceClient en cada envío
No hagas lo siguiente:
Instanciar VoiceClient en su FirebaseMessagingService o un controlador por «push». Esto borra todos los oyentes y se pierde el estado de la sesión.
Qué hacer:
Inicializar VoiceClient una vez en Application.onCreate() o un singleton. Reutilízalo a lo largo de todo el ciclo de vida de la aplicación.
Lista de comprobación de la producción
Antes de publicar tu aplicación, Verify cada uno de los siguientes puntos:
- [ ]
google-services.jsonpresente enapp/y excluidos del control de versiones - [ ]
androidx.core:core-telecomSe ha añadido una dependencia - [ ] Permisos necesarios declarados en AndroidManifest.xml:
RECORD_AUDIO,MANAGE_OWN_CALLS,FOREGROUND_SERVICE,FOREGROUND_SERVICE_PHONE_CALL,FOREGROUND_SERVICE_MICROPHONE,POST_NOTIFICATIONS,USE_FULL_SCREEN_INTENT - [ ]
RECORD_AUDIOSe solicita y concede un permiso en tiempo de ejecución antes de cualquier intento de llamada - [ ]
PushNotificationService,CallServiceyCallActionReceiverdeclarado en el manifiesto (sin servicio manual de primer plano del micrófono) - [ ]
VoiceClientSe inicializa una sola vez (en Applications.onCreate o como singleton) con todos los oyentes configurados antes de la primera sesión - [ ] Se esperaba la restauración de la sesión antes de
processPushCallInvite— El encadenamiento es seguro en Android - [ ] El JWT (y el token de actualización) se almacenan localmente en el momento del inicio de sesión; la restauración no debería requerir una llamada en tiempo real al servidor.
- [ ]
registerDevicePushTokense invocó el inicio de sesión iniciado por el usuario trascreateSessionse ejecuta correctamente; se omite la restauración de la sesión a menos que el token de FCM haya cambiado desde el último registro - [ ]
unregisterDevicePushTokense ejecuta al cerrar sesión de forma explícita - [ ] La rotación de tokens FCM se gestiona en
onNewToken— guardar y volver a registrar si la sesión está activa - [ ]
addCallalojado dentro de un proceso iniciadoService(no es un singleton de aplicación) - [ ]
CallStyleNotificación mostrada durante toda la llamada, con una ventana a pantalla completa para las llamadas entrantes - [ ]
DisconnectCausesujeto aLOCAL/REMOTE/MISSED/REJECTEDantesdisconnect - [ ] Cualquier ruta de colgado, cancelación o desconexión multimedia cancela la notificación y desconecta la llamada de Telecom.
- [ ] La gestión de errores de sesión restaura la sesión automáticamente;
TokenExpiredomite los JWT almacenados - [ ] Se ha probado en un dispositivo físico tras cerrar completamente la aplicación antes de recibir la llamada.
- [ ] Probado en al menos un dispositivo Samsung u otro fabricante (no solo en Pixel o en un emulador)
Lecturas complementarias
- Aplicación de ejemplo de referencia — android-voice (Core-Telecom)
- Documentación de Firebase Cloud Messaging
- Guía de VoIP del marco de telecomunicaciones de Android
- Documentación de CallsManager (Jetpack Core-Telecom)
- Documentación de ConnectionService (versión antigua)
- Configuración del Vonage Client SDK en Android