Saltar al contenido
K
El Pokémon del díaPaso a paso30 min Intermedio

Notificaciones en Android: el canal y el aviso

Al terminar: Declarar el permiso, crear el canal y publicar una notificación que abre la ficha.

Ya sabes qué Pokémon toca hoy. Falta avisar, y avisar en Android es donde más cosas fallan en silencio: sin que la app reviente, sin que salga nada en el registro y sin que nadie te diga por qué no se ve la notificación.

Esta lección y la siguiente montan el aviso entero. Aquí se construye la notificación: el permiso en el manifiesto, el canal, el icono y el aviso con su enlace. Se detiene en tres trampas: dos fallan en silencio (el canal y el icono) y una revienta (la bandera del PendingIntent). La lección siguiente, El permiso de notificaciones en Android, pide el permiso, lee sus tres estados y pone la fila «Pokémon del día» en Ajustes, que es lo que te deja ver el aviso. Al final de las dos, tocar la notificación abre la ficha por el mismo camino que un enlace de fuera, que es lo que evita tener dos formas de llegar a una pantalla.

androidApp/src/main
AndroidManifest.xmlse modifica: POST_NOTIFICATIONS
res/drawable
ic_notificacion.xmlse crea: monocromo
kotlin/com/sebastiannarvaez/pokedex/android
daily/Notificaciones.ktse crea: canal y aviso
PokedexApplication.ktse modifica: crea el canal
Dos ficheros nuevos y dos que se modifican, todos en androidApp. Nada de esto está en el núcleo. Vista Project.

No se instala ninguna librería: NotificationCompat forma parte de androidx.core, que androidApp ya trae desde la plantilla del proyecto.

Paso 1 · Declarar el permiso

Abre androidApp/src/main/AndroidManifest.xml. Debajo de la línea de INTERNET (y antes de <application) añade:

androidApp/src/main/AndroidManifest.xml: líneas nuevas
    <!-- Desde Android 13 las notificaciones son un permiso en tiempo de ejecución.
         Antes de Android 13, basta con declararlo aquí: el sistema lo concede solo. -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Declarar no basta: sin esta línea el diálogo nunca aparece, y con ella todavía hay que pedirlo mientras corre la app (lo harás en la lección siguiente). El porqué está en Permisos.

Paso 2 · El canal, o no se ve nada

El archivo. Notificaciones.kt reúne todo lo que construye avisos: el canal (donde se agrupan) y la notificación del día.

Créalo. Clic derecho sobre la carpeta androidApp → New → File y la ruta desde la carpeta del módulo (el método de La lista en Android):

src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/Notificaciones.kt

Plan B, si ese menú no te lo ofrece: en la Terminal, en la carpeta de tu proyecto, mkdir -p crea las carpetas que falten y touch el fichero vacío:

mkdir -p androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily
touch androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/Notificaciones.kt

Primero la línea package, los imports de todo el fichero y la constante del canal:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/Notificaciones.kt: cabecera
package com.sebastiannarvaez.pokedex.android.daily
 
import android.app.NotificationChannel        // el canal
import android.app.NotificationManager        // para su importancia
import android.app.PendingIntent              // el permiso para lanzar un Intent más tarde
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build                        // para saber en qué versión de Android corre la app
import androidx.core.app.NotificationCompat          // construye la notificación
import androidx.core.app.NotificationManagerCompat   // la publica
import com.sebastiannarvaez.pokedex.android.R        // los recursos de tu app (el icono)
import com.sebastiannarvaez.pokedex.feature.daily.PokemonOfTheDay
import com.sebastiannarvaez.pokedex.navigation.Destination
import com.sebastiannarvaez.pokedex.navigation.toDeepLink
import kotlinx.datetime.LocalDate
 
// El identificador del canal: lo usa quien crea el canal y quien publica en él.
const val CANAL_DIARIO = "pokemon_del_dia"

R es una clase que Android Studio genera con un número por cada recurso de res/ (iconos, textos, colores): R.drawable.ic_notificacion será el icono del paso 3. Vive en el paquete que androidApp declara como namespace. Para saber el tuyo, abre androidApp/build.gradle.kts, busca la línea namespace = "…" y copia ese valor: el import de R lleva ese paquete seguido de .R. Con el proyecto del curso, el valor es com.sebastiannarvaez.pokedex.android, que es el que ves arriba.

