Notificaciones
Al terminar: Mostrar un aviso de gol con un canal de notificaciones, sin pisar los anteriores.
Al terminar esta lección, cada gol del partido en vivo de Marcador aparecerá como una notificación «¡Gol!» en la barra del móvil, y al tocarla se abrirá el detalle de ese gol. Aprenderás de qué piezas se compone una notificación (canal, aviso y PendingIntent), cómo se montan por pasos con un Builder, y por qué dos avisos con el mismo número se pisan.
Importa porque una notificación es la única forma de hablar con el usuario cuando tu app no está en pantalla, y es una de las pocas cosas de Android que se rompen en silencio: sin canal o sin permiso, simplemente no sale nada.
Una notificación tiene tres piezas
Antes del ejemplo, tres ideas que aparecen en el código.
- Un Builder es un montador por pasos.
NotificationCompat.Builder(contexto, canal)crea un montador vacío; cada.setAlgo(…)añade una pieza (título, texto, icono…) y devuelve el mismo montador, por eso se encadenan una tras otra;.build()entrega el objeto terminado. Es como pedir un bocadillo: vas diciendo «con tomate, con queso…» y al final te lo preparan. - El
PendingIntenttiene tres partes que distinguir.- Las banderas (
flags):FLAG_IMMUTABLE(obligatorio desde el nivel de API 31: impide que otra app cambie tu intent antes de que el sistema lo lance) yFLAG_UPDATE_CURRENT(si ya existe uno igual, actualiza sus extras). Se pasan las dos a la vez conor, que junta las dos banderas en un solo valor. - El
requestCode, un número: Android considera «iguales» dosPendingIntentcon el mismorequestCodey el mismo destino (los extras no cuentan para decidirlo). - La consecuencia: si todos usaran el mismo
requestCode, cada notificación nueva pisaría los extras de las anteriores. Por eso cada gol usa su minuto. Un ejemplo de lo que verías: con el mismorequestCodeen dos avisos, el de las zapatillas y el de la mochila, al tocar el primero se abriría la pantalla con el texto del segundo.
- Las banderas (
- El número de
notifytambién distingue.notify(id, notificacion)publica el aviso con un número de identidad; si publicas otro con el mismoid, el nuevo sustituye al anterior en lugar de añadirse.
Y dos detalles de nombres: android.R.drawable.ic_dialog_info es un icono del propio sistema que sirve de icono pequeño (R es la clase que Android genera con los recursos de una app, imágenes y textos; android.R son los del sistema); y getSystemService(NotificationManager::class.java) pide al sistema su gestor de notificaciones, que es quien crea canales y publica.
Un ejemplo con otra app: una tienda que avisa de ofertas
Una app de tienda inventada: canal ofertas y un aviso por producto. No es el código de Marcador.
const val CANAL_OFERTAS = "ofertas" // id del canal; se usa al crearlo y al notificar
fun crearCanalDeOfertas(contexto: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return // antes de Android 8 (nivel 26) no hay canales
val canal = NotificationChannel(CANAL_OFERTAS, "Ofertas", NotificationManager.IMPORTANCE_DEFAULT) // id, nombre visible, importancia
contexto.getSystemService(NotificationManager::class.java) // pide al sistema su gestor de notificaciones
.createNotificationChannel(canal) // registrarlo dos veces con el mismo id no hace nada
}
fun avisarOferta(contexto: Context, producto: String, codigo: Int) {
val alTocar = PendingIntent.getActivity( // un Intent que el sistema lanzará en tu nombre
contexto,
codigo, // requestCode: distinto por aviso para no pisar los extras
Intent(contexto, OfertaActivity::class.java).putExtra("producto", producto),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, // or une las dos banderas
)
val aviso = NotificationCompat.Builder(contexto, CANAL_OFERTAS) // el montador: la notificación irá en ese canal
.setSmallIcon(android.R.drawable.ic_dialog_info) // icono pequeño, uno del sistema
.setContentTitle("Oferta") // título
.setContentText("$producto, a mitad de precio") // texto
.setContentIntent(alTocar) // qué pasa al tocarla
.setAutoCancel(true) // desaparece al tocarla
.build() // entrega el objeto terminado
contexto.getSystemService(NotificationManager::class.java)
.notify(codigo, aviso) // la publica; con el mismo id, sustituye a la anterior
}Lo que se ve en pantalla: llamar a avisarOferta(contexto, "zapatillas", 1) hace aparecer en la barra del móvil un aviso «Oferta · zapatillas, a mitad de precio»; al tocarlo se abre OfertaActivity y el aviso se va. Si luego llamas a avisarOferta(contexto, "mochila", 2), hay dos avisos a la vez, uno por producto. Si llamaras las dos veces con el número 1, verías solo un aviso, el último, como dice el comentario de notify. Y si usaras siempre el mismo codigo solo en el PendingIntent (pero distinto en notify), verías dos avisos, y al tocar el de las zapatillas se abriría con la mochila.
Ejercicio · tres avisos, dos en la barra
Reto · Ejercicio: avisos de oferta
Practicas el canal, el Builder y el número de notify con la API real de Android, con el ejemplo de la tienda como modelo.
Dónde. En la vista Android, clic derecho sobre com.sebastiannarvaez.marcador → New → Package, y escribe .ejercicios.avisos_de_oferta (la vista Android pega ese nombre al paquete). Después, clic derecho sobre la carpeta nueva → New → Kotlin Class/File, elige File y llámalo AvisosDeOferta. La carpeta ejercicios es la que creaste en Rotación y muerte del proceso. Esa carpeta se queda: los ejercicios no se borran.
Requisito. Haber concedido el permiso con el botón Activar avisos de gol de la lección anterior. Si no, el ejercicio te lo dirá en Logcat.
Qué hacer. Escribe una función, probarAvisos, que reciba un Context. Si la app no puede notificar, escribe en Logcat, con el tag Ejercicio, «sin permiso: pulsa «Activar avisos de gol» y vuelve a ejecutar» y termina. Si puede, crea un canal «Ofertas» de importancia normal (IMPORTANCE_DEFAULT) y publica tres avisos con el título «Oferta» y el texto «producto, a mitad de precio»: las zapatillas con el número 1, la mochila con el 2 y la gorra otra vez con el 1. Al final escribe «publicados 3 avisos con los números 1, 2 y 1».
Cómo se prueba. Añade en MainActivity.kt, dentro de onCreate, justo debajo de anotar("onCreate …"), esta llamada temporal, con la ruta completa para no necesitar import (this es la pantalla, que también es un Context). Termina en un comentario para que la reconozcas al borrarla:
com.sebastiannarvaez.marcador.ejercicios.avisos_de_oferta.probarAvisos(this) // EJERCICIO: quitar al terminarSi MainActivity sale en rojo, pulsa Option+Enter (Alt+Enter en Windows y Linux) sobre el error y acepta el import. Ejecuta con Run. En Logcat, con tag:Ejercicio, debes ver:
D Ejercicio: publicados 3 avisos con los números 1, 2 y 1Desliza la barra de notificaciones del emulador hacia abajo: hay dos avisos de Marcador, «Oferta · gorra, a mitad de precio» y «Oferta · mochila, a mitad de precio». La gorra sustituyó a las zapatillas porque llevaba el mismo número. Si giras el móvil se vuelven a publicar y siguen siendo dos.
Pistas. puedeNotificar(contexto) ya la tienes en Notificaciones.kt (Permisos); al usarla desde tu fichero, Android Studio la subrayará en rojo: pulsa Option+Enter y acepta el import; del ejemplo de la tienda, NotificationChannel, getSystemService(NotificationManager::class.java), NotificationCompat.Builder y notify(número, aviso). Una pieza que en esta lección llega en el paso, así que aquí va como adelanto: @SuppressLint("MissingPermission") encima de la función que llama a notify, porque el editor no sabe que el permiso ya se comprobó en otra función. Es solo un aviso del editor, no un error de compilación: con o sin él, el código compila.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.avisos_de_oferta
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.sebastiannarvaez.marcador.core.notifications.puedeNotificar
private const val CANAL_OFERTAS = "ofertas"
// Publica tres avisos con los números 1, 2 y 1: en la barra quedan dos.
fun probarAvisos(contexto: Context) {
if (!puedeNotificar(contexto)) {
Log.d("Ejercicio", "sin permiso: pulsa «Activar avisos de gol» y vuelve a ejecutar")
return
}
crearCanalDeOfertas(contexto)
avisar(contexto, 1, "zapatillas")
avisar(contexto, 2, "mochila")
avisar(contexto, 1, "gorra") // mismo número que las zapatillas: las sustituye
Log.d("Ejercicio", "publicados 3 avisos con los números 1, 2 y 1")
}
private fun crearCanalDeOfertas(contexto: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return // antes de Android 8 no hay canales
val canal = NotificationChannel(CANAL_OFERTAS, "Ofertas", NotificationManager.IMPORTANCE_DEFAULT)
contexto.getSystemService(NotificationManager::class.java).createNotificationChannel(canal)
}
// El permiso ya se comprobó en probarAvisos; el editor no lo sabe seguir, por eso se silencia.
@SuppressLint("MissingPermission")
private fun avisar(contexto: Context, numero: Int, producto: String) {
val aviso = NotificationCompat.Builder(contexto, CANAL_OFERTAS)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Oferta")
.setContentText("$producto, a mitad de precio")
.build()
contexto.getSystemService(NotificationManager::class.java).notify(numero, aviso)
}El PendingIntent se deja para el paso a propósito: el ejercicio se centra en el canal, el Builder y el número de notify.
Antes del paso, borra la línea temporal (la que termina en // EJERCICIO: quitar al terminar) y comprueba que MainActivity.kt vuelve a estar limpio: Cmd+F (Ctrl+F en Windows y Linux) con «ejercicios» no debe dar ningún resultado. Si Android Studio añadió un import, bórralo también. El canal «Ofertas» se queda en los ajustes de notificaciones de la app hasta que la desinstales, y no molesta.
Paso · Notificaciones de gol
Objetivo. Añadir a Notificaciones.kt el canal goles y la función que publica un aviso por gol, crear el canal al arrancar la aplicación y recoger los goles del partido en vivo para notificarlos. Al terminar: Empezar partido hace aparecer «¡Gol!» en cuanto se marca el primer gol del guion (en menos de un segundo), y al tocarlo se abre el detalle.
No hay que instalar ninguna librería nueva. NotificationCompat forma parte de androidx.core, la misma que ya usa puedeNotificar. NotificationManager y PendingIntent vienen con el propio Android.
Qué hay que hacer en cada fichero (obligatorio; el código completo está más abajo):
Notificaciones.kt(app → kotlin+java → com.sebastiannarvaez.marcador → core → notifications): sustituir los imports por la lista completa; ponerCANAL_GOLESycrearCanalDeGolesencima depuedeNotificar, ynotificarGoldebajo.MarcadorApplication.kt(app → kotlin+java → com.sebastiannarvaez.marcador, el que creaste con LeakCanary): una línea enonCreate, trassuper.onCreate():crearCanalDeGoles(this), con suimport com.sebastiannarvaez.marcador.core.notifications.crearCanalDeGoles.MainActivity.kt: un bloque enonCreate, justo después deavisosActivados = puedeNotificar(this)y antes degolesQueSobreviven = savedInstanceState…. Un solo import nuevo,import com.sebastiannarvaez.marcador.core.notifications.notificarGol:lifecycleScopeylaunchya están desde El hilo principal y el ANR.
Piezas nuevas de este paso: NotificationChannel, el PendingIntent, el Builder y notify, los del ejemplo de la tienda. Quedan tres:
IMPORTANCE_HIGH: la importancia alta del canal, un escalón por encima de la normal (IMPORTANCE_DEFAULT) del ejercicio (la notificación se anuncia con sonido o con un aviso emergente).@SuppressLint("MissingPermission"):notifyexige el permiso, y el revisor de Android Studio (lint) avisa si no ve la comprobación justo encima. Aquí la comprobación está enpuedeNotificar, que lint no sabe seguir, así que se comprueba a mano y se silencia el aviso de lint. No silencia al compilador: es un aviso del revisor, no un error de compilación.SharedFlowy.collect(de StateFlow y SharedFlow):partidoEnVivo.golesemite cada gol, y solo lo ve quien esté recogiendo en ese momento.
Intenta escribirlo tú, en este orden:
Notificaciones.kt: un canal de importancia alta con el idgolesy el nombre «Goles», creado al arrancar la aplicación (crearCanalDeGoles); y una función que, si la app puede notificar, publica un aviso por gol (notificarGol) con el título «¡Gol!», el texto «Ana (Rayo FC), minuto 12» con los datos de ese gol, y que al tocarlo abre el detalle con el minuto, el jugador y el equipo. Cada gol usa su minuto como número, para que un aviso no pise a otro.- Al crear la pantalla, la recogida de los goles del partido en vivo, que notifica cada uno, también con la app en segundo plano.
Lo que debes ver: al pulsar Empezar partido con el permiso concedido, la notificación del primer gol.
Los títulos de los bloques de abajo son las rutas de los ficheros en tu proyecto.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
En Notificaciones.kt, sustituye los imports de arriba por esta lista completa:
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.sebastiannarvaez.marcador.laboratorio.DetalleActivity
import com.sebastiannarvaez.marcador.torneo.GolLa constante y el canal van encima de puedeNotificar (que se queda como está):
const val CANAL_GOLES = "goles"
// Un CANAL agrupa notificaciones del mismo tipo. Desde Android 8 (API 26) toda
// notificacion pertenece a uno, y es el USUARIO quien decide (en Ajustes) su
// sonido e importancia por canal. Se crea una vez, al arrancar: crearlo de nuevo
// con el mismo id no hace nada. En API 24-25 no existen los canales, de ahi el `if`.
fun crearCanalDeGoles(contexto: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val canal = NotificationChannel(CANAL_GOLES, "Goles", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Avisa cuando se marca un gol"
}
contexto.getSystemService(NotificationManager::class.java).createNotificationChannel(canal)
}Y notificarGol va debajo de puedeNotificar, al final del fichero:
// `notify` haría que la app se cerrara con un error (una `SecurityException`) si falta el permiso, y el revisor (lint) no lo sabe
// seguir a traves de `puedeNotificar`; por eso se comprueba aqui y se silencia el aviso.
@SuppressLint("MissingPermission")
fun notificarGol(contexto: Context, gol: Gol) {
if (!puedeNotificar(contexto)) return
// Un PendingIntent es un Intent que se entrega a OTRA app (el sistema) para
// que lo lance en tu nombre cuando el usuario toque la notificacion.
// FLAG_IMMUTABLE es obligatorio desde la API 31 salvo que necesites mutarlo.
// El requestCode (aqui, el minuto) distingue unos PendingIntent de otros: con el
// mismo codigo y FLAG_UPDATE_CURRENT, todos los goles compartirian el mismo y
// al tocar CUALQUIER notificacion se veria el texto del ultimo gol.
val alTocar = PendingIntent.getActivity(
contexto,
gol.minuto,
Intent(contexto, DetalleActivity::class.java)
.putExtra(DetalleActivity.EXTRA_RESUMEN, "${gol.minuto}' ${gol.jugador.nombre} (${gol.equipo.nombre})"),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notificacion = NotificationCompat.Builder(contexto, CANAL_GOLES)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("¡Gol!")
.setContentText("${gol.jugador.nombre} (${gol.equipo.nombre}), minuto ${gol.minuto}")
.setContentIntent(alTocar)
.setAutoCancel(true)
.build()
// El id distingue notificaciones: con el mismo id, la nueva sustituye a la anterior.
contexto.getSystemService(NotificationManager::class.java).notify(gol.minuto, notificacion)
}En MarcadorApplication.kt, una línea nueva en onCreate, tras super.onCreate(). El resto del fichero no cambia.
crearCanalDeGoles(this)En MainActivity.kt, dentro de onCreate, justo después de avisosActivados = puedeNotificar(this):
// Cada gol del partido en vivo se convierte en una notificacion. `goles` es
// un SharedFlow: solo ve los goles que ocurren MIENTRAS se recoge. Sin
// repeatOnLifecycle a proposito: con la app en segundo plano tambien avisa
// (hasta que la Activity se destruye; para avisar sin pantalla haria falta
// un servicio, que veras mas adelante).
lifecycleScope.launch {
partidoEnVivo.goles.collect { gol -> notificarGol(applicationContext, gol) }
}Por qué la recogida no usa repeatOnLifecycle. A diferencia del marcador en vivo de Lifecycle y repeatOnLifecycle, que se pausa al salir de la pantalla, aquí queremos el aviso aunque la app esté en segundo plano. El límite es que la recogida vive en lifecycleScope: cuando la Activity se destruye, se acaban los avisos. Y al girar la pantalla, la Activity nueva empieza un partido nuevo: el que estaba en curso, que era una propiedad de la Activity antigua, se pierde.
Probarlo
Necesitas el emulador de la lección anterior (Android 13 o superior) con el permiso concedido: si no, toca Activar avisos de gol y elige Allow. Ejecuta la app con Run, con app elegido arriba. Los comandos adb se escriben en la Terminal de Android Studio; hoy usas dumpsys, que imprime el estado de un servicio del sistema (una pieza de Android que trabaja sin pantalla, como el que gestiona las notificaciones), y está en la Chuleta de adb.
- Toca Empezar partido. En menos de un segundo aparece la notificación del primer gol (minuto 12 del guion; el tiempo varía según la máquina).
- Desde la terminal,
adb shell "dumpsys notification | grep -A3 goles"la lista. El símbolo|pasa la salida de un comando agrep, que deja solo las líneas con el textogolesy, por-A3, las 3 siguientes. Las comillas hacen quegrepcorra dentro del móvil, así que no depende de tu terminal. Buscachannel=golesyimportance=4(alta), yandroid.title=String (¡Gol!)unas líneas más abajo. - Desliza hacia abajo desde el borde superior de la pantalla del emulador, para abrir la barra de notificaciones, y toca la notificación: se abre
DetalleActivitycon el texto del gol, algo como12' … (Rayo FC). Esa es la tarea delPendingIntent. Si hay varios goles, cada uno es una notificación distinta (su id es el minuto) y, gracias alrequestCode, al tocar cada una se abre el detalle con su gol, no con el último. - Comprueba lo de «con la app en segundo plano». El partido solo arranca una vez por apertura de la app: si ya lo empezaste, vuelve a lanzar la app con Run. Toca Empezar partido y pulsa enseguida Home (el botón de inicio, que manda la app al fondo sin cerrarla; en el emulador está en la barra de botones o se desliza hacia arriba desde abajo). El partido entero dura unos 5 segundos, porque cada minuto del guion pasa en 60 milisegundos: los goles de los minutos 55 y 80 llegan con la app oculta, y las notificaciones salen igual.
Punto de control
MainActivity.ktno contiene la palabraejercicios(la línea temporal del ejercicio ya está borrada).- Empezar partido (con el permiso concedido) hace aparecer una notificación «¡Gol!» en menos de un segundo.
adb shell "dumpsys notification | grep -A3 goles"la muestra en el canalgoles, y al tocarla se abre el detalle con su gol.- En la Terminal de Android Studio,
./gradlew :app:assembleDebugtermina enBUILD SUCCESSFUL. - Siguen los 137 tests de
:torneo(ábrelos con el informe, como en Bucles y tu primer test): esta lección no toca:torneo.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.Desde Android 8, ¿qué necesita toda notificación y quién controla su sonido e importancia?
2.¿Para qué sirve el
PendingIntentdenotificarGol?3.Dos goles se publican con
notifyusando el mismo id. ¿Qué ve el usuario en la barra de notificaciones?4.¿Qué silencia
@SuppressLint("MissingPermission")sobrenotificarGol?