Notificações push no Android

Visão geral

As notificações push de VoIP no Android exigem que duas integrações do sistema funcionem em conjunto:

  1. Firebase Cloud Messaging (FCM) — envia mensagens de dados de alta prioridade para ativar o aplicativo, mesmo quando ele é encerrado
  2. Jetpack Core-Telecom (CallsManager) — registra sua chamada na pilha de telecomunicações do Android para o tratamento nativo da chamada: interface de usuário de chamadas do sistema, roteamento de áudio e prioridade de execução em primeiro plano

Responsabilidades dos componentes

Componente Função
FirebaseMessagingService Recebe mensagens FCM e as encaminha ao seu gerenciador de chamadas
VoiceClient SDK da Vonage — gerenciamento de sessões, sinalização de chamadas, mídia
CallsManager Jetpack Core-Telecom — registra a chamada no sistema operacional por meio de addCall
CallControlScope A alça addCall retornos — acionar (responder / colocar em espera / desligar / encaminhar o áudio) e observar a chamada do sistema operacional
Iniciado Service É dono do CallStyle recebe a notificação, administra o registro e mantém a chamada ativa quando a tela é colocada em segundo plano ou quando o usuário desliza o dedo pela tela

Este guia utiliza CallsManager (o androidx.core.telecom (biblioteca Jetpack), a API recomendada pelo Google para chamadas VoIP e o que a aplicativo de exemplo de referência usos. O legado android.telecom.ConnectionService ainda funciona — veja Legado: ConnectionService para saber por que CallsManager é o preferido.

Principal diferença em relação ao iOS

Ao contrário do PushKit do iOS, o FCM do Android possui sem prazo estrito para exibir a interface de chamada. Você pode realizar tarefas assíncronas (restauração de sessão, atualização de token) com segurança antes de exibir a chamada recebida. Isso elimina as condições de corrida que afetam as implementações no iOS.

Configuração do projeto

Dependências do 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

Baixe do Console do Firebase e salve em app/google-services.json. Adicione isso a .gitignore — ele contém as credenciais do seu projeto do Firebase.

Itens do manifesto

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

Permissões em tempo de execução

Solicite esses recursos em tempo de execução antes de tentar qualquer chamada:

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 e CALL_PHONE são permissões normais — elas são concedidas automaticamente durante a instalação e não exigem caixas de diálogo durante a execução.

Aviso: RECORD_AUDIO deve ser concedida antes de qualquer tentativa de chamada. Se for negada, o SDK estabelecerá a chamada, mas o destinatário ouvirá apenas silêncio.

Inicialização do SDK

Inicializar VoiceClient uma vez na sua Application.onCreate() ou um singleton. Nunca o recrie — isso limpa todos os ouvintes e faz com que o estado da sessão seja perdido.

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

Integração com o FCM

FirebaseMessagingService

Mantenha o serviço leve — delegue toda a lógica à sua classe gerenciadora.

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

Manipulação de tokens

A FCM pode alternar os tokens a qualquer momento. onNewToken é o apenas notificação confiável. Quando for acionada:

  1. Salve o novo token localmente
  2. Se já houver uma sessão ativa, chame registerDevicePushToken imediatamente

Um token expirado significa que a Vonage não consegue acessar o dispositivo — todas as notificações futuras não serão entregues, sem aviso prévio.

Registrando o token na 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;
 });

Registre o token FCM com client.registerDevicePushToken(token) após a criação de uma sessão. O servidor gera um deviceId para cada registro; portanto, evite chamar essa função quando o token não tiver mudado — caso contrário, registros duplicados se acumulam, causando várias entregas por chamada.

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

Mensagens de alta prioridade e modo Doze O back-end da Vonage envia mensagens FCM às alta prioridade. As mensagens de alta prioridade contornam o modo Doze do Android e ativam o aplicativo imediatamente. Você não precisa configurar isso no seu aplicativo — trata-se de uma configuração do lado do servidor na plataforma Vonage.

Gerenciamento de sessões

Uma sessão do Vonage (client.createSession(jwt)) deve estar ativo para que o SDK possa processar convites de chamada ou executar ações relacionadas a chamadas. Quando uma notificação push chega e o aplicativo é encerrado ou a sessão expira, é necessário que a restauração ocorra primeiro.