Ahora la función que crea el canal. Antes, tres cosas que usa:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/Notificaciones.kt: el canal
fun crearCanalDiario(context: Context) {
    // Antes de Android 8 (nivel 26) no hay canales: no hay nada que crear.
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
 
    val canal = NotificationChannel(
        CANAL_DIARIO,
        "Pokémon del día",          // lo ve el usuario en los ajustes del sistema
        NotificationManager.IMPORTANCE_DEFAULT,   // cuánto molesta: ver el aviso de abajo
    ).apply {
        description = "Un Pokémon distinto cada día"
    }
 
    NotificationManagerCompat.from(context).createNotificationChannel(canal)
}

La misma página explica por qué el canal se crea en cada arranque sin comprobar nada: «Recrear un canal de notificaciones existente con sus valores originales no hace nada, así que es seguro llamar a este código al iniciar una app.»

Por eso se crea al arrancar y no antes de cada aviso. Abre PokedexApplication.kt (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/PokedexApplication.kt, el que creaste en Arrancar Koin desde Android y desde iOS) y haz dos cambios pequeños. El resto del fichero, con su comentario de arriba y todo lo que tenga, no se toca. Primero, entre los import, añade uno:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/PokedexApplication.kt: import nuevo
import com.sebastiannarvaez.pokedex.android.daily.crearCanalDiario

Y segundo, dentro de onCreate, como última línea (debajo de la que arranca Koin):

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/PokedexApplication.kt: onCreate, línea nueva al final
        // Crear un canal que ya existe no hace nada, así que se puede llamar
        // en cada arranque sin comprobar si ya estaba.
        crearCanalDiario(this)

Paso 3 · El icono tiene que ser monocromo

El archivo. ic_notificacion.xml es el icono pequeño de la notificación: una Poké Ball dibujada como vector (formas descritas con números, que se ven nítidas a cualquier tamaño). Android lo pinta como una silueta: lo que cuenta es dónde hay dibujo y dónde no, no el color.

Créalo como el anterior: clic derecho sobre androidApp → New → File y esta ruta (Android Studio crea la carpeta drawable si no existe):

src/main/res/drawable/ic_notificacion.xml

Plan B, desde la carpeta del proyecto:

mkdir -p androidApp/src/main/res/drawable
touch androidApp/src/main/res/drawable/ic_notificacion.xml

Aparece en androidApp/src/main/res/drawable/. Escríbelo entero:

androidApp/src/main/res/drawable/ic_notificacion.xml
<!-- Un vector: dibujo descrito con números. Todo en blanco sobre transparente. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="24"
    android:viewportHeight="24"
    android:tint="#FFFFFF">
    <!-- pathData es el contorno de la Poké Ball (trazos M, C, L, z) -->
    <path
        android:fillColor="#FFFFFF"
        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-3.72,0 -6.85,-2.56 -7.74,-6h3.9c0.45,1.72 2.01,3 3.84,3s3.39,-1.28 3.84,-3h3.9c-0.89,3.44 -4.02,6 -7.74,6zM10,12c0,-1.1 0.9,-2 2,-2s2,0.9 2,2 -0.9,2 -2,2 -2,-0.9 -2,-2zM16.26,10c-0.45,-1.72 -2.01,-3 -3.84,-3 -0.14,0 -0.28,0.01 -0.42,0.02 -1.83,0 -3.39,1.28 -3.84,2.98h-3.9C5.15,6.56 8.28,4 12,4s6.85,2.56 7.74,6h-3.48z"/>
</vector>

Cuatro palabras de ese fichero, sin más: xmlns:android solo dice en qué «diccionario» se buscan los nombres que empiezan por android:; dp es una unidad de tamaño que se ve igual en todas las pantallas; el viewport es el lienzo imaginario de 24 × 24 sobre el que se dibuja (el pathData usa esas coordenadas); y tint tiñe todo el dibujo de blanco.

El pathData no se escribe a mano: lo exporta un programa de dibujo o lo da Android Studio (New → Vector Asset). Aquí lo copias tal cual. Para ver el dibujo, pulsa Split arriba a la derecha del editor: a un lado el texto y al otro la Poké Ball.

Paso 4 · La notificación, y la bandera obligatoria

Un PendingIntent es un Intent con permiso para ejecutarse más tarde y en nombre de tu app: no lo lanzas tú, lo lanza el sistema cuando el usuario toca la notificación. Por eso hay que decir, al crearlo, si el sistema puede modificarlo (mutable) o no. Ya lo viste en Notificaciones, con el ejemplo de la tienda de ofertas.

