Modern Auth: Integrando Firebase com o Novo Gerenciador de Credenciais do Android
O Google Sign-In no Android costumava significar a importação da antiga GoogleSignInClient, lidando com contratos de resultado de atividade e esperando que a versão do play-services no dispositivo correspondesse àquela esperada pelo aplicativo. O API Gerenciador de Credenciais do Android substitui isso por uma superfície unificada para senhas, passkeys e entrada federada — e combiná-lo com o Firebase Authentication acabou sendo mais um problema sobre "qual camada é responsável por quê" do que um desafio técnico difícil.
Aqui está como a entrada realmente funciona no aplicativo, do início ao fim.
Por que o Gerenciador de Credenciais?
O Gerenciador de Credenciais fica entre o seu aplicativo e todas as fontes de credencial no dispositivo — senhas salvas, passkeys e a identidade do Google One Tap — por meio de uma única API: CredentialManager.getCredential(). Em vez de o aplicativo saber especificamente sobre o Google Sign-In, ele solicita uma credencial e recebe aquilo que o usuário tem e escolhe usar. Essa é toda a proposta: um formato único para solicitação, vários tipos de credenciais e nenhum fluxo específico do SDK para cuidar separadamente.
Para este aplicativo, apenas a entrada federada do Google está conectada até agora, mas a mesma GetCredentialRequest poderia adicionar uma GetPasswordOption ou uma opção de passkey mais tarde sem tocar na lógica de entrada circundante.
Onde as Peças Estão Localizadas
Este é um bom exemplo de fronteiras entre módulos realmente importarem. A chamada do Gerenciador de Credenciais tem que acontecer a partir de um contexto Activity — ela precisa iniciar uma interface do sistema —, então vive em MainActivity.kt, dentro de :app. O Firebase em si e o estado da sessão resultante vivem um nível abaixo em :auth e :session. O AuthViewModel de :auth não sabe nada sobre Gerenciador de Credenciais; ele só sabe como pegar um token ID e passá-lo para o Firebase.
Essa divisão mantém a preocupação "como obtemos uma credencial do SO" (um detalhe da plataforma Android) separada da preocupação "como transformamos uma credencial em um usuário Firebase conectado" (um detalhe de lógica do aplicativo).
O Fluxo de Entrada
MainActivity cria uma única instância de CredentialManager e constrói um GetGoogleIdOption — isso é o que informa ao Gerenciador de Credenciais para realmente se comunicar com o Google como a fonte de identidade:
val credentialManager = CredentialManager.create(context)Quando a pessoa toca em “Entrar com Google”, este pedido é feito:
val googleIdOption: GetGoogleIdOption =
GetGoogleIdOption.Builder()
.setFilterByAuthorizedAccounts(false)
.setServerClientId(getString(R.string.default_web_client_id))
.build()
val request: GetCredentialRequest =
GetCredentialRequest.Builder()
.addCredentialOption(googleIdOption)
.build()setFilterByAuthorizedAccounts(false) significa que o Credential Manager mostrará todas as contas Google no dispositivo, não apenas aquelas que já fizeram login neste aplicativo — a chamada correta para um botão de login que também precisa atender aos usuários de primeira viagem. setServerClientId é o ID do cliente OAuth do projeto Firebase, o que permite que o token ID retornado pelo Credential Manager seja verificado como legítimo por Firebase posteriormente.
A chamada real é uma função suspensa, então ela é lançada de um escopo de coroutine vinculado ao composable:
try {
val result =
credentialManager.getCredential(
context = this@MainActivity,
request = request,
)
val credential = result.credential
val googleIdTokenCredential =
GoogleIdTokenCredential.createFrom(credential.data)
val idToken = googleIdTokenCredential.idToken
authViewModel.signInWithGoogle(idToken) {}
} catch (e: GetCredentialException) {
val msg = getString(R.string.google_sign_in_failed, e.message)
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}Dois pontos importantes aqui. Primeiro, GetCredentialException é um único tipo de exceção que cobre tudo que pode dar errado — o usuário cancela, nenhuma credencial está disponível, a rede falha — o que é uma superfície muito menor para lidar do que ramificações antigas baseadas em resultados de atividade. Segundo, assim que o Credential Manager retorna uma credencial, MainActivity sai imediatamente da questão de identidade: o token ID bruto é entregue diretamente ao ViewModel e nada sobre como esse token foi obtido se espalha mais para baixo.
Passando para Firebase
AuthViewModel — abaixo em :auth, sem conhecimento do Credential Manager — toma a partir daí:
fun signInWithGoogle(
idToken: String,
onSuccess: () -> Unit,
) {
viewModelScope.launch {
_loading.value = true
_error.value = null
try {
val credential = GoogleAuthProvider.getCredential(idToken, null)
auth.signInWithCredential(credential).await()
sessionManager.updateSession()
onSuccess()
} catch (e: Exception) {
_error.value = e.message ?: "Google sign in failed"
} finally {
_loading.value = false
}
}
}GoogleAuthProvider.getCredential(idToken, null) converte o token de nível de plataforma em uma credencial no formato do Firebase, e auth.signInWithCredential() é a chamada que efetivamente cria ou faz login no usuário do Firebase. A partir daqui, o fluxo retorna ao mesmo caminho que a autenticação por email/senha — sessionManager.updateSession() é exatamente a mesma chamada feita por signInWithEmail e signUpWithEmail, então o código downstream nunca precisa saber ou se importar com qual método de identidade o usuário escolheu.
A instância FirebaseAuth em si é injetada em vez de ser construída inline — ela é fornecida como um vinculo singleton a partir do módulo Hilt de :session, então há exatamente uma única instância de FirebaseAuth compartilhada entre login por email, Google e restauração da sessão:
@Provides
@Singleton
fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance()O Desafio: Prevenir um Flash na Inicialização
O Firebase persiste o usuário autenticado localmente, então em uma inicialização fria auth.currentUser geralmente já está preenchido — mas não é preenchido imediatamente. Há um breve período enquanto o Firebase restaura esse estado do disco. Se a UI simplesmente verificar user == null e navegar no momento da criação do ViewModel, um usuário retornando é empurrado para a tela de login por um ou dois frames antes que o Firebase se recupere e a verdadeira navegação comece. Esse flash da tela errada é o tipo de coisa que faz com que um aplicativo pareça quebrado mesmo que nada tenha realmente falhado.
A solução ingênua — simplesmente adiar a navegação por um número fixo de milissegundos — é frágil. Demasiado curto e você não resolveu nada; demais longo e cada inicialização parece lenta, incluindo para usuários que estão deslogados e devem ver a tela de login imediatamente.
A Solução: Um isCheckingSession StateFlow
Em vez de adivinhar um atraso, AuthViewModel expõe uma terceira parte do estado que fica ao lado de user e loading: uma bandeira que é true até a verificação da sessão realmente terminar, por quanto tempo for necessário.
private val _isCheckingSession = MutableStateFlow(true)
val isCheckingSession: StateFlow<Boolean> = _isCheckingSession
init {
checkSession()
}
private fun checkSession() {
viewModelScope.launch {
// Deixe o Firebase um momento para restaurar a sessão se necessário
if (auth.currentUser != null) {
sessionManager.updateSession()
}
_isCheckingSession.value = false
}
}Ele começa true por padrão, e checkSession() é executado automaticamente a partir de init no momento em que o ViewModel é criado — antes que qualquer tela tenha tido a chance de renderizar uma decisão com base no usuário. Se o Firebase já tem um usuário atual quando esta rotina corrotine começa, a sessão é recarregada via sessionManager.updateSession(); de qualquer forma, _isCheckingSession só muda para false uma vez que a verificação é realmente concluída, não em um temporizador.
MainActivity usa esta bandeira para bloquear toda a decisão de navegação atrás de um estado de carregamento em vez de competir com ela:
val isCheckingSession by authViewModel.isCheckingSession.collectAsStateWithLifecycle()
...
if (isCheckingSession) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
LaunchedEffect(user) {
if (user != null) {
navController.navigate("main") { popUpTo(0) { inclusive = true } }
} else {
navController.navigate("login") { popUpTo(0) { inclusive = true } }
}
}
NavHost(/* ... */)
}Enquanto isCheckingSession é true, o aplicativo exibe um único indicador de carregamento deliberado — nada mais. O NavHost (e o LaunchedEffect que decide para onde enviar o usuário) nem mesmo é composto ainda, então não há tela de login sentada embaixo esperando para piscar. Apenas uma vez que a verificação completa, a UI se compromete com um destino e por isso user reflete o estado real restaurado do Firebase ao invés de um temporariamente vazio.
Reagindo ao Resultado
Uma vez que isCheckingSession é limpo, a navegação em si permanece simples, porque está reagindo ao estado em vez de uma única chamada de retorno:
kotlin
val user by authViewModel.user.collectAsStateWithLifecycle()Seja qual for o método de login do usuário (e-mail, Google) ou se ele foi restaurado silenciosamente de uma sessão persistida ao iniciar o aplicativo, a lógica de navegação subsequente é a mesma. O AuthViewModel.user é apenas um sessionManager.user exposto como um StateFlow<FirebaseUser?> — uma única fonte de verdade que o Gerenciador de Credenciais, autenticação por e-mail/senha e restauração da sessão alimentam do mesmo modo, e a porta isCheckingSession garante que nada leia antes que seja confiável.
O Que Vem Próximo
O Gerenciador de Credenciais e o Firebase fazem com que o usuário entre, mas uma sessão assinada não é a mesma coisa que uma segura. O próximo post aborda o que acontece após o login — como o DataStore criptografado em :session rastreia atividades e como um tempo de inatividade de 2 minutos realmente bloqueia o aplicativo ao invés de apenas mostrar a tela de login novamente.

