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

El permiso de notificaciones en Android

Al terminar: Pedir el permiso en Android 13 o posterior, leer sus tres estados y llevar al usuario a los ajustes si dijo que no.

La lección anterior, Notificaciones en Android: el canal y el aviso, dejó escrita la función que publica la notificación, pero nada la llama todavía: ni hay quien pida el permiso, ni un sitio donde tocar para verla. Esta lección lo pone: lee en qué estado está el permiso, lo pide desde Compose, lleva al usuario a los ajustes del sistema si dijo que no, y añade la fila «Pokémon del día» en Ajustes, que además sirve para probarlo todo.

Al terminar, la fila pide el permiso, enseña la notificación de hoy, y tocarla abre la ficha por el mismo camino que un enlace de fuera.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android
daily/PermisoDeNotificaciones.ktse crea: los tres estados
settings/SettingsScreen.ktse modifica: la fila
Un fichero nuevo y uno que se modifica, en androidApp. Vista Project.

Paso 1 · Los tres estados, con lo que Android sabe contestar

El archivo. PermisoDeNotificaciones.kt responde a «¿en qué estado está el permiso?» y a «pídelo». Android no tiene una llamada que diga «sin preguntar / concedido / denegado», así que hay que componerlo.

Créalo en la carpeta daily que ya tienes (clic derecho sobre androidApp → New → File):

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

Plan B, desde la carpeta del proyecto (la carpeta ya existe, pero mkdir -p no se queja):

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

Por qué Android no distingue «nunca preguntado» de «dijo que no»

areNotificationsEnabled() solo contesta «¿están activadas?». El sistema guarda aparte si el usuario ya contestó, pero esta pregunta no lo devuelve; por eso la app trata «desactivadas» como «sin preguntar» y aprende el «no» cuando llega la respuesta del diálogo.

Cabecera y la función que calcula el estado. when { … } sin nada entre los paréntesis es una cadena de condiciones: Kotlin prueba cada línea de arriba abajo y se queda con la primera que sea verdadera. Fíjate en la diferencia de mayúsculas: estadoDelPermiso (minúscula) es la función que escribes aquí, y EstadoDelPermiso (mayúscula) es el tipo del núcleo que devuelve.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/PermisoDeNotificaciones.kt: cabecera y estado
package com.sebastiannarvaez.pokedex.android.daily
 
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult   // el diálogo, desde Compose
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.NotificationManagerCompat
import com.sebastiannarvaez.pokedex.feature.daily.EstadoDelPermiso
 
fun estadoDelPermiso(context: Context): EstadoDelPermiso = when {
    // Antes de Android 13 no hay permiso en tiempo de ejecución: basta con que
    // el usuario no las haya desactivado en los ajustes.
    Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ->    // TIRAMISU = Android 13
        if (NotificationManagerCompat.from(context).areNotificationsEnabled()) {
            EstadoDelPermiso.CONCEDIDO
        } else {
            EstadoDelPermiso.DENEGADO
        }
 
    NotificationManagerCompat.from(context).areNotificationsEnabled() -> EstadoDelPermiso.CONCEDIDO
 
    // Desde Android 13, «desactivadas» puede ser «nunca preguntado» o «dijo que no»,
    // y Android no deja distinguirlo desde aquí. Se trata como sin preguntar.
    else -> EstadoDelPermiso.SIN_PREGUNTAR
}

Tras ella, la que lleva a los ajustes del sistema (lo único que queda tras un «no» definitivo). Hay una diferencia entre versiones: la pantalla de notificaciones de una app existe desde Android 8; en Android 7, la salida es la ficha de la app, donde el usuario las activa. Un if sobre la versión elige la una o la otra. Dos piezas que no has visto: putExtra le añade al Intent un dato suelto (aquí, el nombre del paquete de tu app, para que los ajustes sepan de cuál hablar), y Uri.fromParts("package", …) construye la dirección package: seguida del nombre de tu app, que la ficha de la app necesita.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/PermisoDeNotificaciones.kt: ajustes
/** Abre los ajustes del sistema para que el usuario active las notificaciones de esta app. */
fun abrirAjustesDeNotificaciones(context: Context) {
    val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        // Android 8 o superior: la pantalla de notificaciones de esta app.
        Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
            .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
    } else {
        // Android 7: esa pantalla no existe; se abre la ficha de la app.
        Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))
    }
    // NEW_TASK se añade porque la función recibe un Context cualquiera: si no fuera una
    // Activity, sin esta bandera Android se negaría a abrir la pantalla.
    context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}