Ao contrário do iOS — o encadeamento é seguro no Android

O FCM do Android não possui um prazo, como no PushKit, para exibir a interface do usuário do sistema. Você pode aguardar com segurança a restauração da sessão antes de chamar client.processPushCallInvite. Isso elimina a condição de corrida em que o usuário responde antes que a sessão esteja pronta.

Abordagem recomendada: primeiro, restaure a sessão. Quando for confirmada como ativa, chame processPushCallInvite. A sessão certamente existe quando answer() / reject() são chamados.

Evite: Processar a notificação imediatamente e executar a restauração da sessão simultaneamente. O usuário pode responder antes que a sessão esteja pronta, causando client.answer() falhar sem dar sinal.

Criação de sessão

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

Cadeia de prioridades para restauração de sessão

Prioridade Condição Ação Latência típica
1º Existe uma sessão ativa (sessionId != null) Volte imediatamente 0 ms
2º JWT armazenado disponível (authToken) client.createSession(storedJWT) 100-400 ms
3º O JWT expirou, mas há um token de atualização disponível Atualizar endpoint -> novo JWT -> createSession 300–1.000 ms
4º Sem credenciais Voltar null — o envio não pode ser processado 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
}

Persista o JWT no momento do login. Se createSession Como isso exige uma chamada ao backend em tempo real para obter um JWT sempre que chega uma notificação push, você está adicionando um salto extra na rede antes que a sessão esteja pronta. Em conexões lentas, isso aumenta a latência de restauração e prejudica a confiabilidade da resposta à chamada.

Prevenção do acúmulo de tokens ao fazer novo login

Ao iniciar um novo login por iniciativa do usuário, sempre desregistre o dispositivo armazenado anteriormente antes de criar uma nova sessão. Ignorar essa etapa faz com que o Vonage acumule vários registros de dispositivos, resultando em notificações push duplicadas para a mesma chamada.

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

Processamento de cargas úteis de push

O caminho completo desde a entrega pelo FCM até a interface de usuário de chamadas recebidas do sistema:

Implementação

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

Tratamento do convite de chamada

Quando o SDK é acionado setCallInviteListener, registre a chamada como o estado do seu aplicativo e inicie o CallService — que é o que registra a chamada na Telecom (veja Integração de telecomunicações (pois é o serviço que deve fazer isso).

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

Integração de telecomunicações com o CallsManager

O registro da chamada no sistema operacional é feito por meio da Core-Telecom CallsManager. Você registra seu aplicativo uma vez e, em seguida, chama addCall por chamada: fornece à Telecom uma descrição da chamada (CallAttributesCompat) e suspende durante todo o tempo de duração da chamada, proporcionando a você um CallControlScope para controlá-lo (atender / colocar em espera / desligar / encaminhar o áudio) e monitorá-lo (estado de mudo, terminais de áudio).

A forma condensada (ver CallManager.kt (para o 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) } }
    }
}

Hospedar o registro em um serviço em execução

Ligar addCall de um serviços iniciados escopo, e não um escopo de singleton de aplicativo. O serviço iniciado concede ao processo a autorização para que a Telecom manter a chamada; uma chamada adicionada a partir de um processo em segundo plano iniciado a frio, sem âncora de serviço, é adicionada e, em seguida, imediatamente destruída (CallException.ERROR_CALL_IS_NOT_BEING_TRACKED), após o que todas as operações de controle falham. É por isso que o manipulador de convites é iniciado CallService, e o serviço executa o registro. Veja CallService.kt.

Prenda a causa da desconexão

Da Core-Telecom disconnect aceita apenas LOCAL, REMOTE, MISSED, e REJECTED — qualquer outro DisconnectCause desmonta e destrói a sessão. Mapa no 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)) }
}

Uma única fonte de verdade: ActiveCall

Em vez de vincular o estado do aplicativo a um Connection subclasse, mantenha um imutável ActiveCall que a interface do usuário e as notificações seguem; tanto o SDK quanto os callbacks de telecomunicações o atualizam. O CallManager O wrapper é uma integração com o sistema operacional do tipo “fire-and-forget” e nunca retorna o status da chamada. Veja ActiveCall.kt.