Al final de Notificaciones.kt añade la función que construye y publica el aviso. Primero el PendingIntent:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/Notificaciones.kt: el aviso, primera parte
fun mostrarPokemonDelDia(context: Context, fecha: LocalDate) {
    val id = PokemonOfTheDay.idParaFecha(fecha)          // la regla del núcleo
    val enlace = Destination.Detalle(id).toDeepLink()    // pokedex://pokemon/<id>
 
    val intent = PendingIntent.getActivity(
        context,
        id,   // requestCode: junto con el Intent, identifica a este PendingIntent
        // setPackage: el enlace solo puede abrirlo ESTA app, no otra que reclame «pokedex://»
        Intent(Intent.ACTION_VIEW, Uri.parse(enlace)).setPackage(context.packageName),
        // IMMUTABLE: desde Android 12 hay que decir si el sistema podrá modificarlo.
        // UPDATE_CURRENT: si ya existe uno igual, se reutiliza en vez de crear otro.
        // `or` junta las dos banderas en un solo valor, como encender dos interruptores a la vez.
        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
    )

Hay dos «identificadores» que no se deben confundir (los viste en Marcador). El requestCode (aquí, el id del Pokémon) y el Intent identifican al PendingIntent: Android considera iguales dos con el mismo requestCode y el mismo destino, así que dar a cada día el suyo evita que el sistema reutilice el de ayer. En cambio ID_NOTIFICACION (lo escribes al final del fichero, un poco más abajo) identifica a la notificación en la barra: como es fijo, la de hoy sustituye a la de ayer si ayer no la tocaste, y no se acumulan siete.

Fíjate en que el destino no es una pantalla: es el mismo enlace profundo que funciona desde fuera. Y después, la notificación en sí y su publicación:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/Notificaciones.kt: el aviso, segunda parte
    val notificacion = NotificationCompat.Builder(context, CANAL_DIARIO)
        .setSmallIcon(R.drawable.ic_notificacion)     // la silueta del paso 3
        .setContentTitle("El Pokémon de hoy")
        .setContentText("Descubre quién es el número $id")
        .setContentIntent(intent)                      // qué pasa al tocarla
        .setAutoCancel(true)                           // desaparece al tocarla
        .build()
 
    // Sin permiso, notify() no lanza ninguna excepción: descarta el aviso sin decir
    // nada. Este if es la comprobación que Android Studio exige antes de notify
    // (si no, subraya la llamada con un aviso, «MissingPermission»).
    if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
        NotificationManagerCompat.from(context).notify(ID_NOTIFICACION, notificacion)
    }
}

La función termina ahí. Ahora, al final del fichero, fuera de cualquier función, la constante que usa:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/Notificaciones.kt: el número de la notificación
private const val ID_NOTIFICACION = 1   // un número fijo: un aviso nuevo sustituye al anterior

Pruébalo

Todavía nada llama a mostrarPokemonDelDia: la fila que la usa llega en la lección siguiente, así que aún no verás ninguna notificación. Lo que sí puedes comprobar ahora es el canal, que se crea al arrancar. Ejecuta androidApp en un emulador o móvil y, con la app abierta, en la Terminal:

adb shell dumpsys notification | grep pokemon_del_dia

dumpsys imprime el estado interno de un servicio del sistema, aquí el de las notificaciones, y grep se queda con las líneas que nombran tu canal. Debe salir al menos una línea con pokemon_del_dia. Si no sale nada, repasa que crearCanalDiario(this) está en onCreate y que el package de PokedexApplication.kt es el tuyo.

Punto de control

  • En androidApp/src/main/AndroidManifest.xml está POST_NOTIFICATIONS, y existen Notificaciones.kt en androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/ e ic_notificacion.xml en androidApp/src/main/res/drawable/.
  • PokedexApplication.kt llama a crearCanalDiario(this) al final de onCreate.
  • El proyecto compila y la app arranca igual que antes: no cambia nada a la vista.
  • El comando dumpsys de arriba nombra pokemon_del_dia.
  • Esta lección no añade tests: si lanzas las tres tareas sin filtro, como en la lección anterior, los informes siguen dando 80 en la JVM, 44 en Android host y 47 en iOS.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.La notificación no aparece y no hay ningún error en la consola. ¿Cuál es la primera sospecha?

  2. 2.¿Por qué el canal se crea en cada arranque sin comprobar si ya existía?

  3. 3.La notificación sale con una forma lisa (un círculo o un aro) en vez de la Poké Ball. ¿Qué pasó?

  4. 4.¿Por qué el PendingIntent lleva FLAG_IMMUTABLE?

  5. 5.Publicas la notificación con el canal en IMPORTANCE_DEFAULT y no sale nada encima de la app. ¿Falla algo?

  6. 6.¿Para qué sirve if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return al principio de crearCanalDiario?

Faltan 6 de 6