El diálogo desde Compose

En Marcador creaste el lanzador del diálogo como una propiedad de la Activity con registerForActivityResult, porque tenía que existir antes de que la pantalla se viera. En Compose no hay Activity a mano: rememberLauncherForActivityResult hace lo mismo dentro de un composable, y remember hace que sea el mismo lanzador en cada recomposición. Un ejemplo ajeno al proyecto, una app de recetas que quiere hacer fotos del plato:

ejemplo mínimo: pedir un permiso desde Compose (con comentarios)
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
 
// Una app de recetas que quiere hacer fotos del plato.
@Composable
fun FotoDelPlato() {
    // Lo que se enseña en pantalla; empieza sin respuesta.
    var respuesta by remember { mutableStateOf("Aún no has contestado") }
 
    // El lanzador se crea al dibujar la pantalla (no dentro del onClick).
    // RequestPermission: «pide un permiso y devuélveme true o false».
    val lanzador = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission(),
    ) { concedido ->                       // llega cuando el usuario toca un botón del diálogo
        respuesta = if (concedido) "Cámara lista" else "Sin cámara"
    }
 
    Column {
        Text(respuesta)
        // El botón solo lanza; la respuesta llega a la lambda de arriba.
        Button(onClick = { lanzador.launch(Manifest.permission.CAMERA) }) {
            Text("Hacer foto")
        }
    }
}

Lo que se vería (la app de recetas tendría que declarar CAMERA en su manifiesto): «Aún no has contestado» y el botón; al tocarlo, el diálogo del sistema; tras Allow, «Cámara lista»; tras Don’t allow, «Sin cámara».

Devolver dos cosas a la vez: Pair y to

estado to pedir crea un par: una caja con dos valores, el primero y el segundo. Sirve para que una función devuelva dos cosas a la vez. Pair<EstadoDelPermiso, () -> Unit> dice qué tipo tiene cada uno: un estado y una función sin parámetros que no devuelve nada. Y val (permiso, pedirPermiso) = … (que verás en el paso 2) desestructura ese par: el primer valor va al primer nombre y el segundo al segundo.

Una convención de Compose: los composables que dibujan algo (Text, Button) se escriben con mayúscula inicial, y los que solo devuelven un dato se escriben en minúscula y con un verbo delante, como remember. Por eso este se llama recordarPermisoDeNotificaciones: no dibuja nada, devuelve datos.

Ahora el composable que junta todo, a continuación de lo anterior en el mismo fichero:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/PermisoDeNotificaciones.kt: el composable
/** Devuelve el estado actual y una función para pedir el permiso. */
@Composable
fun recordarPermisoDeNotificaciones(): Pair<EstadoDelPermiso, () -> Unit> {
    val context = LocalContext.current
    var estado by remember { mutableStateOf(estadoDelPermiso(context)) }
 
    // El lanzador es un composable: solo se puede crear mientras Compose dibuja la
    // pantalla, nunca dentro de un onClick (el compilador lo rechaza). Se crea aquí
    // y el onClick, más abajo, solo lo usa. La lambda recibe la respuesta del diálogo.
    val lanzador = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission(),
    ) { concedido ->
        estado = if (concedido) EstadoDelPermiso.CONCEDIDO else EstadoDelPermiso.DENEGADO
    }
 
    val pedir: () -> Unit = {
        if (estado == EstadoDelPermiso.SIN_PREGUNTAR) {
            lanzador.launch(Manifest.permission.POST_NOTIFICATIONS)    // el diálogo del sistema
        } else {
            // Ya se dijo que no: la app no insiste, lleva a los ajustes.
            abrirAjustesDeNotificaciones(context)
        }
    }
 
    return estado to pedir
}

Esa regla de «no insistir» es una decisión de la app: tras un primer «No permitir», Android aún dejaría preguntar una vez más, y solo la segunda negativa es definitiva. Aquí la app lo trata como definitivo mientras la pantalla está abierta, que es lo más simple. El límite se ve en la prueba 3.