Execução em primeiro plano e a notificação CallStyle

Você faz não criar um serviço de microfone em primeiro plano. Enquanto uma chamada estiver registrada no CallsManager e a CallStyle Quando a notificação é publicada, a Core-Telecom trata a chamada como uma phoneCall|microphone serviço em primeiro plano para você — é isso que garante o áudio do microfone ao vivo e a prioridade de execução em primeiro plano, mesmo a partir de uma instância encerrada ou colocada em segundo plano. Um serviço em primeiro plano manual é redundante e pode causar conflitos.

O CallStyle A notificação é, portanto, obrigatória, e não meramente simbólica. Há dois pontos que devem ser esclarecidos (ver CallNotifier.kt):

  • Intenção de tela cheia para chamadas recebidas ativa a função “Wake on Call” mesmo com o bloqueio de tela ativado, quando chega uma notificação em um dispositivo bloqueado.
  • A resposta deve ser uma atividade PendingIntent, e não uma transmissão que, em seguida, chama startActivity — este último é um “trampolim” de notificações, proibido desde o Android 12. As opções “Recusar” e “Desligar” são transmissões para um BroadcastReceiver.

Sincronize o cronômetro da notificação ativa com o tempo real de conexão e só reenvie a notificação quando seus dados forem alterados, para que as alterações nos botões de silenciar/colocar em espera não reiniciem o cronômetro de duração da chamada.

Ouvintes do SDK

Defina todos os ouvintes uma única vez, imediatamente após a inicialização VoiceClient, antes de criar qualquer sessão. Mapeie cada evento do SDK para ActiveCall definir e direcionar todos os eventos de terminal por um único caminho de limpeza, de modo que a chamada de telecomunicações seja sempre desconectada e a notificação sempre cancelada. (Conjunto completo em VoiceClientManager.kt.)

Ativo / Reconectando

// 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 (fixando o tempo de conexão para o cronômetro de notificação) e chamadas telecom.setActive().

Eventos finais

Mapeie o motivo do SDK para um DisconnectCause, depois execute um cleanUp caminho (que chama telecom.disconnect(cause), exibe brevemente o estado “desconectado” e, em seguida, apaga 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 corridas telecom.disconnect, o DisconnectCause Os códigos acima estão restritos aos quatro aceitos pela Core-Telecom (consulte Prenda a causa da desconexão).

Chamadas efetuadas

Mais simples do que com ConnectionService — não placeCall / onCreateOutgoingConnection dance e sem temporizador de fallback do OEM. Inicie a chamada do SDK, registre-a como a chamada ativa e inicie o serviço para registrá-la na 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
    }
}

Tratamento de erros

O SDK é acionado setSessionErrorListener quando a conexão WebSocket for interrompida durante uma sessão ativa. Tente restaurá-la automaticamente, utilizando o motivo do erro para escolher a estratégia correta.

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

Cenários comuns de erros

Erro Causa Recuperação
TokenExpired O TTL do JWT foi excedido Use o token de atualização para obter um novo JWT
TransportClosed Interrupção na rede Tente novamente com o JWT armazenado
PingTimeout Servidor inacessível Tentar novamente com recuo exponencial
registerDevicePushToken falha Sessão inativa Certifique-se de que createSession concluído antes do cadastro
processPushCallInvite silencioso Sem sessão Sempre restaure a sessão primeiro
answer() falha A sessão expirou entre o envio e a resposta Restaure novamente a sessão e, em seguida, responda

Legado: ConnectionService

Antes do Jetpack Core-Telecom, as chamadas VoIP autogerenciadas utilizavam android.telecom.ConnectionService diretamente: você se registrou em um PhoneAccount, subclassificado ConnectionService, retornou Connection objetos de onCreateIncomingConnection / onCreateOutgoingConnection, e administrou o seu próprio microphone serviço em primeiro plano. Ele ainda funciona e é não está obsoleto, portanto, as integrações existentes não precisam ser migradas com urgência.

Para novo integrações, CallsManager é preferível:

