Acciones de notificación en Android bien hechas: marcar una dosis como tomada sin abrir la app
Una guía práctica de los botones de acción de notificación en Android — PendingIntent, la restricción del trampolín de actividad, goAsync() y actualizar el estado de Room con seguridad.
Si un recordatorio te obliga a abrir la app para cerrarlo, has añadido un paso que nadie pidió. Todo el sentido de una notificación es que sea la interfaz misma — el usuario reacciona a ella y sigue con lo suyo. OldSchool, un recordatorio de medicación, depende por completo de esto: los botones de pantalla de bloqueo Tomada, Posponer y Omitir tienen que funcionar al instante, actualizar el dato correcto, y nunca lanzar una Activity que el usuario no pidió ver. Así es como está construido, y los dos o tres puntos donde Android lo complica más de lo necesario.
El problema de “simplemente abre la app”
La versión ingenua de una notificación de recordatorio es un solo toque que abre la app en una pantalla donde se pulsa un botón. Eso son dos acciones extra para algo que debería ser una sola. También significa arrancar una Activity en frío cada vez — en un teléfono de gama media con una docena de otras apps en memoria, eso es un retraso visible entre “vi el recordatorio” y “está marcado como hecho”.
La solución es NotificationCompat.Action: botones adjuntos directamente a la notificación que se disparan sin llevar nunca una Activity al primer plano.
val takenIntent = Intent(context, DoseActionReceiver::class.java).apply {
action = ACTION_MARK_TAKEN
putExtra(EXTRA_DOSE_ID, doseId)
}
val takenPendingIntent = PendingIntent.getBroadcast(
context,
doseId.hashCode(),
takenIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle("Metformin — 500mg")
.addAction(R.drawable.ic_check, "Taken", takenPendingIntent)
.addAction(R.drawable.ic_snooze, "Snooze", snoozePendingIntent)
.addAction(R.drawable.ic_skip, "Skip", skipPendingIntent)
.build()
Tres botones, tres PendingIntent, cada uno apuntando a un BroadcastReceiver en lugar de una Activity. Esa distinción es todo el truco.
Por qué un receiver y no una Activity
Es tentador dirigir la acción a través de una Activity — ya tienes una, ya habla con tu base de datos. No lo hagas. Desde Android 12, lanzar una Activity directamente desde una acción de notificación (en lugar de desde el destino táctil principal de la notificación) está restringido como un “trampolín de actividad”, e incluso donde todavía funciona significa un destello de interfaz no deseado, una entrada en el selector de tareas y una respuesta más lenta. Un BroadcastReceiver no hace nada de eso — se ejecuta, hace su trabajo, y no ocurre nada visible salvo la actualización de la notificación.
FLAG_IMMUTABLE también importa: desde Android 12, los PendingIntent mutables están bloqueados por defecto para todo lo que no requiera explícitamente FLAG_MUTABLE (como un RemoteInput). Un botón de acción simple no necesita mutabilidad — márcalo como inmutable y sigue adelante.
Hacer trabajo real en un receiver: goAsync()
El problema con BroadcastReceiver.onReceive() es que se espera que retorne casi al instante — unos pocos milisegundos, no el tiempo que tarda una escritura en Room en segundo plano. Si retornas antes de que tu escritura termine, el sistema puede matar el proceso a mitad de la escritura en algunas capas de personalización de fabricantes.
goAsync() compra ese tiempo extra correctamente:
class DoseActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val doseId = intent.getLongExtra(EXTRA_DOSE_ID, -1)
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
doseDao.markTaken(doseId, timestamp = System.currentTimeMillis())
NotificationManagerCompat.from(context).cancel(doseId.toInt())
} finally {
pendingResult.finish()
}
}
}
}
goAsync() te da alrededor de 10 segundos de vida extendida — suficiente para una escritura en Room, no suficiente para justificar un servicio en primer plano. Llama siempre a pendingResult.finish() dentro de un bloque finally; olvidarlo en una ruta de excepción deja al sistema reteniendo un wakelock que no puede liberar, y eventualmente marcará tu receiver como mal comportado.
Mantener honestas la notificación y la base de datos
Marcar una dosis como “tomada” no es solo una fila de base de datos — la notificación misma tiene que desaparecer o cambiar, o el usuario la tocará de nuevo y se preguntará por qué no pasa nada. Llamar a NotificationManagerCompat.cancel() después de la escritura es el mínimo; para Posponer, reprogramo mediante la misma ruta AlarmManager/WorkManager que usó el recordatorio original (ver el artículo sobre WorkManager y alarmas exactas para la mitad de programación de todo esto) en lugar de inventar una segunda tubería de notificación. Un recordatorio, una ruta de código, sin importar si se disparó la primera vez o la tercera.
El otro error fácil es actualizar un estado en memoria que nada observa. Si la vista del día está respaldada por un Flow de Room, la escritura del receiver es suficiente — la interfaz se recompone la próxima vez que sea visible, con cero código de sincronización explícito. Si no lo está, obtendrás datos obsoletos en cuanto el usuario vuelva a la app, y acabarás depurando una condición de carrera que en realidad es solo un observador ausente.
Lo que esto te da
La diferencia medible no es dramática sobre el papel — una pantalla menos, un toque menos — pero cambia lo que se siente un recordatorio. Una notificación que se resuelve sola en menos de un segundo, justo donde apareció, se lee como respetuosa de los dos segundos que le diste. Una que exige un arranque en frío de la app se lee como fricción, y la fricción es lo que convierte un recordatorio en algo que descartas por costumbre en lugar de algo que usas.
Si estás construyendo cualquier tipo de notificación de “actúa ahora” — un recordatorio, un empujón de tarea pendiente, una confirmación de entrega — el patrón se sostiene sin importar el dominio: dirige las acciones a través de un BroadcastReceiver, extiende tu ventana con goAsync(), escribe a través de tu capa de datos existente, y deja que una interfaz reactiva recoja el cambio en lugar de empujarlo manualmente. Es un poco de plomería para una función que los usuarios solo notan cuando falta.
// Lecturas relacionadas
Más del diario
Recordatorios fiables en Android en 2026: WorkManager, alarmas exactas y las nuevas reglas de batería
Cómo publicar en 2026 recordatorios en Android que de verdad se disparen — WorkManager vs AlarmManager, SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS y las rarezas de los fabricantes que todavía muerden.
Cómo construir una rutina de medicación a la que de verdad te apegues
Una guía práctica sobre la adherencia a la medicación: por qué la gente se salta dosis, cómo funciona realmente el momento de los recordatorios, y cómo construir una rutina que sobreviva a una mala semana.
Una rutina de hidratación que no depende de la fuerza de voluntad
La mayoría de los consejos sobre hidratación se reducen a 'bebe más agua' y confían en que la fuerza de voluntad haga el resto. Aquí una rutina construida en torno a señales y recordatorios suaves, no a la disciplina.