Resposta rápida: use Firebase Cloud Messaging (FCM) quando o servidor precisa avisar o app Android — pedido atualizado, mensagem nova, alerta de segurança, lembrete ou sincronização. No Kotlin moderno, separe três camadas: (1) obter e renovar o token FCM com segurança; (2) criar NotificationChannel adequados e respeitar POST_NOTIFICATIONS no Android 13+; (3) decidir o que fazer com o payload — exibir notificação, abrir deep link, enfileirar WorkManager ou atualizar estado local. Em Compose, trate a permissão e as preferências do produto como estado de UI, não como detalhe escondido no Application. FCM não substitui um canal de tempo real bidirecional (WebSocket/SSE) nem justifica um foreground service permanente sem necessidade clara.
Push notification parece “só mostrar um balão” até o app cair em produção. O token muda, o usuário nega a permissão, o canal fica silencioso demais, o payload chega em background e some, a tela errada abre no clique, ou o time usa WorkManager periódico tentando fingir tempo real. O roadmap Android já aponta FCM como habilidade esperada; este guia fecha a lacuna com um caminho prático em Kotlin.
Consulte também a documentação oficial do Firebase Cloud Messaging e as diretrizes de notificações do Android antes de fixar versões do BoM Firebase e das APIs de notificação do sistema.
Quando FCM vale a pena — e quando não
FCM é a escolha certa quando:
- o servidor precisa iniciar a conversa com o dispositivo;
- a mensagem pode chegar com o app em background ou morto;
- você precisa de fan-out (um para muitos) ou targeting por tópico;
- o produto depende de alertas transacionais (pagamento, entrega, segurança).
Evite FCM quando:
- a interação é só local (alarme de pomodoro, lembrete criado no aparelho) — use
AlarmManager/ agendamento local; - você precisa de streaming contínuo bidirecional — prefira WebSocket, SSE no Ktor ou canal próprio;
- a “notificação” é só marketing agressivo sem valor — isso queima permissão e retenção.
O erro clássico documentado no guia de WorkManager continua válido: não use periodic work para simular push. Bateria, Doze e quotas vão atrasar o job; FCM existe justamente para esse sinal.
Arquitetura mental: token, canal, payload e clique
| Peça | Papel |
|---|---|
| Token FCM | endereço lógico do app nesta instalação |
| Tópico / condição | segmentação no servidor sem listar tokens um a um |
FirebaseMessagingService | recebe mensagens no processo do app |
NotificationChannel | categoria, importância, som e agrupamento no sistema |
Payload notification | o sistema pode exibir sozinho em background |
Payload data | seu código decide o que fazer |
| PendingIntent / deep link | para onde o usuário vai ao tocar |
| Preferências do produto | o usuário controla tipos de alerta além da permissão do SO |
Em times sérios, o backend é dono do envio. O app Android autentica o usuário, envia o token atualizado e trata a mensagem. Não embuta chaves de servidor FCM no APK.
Dependências e setup mínimo
No módulo :app, alinhe o Firebase BoM e o messaging:
dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.12.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
}
Confirme a versão estável atual do BoM na documentação do Firebase antes de piná-la. Aplique o plugin do Google Services no Gradle do app e coloque o google-services.json correto por flavor (dev/staging/prod). Tokens de debug misturados com produção são uma fonte clássica de “push não chega”.
Inicialize o Firebase cedo — muitas bases já usam AndroidX App Startup ou o Application padrão. O importante é não bloquear a main thread com trabalho pesado no boot.
Token FCM: obter, enviar e renovar
class PushTokenRepository(
private val messaging: FirebaseMessaging,
private val api: DeviceApi,
) {
suspend fun syncToken() {
val token = messaging.token.await()
api.registerDeviceToken(token)
}
}
Trate três eventos:
- primeiro login / instalação — sincroniza o token;
onNewToken— o FCM rotacionou o endereço;- logout — desassocie o token do usuário no backend (e considere
deleteToken()se a política de privacidade exigir).
Guarde o token com o mesmo cuidado de outros identificadores locais. O guia de segurança de dados locais e a LGPD no Android ajudam a decidir o que fica em DataStore, o que vai cifrado e o que nunca deve ir para analytics.
NotificationChannel: sem canal, não há produto
Desde o Android 8, canais não são opcionais. Crie-os no startup (idempotente):
fun ensureChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java)
val transactional = NotificationChannel(
"orders",
"Pedidos e entregas",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Atualizações de pedido, pagamento e entrega"
}
val social = NotificationChannel(
"social",
"Mensagens e menções",
NotificationManager.IMPORTANCE_DEFAULT,
)
val marketing = NotificationChannel(
"marketing",
"Novidades e ofertas",
NotificationManager.IMPORTANCE_LOW,
)
manager.createNotificationChannels(listOf(transactional, social, marketing))
}
Boas práticas:
- um canal por intenção de produto, não um canal genérico “Geral”;
- importância alta só para o que realmente interrompe;
- nomes e descrições em PT-BR claros — o usuário gerencia isso nas configurações do sistema;
- nunca mude o ID do canal depois do lançamento se quiser preservar a escolha do usuário.
Permissão POST_NOTIFICATIONS e Compose
No Android 13+, exibir notificações exige permissão em runtime. O guia de permissões Android já cobre o padrão Activity Result + Compose; aqui o recorte específico de push:
@Composable
fun NotificationPermissionCard(
onRequest: () -> Unit,
onOpenSettings: () -> Unit,
) {
Card {
Text("Ative alertas de pedido para saber quando sair para entrega.")
Button(onClick = onRequest) { Text("Permitir notificações") }
TextButton(onClick = onOpenSettings) { Text("Abrir configurações") }
}
}
Regras de produto que salvam conversão:
- peça a permissão no momento de valor (ativar alertas de pedido), não no primeiro frame do onboarding;
- explique o benefício antes do dialog do sistema;
- ofereça toggles internos por tipo (transacional vs marketing) mesmo depois da permissão concedida;
- se o usuário negou duas vezes, encaminhe para as configurações do app em vez de spammar o dialog.
Payload: notification vs data
FCM aceita mensagens com bloco notification, bloco data, ou ambos. A diferença prática:
| Tipo | App em foreground | App em background / morto |
|---|---|---|
só notification | seu onMessageReceived costuma rodar; você decide exibir | o sistema pode exibir a notificação automaticamente |
só data | você decide tudo | você decide tudo (com limites de execução) |
| híbrido | comportamento depende da plataforma e do estado | o sistema pode mostrar o bloco notification e entregar data no Intent do clique |
Para apps com lógica de negócio (deeplink tipado, dedupe, analytics, escrita local), muitos times preferem data messages controladas pelo app, com o backend enviando título/corpo/rota como chaves. O trade-off é mais código e mais responsabilidade sobre exibir a notificação corretamente.
Exemplo de serviço enxuto:
class AppFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
// Enfileire sync do token — não faça rede bloqueante aqui
PushTokenWorker.enqueue(applicationContext, token)
}
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
val type = data["type"].orEmpty()
when (type) {
"order_update" -> OrderPushHandler.handle(applicationContext, data)
"chat_message" -> ChatPushHandler.handle(applicationContext, data)
else -> GenericPushHandler.handle(applicationContext, message)
}
}
}
Mantenha onMessageReceived curto. Se precisar baixar anexo, reconciliar banco ou chamar API, delegue para WorkManager com constraints. Isso evita ANR e combina com o que o time já pratica em Crashlytics/ANR e Firebase Performance.
Exibir a notificação e abrir a tela certa
fun showOrderNotification(context: Context, orderId: String, title: String, body: String) {
val deepLink = "https://app.exemplo.com.br/pedidos/$orderId".toUri()
val intent = Intent(Intent.ACTION_VIEW, deepLink).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pending = PendingIntent.getActivity(
context,
orderId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, "orders")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(pending)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
NotificationManagerCompat.from(context).notify(orderId.hashCode(), notification)
}
Conecte o clique ao mesmo grafo de navegação que você já usa com Navigation Compose e App Links / deep links. Push que abre a home genérica destrói a confiança do usuário.
Foreground service: use com justificativa
Há cenários legítimos — navegação turn-by-turn, upload longo visível, sessão de mídia com Media3, captura contínua com CameraX. Nesses casos o Android exige notificação persistente e, em versões recentes, foregroundServiceType declarado no manifesto.
Não transforme todo push em foreground service. Para sync curto após um data message, WorkManager + notificação cancelável costuma ser suficiente e mais amigável à bateria. Se o Play Console pedir declaração de tipo de service, alinhe o manifesto à realidade do produto — declarações falsas geram rejeição.
Segurança, abuso e Play Integrity
Push é vetor de phishing interno se o backend for frágil:
- autentique o registro de token (usuário logado, device id estável, ambiente);
- assine ou valide no servidor qualquer ação sensível disparada pelo clique;
- não confie em deep links abertos sem checagem (open redirect, orderId de outro usuário);
- rate-limite envios por usuário/dispositivo;
- para fluxos de alto risco (pagamento, troca de senha), combine com Play Integrity e confirmação in-app — a notificação avisa, a ação crítica confirma.
Biometria e Keystore entram quando o clique leva a uma ação sensível; veja BiometricPrompt.
Testes e diagnóstico
Checklist mínimo:
- token sincroniza após login e após
onNewToken; - canais existem logo na primeira abertura;
- permissão Android 13+ pedida no momento certo;
- data message em foreground atualiza UI ou enfileira worker;
- app morto ainda mostra notificação e abre a rota correta;
- logout remove associação do token;
- marketing respeita opt-out interno mesmo com permissão do SO;
- não há PII sensível no título/corpo visível na lock screen sem necessidade.
No Logcat, filtre por FirebaseMessaging e pelo seu tag de handler. No console Firebase, use o envio de teste para o token do device de desenvolvimento. Em CI, prefira testes unitários dos parsers de payload e testes instrumentados do PendingIntent / deep link — Espresso ajuda na navegação pós-clique (guia Espresso).
Erros comuns
- Pedir permissão no launch frio — o usuário nega e o canal de receita some.
- Um único canal “Geral” — o usuário silencia tudo por causa de promoção.
- Chave de servidor no app — qualquer APK descompilado envia spam em seu nome.
- Ignorar
onNewToken— metade da base para de receber push após reinstall/backup. - Payload gigante — FCM não é CDN; mande IDs e busque o detalhe na API.
- WorkManager “quase em tempo real” — atrasa, drena confiança e não substitui FCM.
- Abrir sempre a MainActivity — deep link quebrado = suporte lotado.
- Misturar flavors —
google-services.jsonde prod em build de debug (ou o inverso).
Onde encaixar na carreira Android
Em entrevistas e portfólio, um fluxo FCM completo demonstra maturidade de produto: permissões, canais, ciclo de token, payload, navegação e privacidade. Combine com o roadmap Android, vagas em kotlin.dev.br/vagas e um app amostra que registre o token em um backend Kotlin (Spring ou Ktor).
Perguntas frequentes
FCM funciona sem Google Play Services?
Em dispositivos sem Play Services o cliente oficial do FCM não é o caminho padrão. Avalise a matriz de dispositivos do produto (Android puro, Huawei, etc.) antes de prometer push universal. Para a maioria dos apps brasileiros na Play Store, FCM permanece o padrão.
Qual a diferença entre tópico e token?
Token endereça uma instalação. Tópico endereça um conjunto de assinantes (news, order_123 raramente faz sentido como tópico eterno). Use tópicos para fan-out amplo; use tokens (ou groups no backend) para mensagens pessoais.
Posso garantir entrega em tempo real absoluto?
Não. Rede, Doze, otimização de bateria do OEM e decisão do usuário afetam a entrega. Desenhe o produto para ser eventualmente consistente: a notificação é um sinal; a fonte da verdade continua sendo a API ao abrir o app.
Notificação local e push são a mesma API de UI?
A exibição usa as mesmas NotificationCompat / canais. A origem muda: local nasce no aparelho; push nasce no servidor via FCM. Reaproveite o builder e os canais; separe os disparadores.
Preciso de Compose obrigatoriamente?
Não. FCM e canais são APIs de plataforma/serviço. Compose brilha na UX de permissão e preferências. Views tradicionais continuam válidas se o restante do app ainda não migrou.
Conclusão e próximos passos
Push bem feito é menos “biblioteca Firebase” e mais contrato entre backend, sistema operacional e expectativa do usuário. Com Kotlin, mantenha o FirebaseMessagingService fino, canais honestos, permissão contextual, payload previsível e deep links testados.
Próximos passos naturais no cluster Android deste site:
- revisar permissões e o momento do
POST_NOTIFICATIONS; - enfileirar trabalho pós-push com WorkManager;
- abrir a tela certa via App Links;
- observar estabilidade com Crashlytics e Performance Monitoring;
- endurecer fluxos sensíveis com Play Integrity e BiometricPrompt.
Se você está montando portfólio, documente no README do app: como obter o token de debug, quais canais existem, e como o backend autentica o registro. Isso diferencia um demo de um app pronto para operação.