Cómo funciona FCM — el modelo que hay que entender primero
Muchos devs empiezan a integrar FCM sin entender el flujo completo, y después se preguntan por qué el push no llega en ciertas condiciones. El modelo es este:
# Tu servidor backend
# ↓ (HTTP POST con el token del dispositivo)
# Servidores de FCM (Google)
# ↓ (conexión persistente TCP mantenida por Google Play Services)
# Dispositivo Android
# ↓
# Tu app
Tres puntos clave que explican mucho:
- No podés mandar pushes directamente al dispositivo. Siempre pasan por los servidores de FCM. Esto es intencional: FCM maneja las reconexiones, el Doze mode, la entrega garantizada y la cola de mensajes cuando el dispositivo está offline.
- El dispositivo mantiene una conexión persistente con FCM a través de Google Play Services, no de tu app. Por eso los pushes llegan aunque tu app no esté corriendo.
- El token identifica la instalación, no al usuario. Si el usuario desinstala y reinstala la app, el token cambia. Si borra los datos de la app, el token cambia. Tu backend tiene que manejar este ciclo de vida.
¿Y sin Google Play Services?FCM requiere Google Play Services. En dispositivos sin Play Services (algunos Huawei, ROMs custom, emuladores sin Google), FCM no funciona. Para esos casos hay alternativas como HMS Push de Huawei o soluciones basadas en WebSocket propias, pero están fuera del alcance de este artículo.
Integración en Android
// build.gradle (project)
plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
}
// build.gradle (app)
plugins {
id("com.google.gms.google-services")
}
dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.1.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
// Si ya tenés Analytics o Crashlytics, el BOM unifica las versiones
}
El archivo google-services.json va en la carpeta app/. Lo descargás desde Firebase Console → Configuración del proyecto → Tu app Android. Sin este archivo el build falla.
google-services.json no va al repositorioContiene el identificador de tu proyecto de Firebase y claves de configuración. Agregalo al .gitignore y manejalo como variable de entorno en CI/CD (GitHub Actions, Bitrise, etc. tienen soporte nativo para archivos de secretos).
El token del dispositivo — cómo obtenerlo y gestionarlo
El token FCM identifica la instalación de tu app en un dispositivo específico. Tu backend lo necesita para saber a quién mandarle el push.
// Obtener el token actual — siempre usar la Task API
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("FCM", "No se pudo obtener el token", task.exception)
return@addOnCompleteListener
}
val token = task.result
Log.d("FCM", "Token: $token")
// Enviar al backend para asociarlo al usuario actual
enviarTokenAlBackend(token)
}
// O con coroutines:
suspend fun obtenerToken(): String? {
return try {
FirebaseMessaging.getInstance().token.await()
} catch (e: Exception) {
null
}
}
// Detectar cuando el token se renueva automáticamente:
class MiFirebaseMessagingService : FirebaseMessagingService() {
// Este método se llama cuando:
// - El usuario instala la app por primera vez
// - El usuario restaura la app en un dispositivo nuevo
// - El usuario borra los datos de la app
// - FCM invalida el token por seguridad
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d("FCM", "Token renovado: $token")
// IMPORTANTE: actualizar el token en el backend
// Si no lo hacés, los pushes dejan de llegar
enviarTokenAlBackend(token)
}
}
Estrategia de gestión de tokensEl momento correcto para enviar el token al backend es al hacer login. Así asociás el token al usuario autenticado. Al hacer logout, desasocialo (o eliminalo del backend) para que el usuario no reciba pushes de la sesión anterior. Y usá onNewToken() para mantenerlo actualizado automáticamente.
Notification message vs Data message — la diferencia crítica
Esta es la distinción que más confusión genera y que más importa para el comportamiento de tu app. Los dos tipos de mensajes se comportan completamente diferente:
// NOTIFICATION MESSAGE — Android lo maneja automáticamente
{
"to": "TOKEN_DEL_DISPOSITIVO",
"notification": {
"title": "Nuevo mensaje",
"body": "Tenés 3 mensajes sin leer",
"icon": "ic_notification"
}
}
// ✓ Android muestra la notificación automáticamente cuando la app está en background
// ✓ No necesitás código para mostrarla
// ✗ Cuando la app está en FOREGROUND, NO se muestra automáticamente
// ✗ No podés personalizar mucho (sin acciones custom, sin progress, etc.)
// DATA MESSAGE — tu app lo maneja completamente
{
"to": "TOKEN_DEL_DISPOSITIVO",
"data": {
"tipo": "nuevo_mensaje",
"chat_id": "123",
"remitente": "Carlos",
"texto": "Hola!"
}
}
// ✓ Tu app recibe el mensaje en TODOS los estados (foreground, background, proceso muerto)
// ✓ Control total sobre cómo mostrar (o no) la notificación
// ✓ Podés incluir cualquier dato que necesites
// ✗ Tenés que escribir todo el código de display vos
// MENSAJE COMBINADO — lo mejor de los dos mundos
{
"to": "TOKEN_DEL_DISPOSITIVO",
"notification": {
"title": "Nuevo mensaje",
"body": "Tenés 3 mensajes sin leer"
},
"data": {
"tipo": "nuevo_mensaje",
"chat_id": "123"
}
}
// En BACKGROUND: Android muestra la notificación automáticamente
// En FOREGROUND: tu app recibe el mensaje y decide qué mostrar
El comportamiento más confusoCon un mensaje combinado (notification + data) cuando la app está en background, Android muestra la notificación automáticamente pero los datos del campo data llegan al Intent cuando el usuario toca la notificación. onMessageReceived() NO se llama. Esto sorprende a mucha gente la primera vez.
Comportamiento según el estado de la app
Esta tabla es la que tenés que tener clara. El comportamiento varía según el tipo de mensaje y el estado de la app:
# ┌─────────────────┬──────────────────┬──────────────────┬──────────────────┐
# │ │ FOREGROUND │ BACKGROUND │ PROCESO MUERTO │
# ├─────────────────┼──────────────────┼──────────────────┼──────────────────┤
# │ Notification │ onMessageReceived│ Android muestra │ Android muestra │
# │ message │ se llama. │ la notif sola. │ la notif sola. │
# │ │ NO muestra sola. │ onMessage NO │ onMessage NO │
# │ │ Tenés que mostrar│ se llama. │ se llama. │
# ├─────────────────┼──────────────────┼──────────────────┼──────────────────┤
# │ Data │ onMessageReceived│ onMessageReceived│ onMessageReceived│
# │ message │ se llama. │ se llama. │ se llama. │
# │ │ Vos manejás todo.│ Vos manejás todo.│ Vos manejás todo.│
# ├─────────────────┼──────────────────┼──────────────────┼──────────────────┤
# │ Notification │ onMessageReceived│ Android muestra │ Android muestra │
# │ + Data │ se llama con │ la notif sola. │ la notif sola. │
# │ │ ambas partes. │ data llega al │ data llega al │
# │ │ Vos mostrás. │ Intent al tocar. │ Intent al tocar. │
# └─────────────────┴──────────────────┴──────────────────┴──────────────────┘
La conclusión práctica: si necesitás control total del comportamiento en todos los estados, usá data messages puros y manejá el display en onMessageReceived(). Tenés más código pero cero sorpresas.
Mostrar la notificación desde código
Todo el código de display va en FirebaseMessagingService:
class MiFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
// Log para debugging
Log.d("FCM", "De: ${message.from}")
Log.d("FCM", "Datos: ${message.data}")
message.notification?.let { Log.d("FCM", "Notificación: ${it.title}") }
// Determinar qué tipo de mensaje es y manejarlo
val tipo = message.data["tipo"]
when (tipo) {
"nuevo_mensaje" -> mostrarNotificacionMensaje(message)
"nueva_oferta" -> mostrarNotificacionOferta(message)
"sync_datos" -> sincronizarEnBackground(message) // sin notificación visible
else -> mostrarNotificacionGenerica(message)
}
}
private fun mostrarNotificacionMensaje(message: RemoteMessage) {
val titulo = message.notification?.title
?: message.data["titulo"]
?: "Nuevo mensaje"
val cuerpo = message.notification?.body
?: message.data["cuerpo"]
?: ""
val chatId = message.data["chat_id"]
// Intent que se abre al tocar la notificación
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("deep_link", "miapp://chat/$chatId")
}
val pendingIntent = PendingIntent.getActivity(
this, chatId.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_MENSAJES)
.setContentTitle(titulo)
.setContentText(cuerpo)
.setSmallIcon(R.drawable.ic_notification)
.setAutoCancel(true) // se descarta al tocarla
.setContentIntent(pendingIntent)
// Prioridad alta para que aparezca como heads-up notification
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Vibración y sonido del canal (configurado en el canal)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.build()
val notificationManager = getSystemService(NotificationManager::class.java)
// Usar chatId como notificationId agrupa mensajes del mismo chat
notificationManager.notify(chatId?.toIntOrNull() ?: 0, notification)
}
private fun sincronizarEnBackground(message: RemoteMessage) {
// No mostrar notificación — solo lanzar un WorkManager job
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setInputData(workDataOf("source" to "fcm"))
.build()
WorkManager.getInstance(applicationContext).enqueue(request)
}
companion object {
const val CHANNEL_MENSAJES = "mensajes"
const val CHANNEL_OFERTAS = "ofertas"
const val CHANNEL_GENERAL = "general"
}
}
Y registrarlo en el Manifest:
<service
android:name=".MiFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
Canales de notificación — obligatorios desde Android 8
Desde Android 8.0 (API 26), todas las notificaciones deben pertenecer a un canal. Los canales le dan al usuario control granular: puede silenciar "Ofertas" sin silenciar "Mensajes". Si no creás los canales, las notificaciones no se muestran en Android 8+.
// Crear canales — en Application.onCreate() o en MainActivity
fun crearCanalesDeNotificacion(context: Context) {
val notificationManager = context.getSystemService(NotificationManager::class.java)
// Canal para mensajes — alta importancia, sonido + vibración
val canalMensajes = NotificationChannel(
"mensajes",
"Mensajes", // nombre visible para el usuario en Configuración
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Mensajes nuevos de tus contactos"
enableVibration(true)
enableLights(true)
lightColor = Color.BLUE
// setSound(Uri, AudioAttributes) para sonido custom
}
// Canal para ofertas — importancia baja, sin sonido
val canalOfertas = NotificationChannel(
"ofertas",
"Ofertas y promociones",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Descuentos y promociones especiales"
setShowBadge(false) // no mostrar badge en el ícono de la app
}
// Canal general — importancia default
val canalGeneral = NotificationChannel(
"general",
"Notificaciones generales",
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannels(
listOf(canalMensajes, canalOfertas, canalGeneral)
)
}
// IMPORTANTE: los canales solo se crean la primera vez
// Después de la primera creación, las propiedades que el usuario puede cambiar
// (sonido, vibración, importancia) NO se sobrescriben desde el código
// El usuario tiene el control final
No podés cambiar la importancia del canal después de crearloUna vez que un canal es creado, solo el usuario puede cambiar su importancia desde Configuración del sistema. Si querés cambiar la importancia, tenés que crear un canal nuevo con un ID diferente y eliminar el viejo. Elegí la importancia correcta desde el principio.
Si tu backend manda mensajes con el campo android_channel_id, Android los enruta al canal correspondiente automáticamente cuando la app está en background.
Deep links desde notificaciones
El patrón más común: el usuario toca la notificación y la app navega directamente a la pantalla relevante.
// En MainActivity — manejar el Intent de la notificación
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
manejarIntentDeNotificacion(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// onNewIntent se llama cuando la Activity ya existe (FLAG_SINGLE_TOP)
manejarIntentDeNotificacion(intent)
}
private fun manejarIntentDeNotificacion(intent: Intent) {
// Opción 1: extras directos
val chatId = intent.getStringExtra("chat_id")
if (chatId != null) {
navController.navigate(
MainNavDirections.actionToChat(chatId)
)
return
}
// Opción 2: deep link URI
val deepLink = intent.getStringExtra("deep_link")
if (deepLink != null) {
navController.navigate(Uri.parse(deepLink))
}
}
}
// En el PendingIntent de la notificación — agregar los datos:
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("chat_id", chatId)
// O con deep link:
// putExtra("deep_link", "miapp://chat/$chatId")
}
// Alternativa más limpia con Navigation Component deep links:
val deepLinkIntent = NavDeepLinkBuilder(this)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.chatFragment)
.setArguments(bundleOf("chatId" to chatId))
.createPendingIntent()
Topics — pushes sin manejar tokens individuales
Los topics permiten suscribir dispositivos a grupos temáticos y mandar pushes a todos los suscriptores sin conocer sus tokens individuales. Ideal para notificaciones de broadcast (breaking news, actualizaciones de la app, mantenimiento programado).
// Suscribirse a un topic
FirebaseMessaging.getInstance().subscribeToTopic("noticias_android")
.addOnCompleteListener { task ->
if (task.isSuccessful) Log.d("FCM", "Suscripto a noticias_android")
}
// Desuscribirse
FirebaseMessaging.getInstance().unsubscribeFromTopic("noticias_android")
.addOnCompleteListener { task ->
if (task.isSuccessful) Log.d("FCM", "Desuscripto de noticias_android")
}
// Casos de uso típicos:
// - Todos los usuarios: topic "all_users"
// - Por plan: topic "plan_premium", "plan_free"
// - Por región: topic "region_ar", "region_mx"
// - Por categoría de interés: topic "ofertas_tecnologia", "ofertas_ropa"
// Limitaciones:
// - Un dispositivo puede estar suscripto a máx 2000 topics
// - Los mensajes a topics pueden tardar hasta 30 segundos en entregarse
// - Para mensajes urgentes/personalizados, usar tokens individuales
Cómo probar sin backend — las cuatro formas
Esta es la sección que más vale cuando estás desarrollando. No necesitás un backend completo para testear pushes.
1. Firebase Console — la más fácil
Firebase Console → Messaging → Crear primera campaña → Notificación. Podés mandarla a un token específico (el que imprimís en Logcat con Log.d("FCM", token)), a un topic, o a toda la app.
Limitación: solo soporta notification messages básicos desde la UI. Para data messages o payloads complejos, usá las siguientes opciones.
2. FCM REST API con curl — la más flexible
# Primero: obtener el token de tu dispositivo desde Logcat
# Filtrar por tag "FCM" en Android Studio
# Obtener el Server Key desde Firebase Console:
# Configuración del proyecto → Cloud Messaging → Server key
# Mandar una notification message:
curl -X POST \
https://fcm.googleapis.com/fcm/send \
-H "Authorization: key=TU_SERVER_KEY_ACÁ" \
-H "Content-Type: application/json" \
-d '{
"to": "TOKEN_DEL_DISPOSITIVO",
"notification": {
"title": "Test push",
"body": "Esto es una prueba desde curl"
}
}'
# Mandar un data message (para testear onMessageReceived):
curl -X POST \
https://fcm.googleapis.com/fcm/send \
-H "Authorization: key=TU_SERVER_KEY_ACÁ" \
-H "Content-Type: application/json" \
-d '{
"to": "TOKEN_DEL_DISPOSITIVO",
"data": {
"tipo": "nuevo_mensaje",
"chat_id": "42",
"titulo": "Carlos",
"cuerpo": "Hola desde curl!"
}
}'
# Mandar a un topic:
curl -X POST \
https://fcm.googleapis.com/fcm/send \
-H "Authorization: key=TU_SERVER_KEY_ACÁ" \
-H "Content-Type: application/json" \
-d '{
"to": "/topics/noticias_android",
"notification": {
"title": "Noticia de Android",
"body": "Nueva versión de Kotlin disponible"
}
}'
# Respuesta exitosa:
# {"multicast_id":123,"success":1,"failure":0,"canonical_ids":0,"results":[{"message_id":"0:1234"}]}
# Error de token inválido:
# {"multicast_id":123,"success":0,"failure":1,"canonical_ids":0,"results":[{"error":"InvalidRegistration"}]}
3. Firebase Admin SDK con un script Node.js — para datos complejos
# Instalar Firebase Admin SDK
npm install firebase-admin
# script.js — mandar pushes programáticamente
// script.js (Node.js)
const admin = require('firebase-admin');
const serviceAccount = require('./serviceAccountKey.json'); // descargar desde Firebase Console
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
const token = 'TOKEN_DEL_DISPOSITIVO';
admin.messaging().send({
token: token,
notification: {
title: 'Test desde Node.js',
body: 'Con el Admin SDK tenés más control'
},
data: {
tipo: 'nuevo_mensaje',
chat_id: '99',
prioridad: 'alta'
},
android: {
priority: 'high', // para que llegue en Doze
notification: {
channelId: 'mensajes', // canal de notificación
sound: 'default'
}
}
}).then(response => {
console.log('Push enviado:', response);
}).catch(error => {
console.error('Error:', error);
});
node script.js
4. adb para forzar estados y testear comportamientos
# Testear con la app en FOREGROUND:
# Simplemente tener la app abierta y mandar el push desde curl o la consola
# Testear con la app en BACKGROUND (usuario presionó Home):
# 1. Presionar Home en el emulador/dispositivo
# 2. Mandar el push — debería aparecer en la barra de notificaciones
# Testear con PROCESO MUERTO (la app no está en memoria):
# 1. Forzar el cierre desde Configuración → Apps → Tu app → Forzar detención
# 2. O desde adb:
adb shell am force-stop ar.pensa.miapp
# 3. Mandar el push — debería llegar y aparecer en la barra
# Ver el token FCM actual en Logcat:
adb logcat -s FCM # filtrar solo logs con tag "FCM"
# Verificar que el servicio FCM está registrado:
adb shell dumpsys package ar.pensa.miapp | grep -i firebase
# Simular condiciones de red para testear comportamiento offline:
adb shell svc wifi disable
# Mandar push — FCM lo encola
adb shell svc wifi enable
# El push debería llegar al reconectarse
# Limpiar todas las notificaciones (para empezar de cero):
adb shell service call notification 1
Guardar el token fácilmente durante desarrolloEn vez de copiar el token desde Logcat cada vez, podés agregar un botón de debug que lo copia al portapapeles: ClipboardManager.setPrimaryClip(ClipData.newPlainText("FCM Token", token)). Solo en el build debug, obviamente.
Permiso de notificaciones en Android 13+
Desde Android 13 (API 33), mostrar notificaciones requiere que el usuario conceda el permiso POST_NOTIFICATIONS. Sin este permiso, las notificaciones no aparecen — ni siquiera las de FCM.
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
// Pedir el permiso en el momento correcto — no al abrir la app
// El mejor momento: cuando el usuario activa algo que requiere notificaciones
// (ej: activa "recibir alertas de mensajes" en la configuración)
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// El usuario concedió el permiso
activarNotificaciones()
} else {
// El usuario rechazó — mostrar explicación, no pedir de nuevo inmediatamente
mostrarExplicacionDeValor()
}
}
fun pedirPermisoNotificaciones() {
when {
// Android < 13: no necesita permiso
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU -> {
activarNotificaciones()
}
// Ya tiene el permiso
ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
activarNotificaciones()
}
// Mostrar rationale si ya rechazó una vez
shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
mostrarDialogoExplicativo {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
// Pedir el permiso directamente
else -> {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
El momento de pedir el permiso importa muchoGoogle recomienda no pedir permisos al inicio de la app. El usuario no sabe todavía si quiere notificaciones de algo que no probó. El mejor momento es cuando el usuario realiza una acción que claramente se beneficia de notificaciones: activar recordatorios, suscribirse a alertas, o configurar preferencias de mensajes.