CallsManager (Core-Telecom) ConnectionService (antigo)
Android 14: tipo FGS + permissão Já cuidamos disso para você Você mesmo declara e inicia o FGS
Serviço em primeiro plano Fornecido pela biblioteca Você o escreve e o gerencia
Multiplatforma (Wear / Auto / Bluetooth) Integrado Manual
Estrutura da API Um addCall + a CallControlScope PhoneAccount + Connection subclasse + callbacks do sistema
Peculiaridades das chamadas efetuadas pelo OEM Gerenciado pela biblioteca Temporizadores manuais de retorno ao modo normal (Samsung/Xiaomi)
API mínima 26 23

O ponto relativo ao Android 14 é o fator prático: a partir da API 34, o sistema operacional verifica o tipo de serviço em primeiro plano e a permissão correspondente (por exemplo, RECORD_AUDIO) no momento em que o serviço é iniciado. CallsManager isso atende às suas necessidades; um enrolado à mão ConnectionService A integração precisa funcionar perfeitamente; caso contrário, as chamadas falharão em dispositivos modernos.

Veja o Referência do ConnectionService se você mantiver uma integração já existente.

Armadilhas e antipadrões

1. Processamento do push antes da restauração da sessão

Não faça o seguinte:

Ligar processPushCallInvite imediatamente após o recebimento da notificação push, sem verificar se há uma sessão ativa. O ouvinte de convite de chamada pode não ser acionado, resultando em uma chamada perdida sem interface de usuário.

O que fazer:

Restauração da sessão em cadeia antes do processamento: restoreSessionIfNeeded { processPushCallInvite(data) }.

2. Obter o JWT do backend a cada envio

Não faça o seguinte:

Exigir uma chamada ao backend ativo antes de createSession a cada envio. Isso dobra a latência nos envios em inicialização a frio.

O que fazer:

Armazene o JWT no momento do login para que a restauração exija apenas um createSession ligar.

3. Não registrar o token de push após a criação da sessão

Não faça o seguinte:

Registre o token de notificação uma única vez ao iniciar o aplicativo e nunca mais. O token deve ser registrado na Vonage após cada login iniciado pelo usuário (já que o registro anterior do dispositivo foi cancelado no momento do logout).

O que fazer:

Ligar registerDevicePushToken dentro do createSession callback de sucesso para logins iniciados pelo usuário. Para restaurações de sessão, ignore o registro, a menos que o token do FCM tenha sido alterado (consulte a armadilha nº 5).

4. Não cancelar o registro ao sair da conta

Não faça o seguinte:

Pular unregisterDevicePushToken ao sair da conta. As chamadas do usuário anterior continuarão chegando neste aparelho.

O que fazer:

Sempre ligue unregisterDevicePushToken antes deleteSession durante o logout.

5. Registro de tokens de dispositivo duplicados

Não faça o seguinte:

Ligar registerDevicePushToken em cada createSession independentemente de o token ter mudado ou não. O servidor da Vonage emite um novo deviceId em cada chamada de registro — chamadas repetidas com o mesmo token acumulam registros duplicados, causando várias entregas por push por chamada.

O que fazer:

Rastrear o último token registrado com sucesso (por exemplo, coreContext.lastRegisteredPushToken). Ligue apenas registerDevicePushToken quando:

  • Este é um novo login iniciado pelo usuário (o dispositivo anterior não estava registrado)
  • O token FCM mudou (onNewToken demitido)
  • coreContext.deviceId é nulo (não há nenhum cadastro anterior)

Ignorar o registro durante a restauração da sessão se o token corresponder ao registrado anteriormente.

6. Token FCM expirado não foi registrado novamente

Não faça o seguinte:

Ignorar onNewToken. O FCM alterna os tokens sem aviso prévio — todas as transmissões futuras não poderão ser entregues.

O que fazer:

Em onNewToken, salve o novo token e chame registerDevicePushToken se houver uma sessão ativa.

7. Registro da chamada fora de um serviço já iniciado

Não faça o seguinte:

Ligar callsManager.addCall a partir de um escopo de singleton de aplicativo ou de uma corrotina única. A partir de uma inicialização em segundo plano a frio, o Telecom adiciona e, em seguida, destrói imediatamente a chamada (ERROR_CALL_IS_NOT_BEING_TRACKED).

O que fazer:

Comece um Service e executar addCall fora do seu âmbito, de modo que o processo dá à Telecom o direito de manter a ligação.