Hay otro límite que verás en la prueba 4: estado se calcula una vez, al entrar en la pantalla. Si el usuario activa las notificaciones en los ajustes del sistema y vuelve a la app, la fila no se entera sola: hay que cerrar la app del todo y abrirla de nuevo.

Paso 2 · La fila en Ajustes, que además sirve para probar

Abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt. Hacen falta ocho imports nuevos (Android Studio los ofrece al colocarte sobre cada nombre en rojo: Option+Enter):

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt: imports nuevos
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.ui.platform.LocalContext
import com.sebastiannarvaez.pokedex.android.daily.mostrarPokemonDelDia
import com.sebastiannarvaez.pokedex.android.daily.recordarPermisoDeNotificaciones
import com.sebastiannarvaez.pokedex.feature.daily.EstadoDelPermiso
import kotlin.time.Clock                        // el reloj del sistema (instante actual)
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime         // de instante a fecha local

Dentro del Column de la pantalla, entre la fila «Ordenar favoritos por número» y la de «Acerca de», añade la fila nueva. Dice la verdad en cada uno de los tres estados:

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt: la fila nueva
            val (permiso, pedirPermiso) = recordarPermisoDeNotificaciones()
            val contexto = LocalContext.current
            Fila(
                titulo = "Pokémon del día",
                detalle = when (permiso) {
                    EstadoDelPermiso.CONCEDIDO -> "Una notificación al día"
                    EstadoDelPermiso.SIN_PREGUNTAR -> "Toca para activar las notificaciones"
                    EstadoDelPermiso.DENEGADO -> "Las notificaciones están desactivadas"
                },
                icono = {
                    Icon(
                        Icons.Default.Notifications,
                        contentDescription = null,
                        tint = if (permiso == EstadoDelPermiso.CONCEDIDO) {
                            MaterialTheme.colorScheme.primary
                        } else {
                            MaterialTheme.colorScheme.onSurfaceVariant
                        },
                    )
                },
                alPulsar = {
                    if (permiso == EstadoDelPermiso.CONCEDIDO) {
                        // Con el permiso dado, tocarla enseña la de hoy al momento:
                        // esperar veinticuatro horas no es una forma de comprobar nada.
                        mostrarPokemonDelDia(contexto, hoy())
                    } else {
                        pedirPermiso()
                    }
                },
            )

Y, al final del fichero, fuera de cualquier función, la frontera de la que hablaba El Pokémon del día en el núcleo: convierte un instante en «qué día es hoy aquí», con la zona horaria del móvil, y se lo da ya resuelto a la regla.

androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/settings/SettingsScreen.kt: hoy()
/** La fecha de hoy en la zona del dispositivo, que es la que ve el usuario. */
private fun hoy() = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date

private significa que solo este fichero la ve. En la lección de trabajo en segundo plano necesitarás la misma conversión en otro fichero y entonces la mudarás a daily; por ahora, aquí.

Pruébalo

Ejecuta androidApp en un emulador con Android 13 o superior (el nivel de API se comprueba como en Permisos). Abre la pestaña Ajustes.

  1. Concederlo. La fila dice «Toca para activar las notificaciones». Tócala: sale el diálogo del sistema. Allow → la fila pasa a «Una notificación al día». Tócala otra vez: arriba a la izquierda, en la barra de estado, aparece el icono de la Poké Ball (con IMPORTANCE_DEFAULT no se asoma ningún aviso sobre la app). Baja la barra de notificaciones: ahí está «El Pokémon de hoy», con «Descubre quién es el número N» debajo y la Poké Ball en silueta. Tócala: se abre la ficha de ese Pokémon, no la lista, y la notificación desaparece.

  2. Denegarlo. En la Terminal, deja el permiso como recién instalado con estas dos órdenes (la segunda borra el recuerdo de que ya contestaste; el applicationId es el de tu app, y pm ya lo viste en Permisos):

    adb shell pm revoke com.sebastiannarvaez.pokedex android.permission.POST_NOTIFICATIONS
    adb shell pm clear-permission-flags com.sebastiannarvaez.pokedex android.permission.POST_NOTIFICATIONS user-set user-fixed

    La primera orden cierra la app. Vuelve a abrirla desde el lanzador de apps del emulador, ve a Ajustes, toca la fila y elige Don’t allow: la fila dice «Las notificaciones están desactivadas». Tócala otra vez: se abre la pantalla de ajustes del sistema de la app, no un diálogo.

  3. El estado que Android no recuerda. Sigue desde la prueba anterior (ya has dicho que no una vez), paso a paso:

    1. Cierra la app del todo: en el emulador, abre la lista de apps recientes (desliza hacia arriba desde el borde de abajo y mantén un momento, o pulsa el botón cuadrado), y desliza hacia arriba la tarjeta de Pokédex. Ábrela de nuevo desde el lanzador de apps y ve a Ajustes.
    2. La fila vuelve a decir «Toca para activar las notificaciones», porque Android solo sabe contestar «¿están activadas?».
    3. Tócala: sale el diálogo una vez más. Di Don’t allow otra vez: ahora el sistema ya no lo enseñará nunca.
    4. Tócala de nuevo: te lleva a los ajustes del sistema (la app recuerda el «no» mientras sigue abierta). Vuelve a la app.
    5. Cierra la app del todo como en el primer paso y ábrela. La fila dice otra vez «Toca para activar las notificaciones».
    6. Tócala una vez: no se ve nada, porque el sistema contesta «no» sin enseñar el diálogo, y la fila pasa a «desactivadas». El toque siguiente es el que lleva a los ajustes.
  4. Provócalo tú: la forma lisa. Para ver la segunda trampa, dale el permiso por los ajustes del sistema que abre la fila (activa las notificaciones de Pokédex) y vuelve a la app. Recuerda el límite del paso 1: la fila no se entera sola, así que cierra la app del todo y ábrela; ahora dice «Una notificación al día». Cambia setSmallIcon(R.drawable.ic_notificacion) por setSmallIcon(R.mipmap.ic_launcher), el icono de la app (mipmap es la carpeta de res/ donde viven los iconos del lanzador, igual que drawable para el resto de imágenes). Ejecuta y toca la fila: en vez de la Poké Ball sale una forma lisa, en Android 15 un aro, y ningún error ni aviso en Logcat. Es opaco de lado a lado, así que su silueta es la forma entera. Devuelve el R.drawable.ic_notificacion al terminar.

Punto de control

  • Existe PermisoDeNotificaciones.kt en androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/daily/.
  • En Ajustes hay una fila «Pokémon del día» con el texto correcto para el estado del permiso.
  • En una instalación nueva sobre Android 13 o superior, tocarla abre el diálogo del sistema.
  • Tras decir que no, el siguiente toque abre los ajustes del sistema, no un diálogo.
  • Con el permiso dado, tocarla muestra la notificación de hoy al instante, con el icono en silueta (la Poké Ball).
  • Tocar la notificación abre la ficha de ese Pokémon, no la lista.
  • Esta lección no añade tests. Lanza las tres tareas sin filtro, como al final de la lección del núcleo, y abre los informes: tienen que seguir dando 80 en la JVM, 44 en Android host y 47 en iOS.
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test

Comprueba lo que entendiste

7 preguntas · necesitas 5 para completar la lección

  1. 1.El usuario acaba de pulsar «No permitir». ¿Qué hace el siguiente toque en «Pokémon del día» en esa misma sesión?

  2. 2.Tras reiniciar la app, la fila dice «Toca para activar» aunque el usuario ya dijo que no. ¿Por qué?

  3. 3.¿Por qué estadoDelPermiso en Android 12 o anterior solo mira areNotificationsEnabled() y nunca devuelve SIN_PREGUNTAR?

  4. 4.¿Por qué rememberLauncherForActivityResult se escribe al dibujar la pantalla y no dentro del onClick del botón?

  5. 5.En val (permiso, pedirPermiso) = recordarPermisoDeNotificaciones(), ¿qué es cada nombre?

  6. 6.El usuario activa las notificaciones en los ajustes del sistema y vuelve a la app, pero la fila sigue diciendo «desactivadas». ¿Por qué?

  7. 7.En Android 7, ¿adónde manda abrirAjustesDeNotificaciones?

Faltan 7 de 7