8. Não enviar uma notificação do CallStyle

Não faça o seguinte:

Registre a chamada, mas pule a CallStyle notificação. O Core-Telecom só concede prioridade ao primeiro plano/microfone enquanto essa notificação estiver ativa — sem ela, a chamada não tem acesso confiável ao microfone em segundo plano.

O que fazer:

Publicar um CallStyle notificação para toda a chamada, com uma tela inteira para chamadas recebidas.

9. Como adicionar seu próprio serviço de primeiro plano de microfone

Não faça o seguinte:

Declarar e iniciar um manual foregroundServiceType="microphone" serviço. Com o Core-Telecom, ele é redundante e pode causar conflitos.

O que fazer:

Deixe que a Core-Telecom forneça o serviço principal; o seu próprio serviço é apenas um começou serviço para notificação e fixação do processo.

10. Passagem de um `DisconnectCause` não suportado

Não faça o seguinte:

Encaminhar arbitrariamente DisconnectCause códigos para a Core-Telecom’s disconnect — qualquer coisa lá fora LOCAL/REMOTE/MISSED/REJECTED interrompe e encerra a sessão.

O que fazer:

Resolva o problema na origem antes de chamar a função disconnect.

11. Recriando o VoiceClient a cada notificação push

Não faça o seguinte:

Instanciar VoiceClient no seu FirebaseMessagingService ou manipulador por envio. Isso limpa todos os ouvintes e perde o estado da sessão.

O que fazer:

Inicializar VoiceClient uma vez em Application.onCreate() ou um singleton. Reutilize-o ao longo de todo o ciclo de vida do aplicativo.

Lista de verificação da produção

Antes de lançar seu aplicativo, Verify cada item:

  • [ ] google-services.json presente em app/ e excluídos do controle de versão
  • [ ] androidx.core:core-telecom dependência adicionada
  • [ ] Permissões necessárias declaradas no AndroidManifest.xml: RECORD_AUDIO, MANAGE_OWN_CALLS, FOREGROUND_SERVICE, FOREGROUND_SERVICE_PHONE_CALL, FOREGROUND_SERVICE_MICROPHONE, POST_NOTIFICATIONS, USE_FULL_SCREEN_INTENT
  • [ ] RECORD_AUDIO permissão de tempo de execução solicitada e concedida antes de qualquer tentativa de chamada
  • [ ] PushNotificationService, CallService, e CallActionReceiver declarado no manifesto (sem serviço manual de microfone em primeiro plano)
  • [ ] VoiceClient inicializada uma vez (em Applications.onCreate ou como singleton) com todos os ouvintes definidos antes da primeira sessão
  • [ ] Aguardando a restauração da sessão antes de processPushCallInvite — o encadeamento é seguro no Android
  • [ ] O JWT (e o token de atualização) são armazenados localmente no momento do login — a restauração não deve exigir uma chamada ao backend em tempo real
  • [ ] registerDevicePushToken foi chamado após o login iniciado pelo usuário após createSession é bem-sucedido; é ignorado na restauração da sessão, a menos que o token do FCM tenha mudado desde o último registro
  • [ ] unregisterDevicePushToken chamada ao logout explícito do usuário
  • [ ] A rotação de tokens do FCM é gerenciada em onNewToken — salvar e registrar novamente se a sessão estiver ativa
  • [ ] addCall hospedado dentro de um processo iniciado Service (não é um singleton do aplicativo)
  • [ ] CallStyle notificação exibida durante toda a chamada, com a intenção de ocupar a tela inteira para chamadas recebidas
  • [ ] DisconnectCause preso a LOCAL/REMOTE/MISSED/REJECTED antes disconnect
  • [ ] Qualquer caminho de desligamento, cancelamento ou desconexão de mídia cancela a notificação e desconecta a chamada de telecomunicações
  • [ ] O gerenciamento de erros de sessão restaura a sessão automaticamente; TokenExpired ignora os JWTs armazenados
  • [ ] Testado em um dispositivo físico, com o aplicativo totalmente encerrado antes da chamada recebida
  • [ ] Testado em pelo menos um dispositivo Samsung ou de outro fabricante (não apenas no Pixel ou em emulador)

Leitura complementar