Denegación y no volver a preguntar
El caso feliz de un permiso ocupa tres líneas; todo lo interesante está en el fracaso. Esta lección diserta sobre la máquina de estados real de una concesión: por qué la plataforma expone tres situaciones distintas a través de una API que solo devuelve dos valores, cómo inferir la denegación permanente sin confundirla con la primera ejecución, qué cambió en Android 11 al eliminar la casilla de no volver a preguntar, cómo construir un catálogo de alternativas que degrade la funcionalidad en lugar de cancelarla, y cómo conducir al usuario hasta Ajustes sin convertir la aplicación en un peaje.
Escribir el camino feliz de un permiso es trivial: se pide, se concede, se ejecuta. Todo el trabajo real de ingeniería está en las ramas que casi nadie escribe, y su ausencia produce una familia de defectos con una firma inconfundible: la pantalla en blanco con un botón que no hace nada, el diálogo que reaparece en bucle hasta que el usuario cierra la aplicación, el mensaje que dice que se necesita un permiso sin explicar cuál ni dónde concederlo. Ninguno de esos comportamientos es fruto de la mala fe; todos son fruto de haber modelado la concesión como un booleano cuando en realidad es una máquina de estados con memoria, con transiciones irreversibles desde dentro del proceso y con una API deliberadamente incompleta que obliga a reconstruir parte del estado por tu cuenta. Aprender a manejar la denegación no es una tarea de cortesía: es la mitad del trabajo, y es la mitad que decide si tu aplicación sigue instalada mañana.
- Modelar la máquina de estados completa de un permiso, incluida la denegación permanente que la API no expone directamente.
- Inferir con corrección esa denegación permanente sin confundirla con la primera solicitud de la vida de la aplicación.
- Diseñar un catálogo de degradaciones que mantenga la aplicación utilizable ante cualquier denegación.
- Conducir al usuario a la pantalla de ajustes correcta y detectar el cambio al volver, sin ciclos ni coacción.
Tres estados detrás de una API que devuelve dos
Desde el punto de vista del usuario, un permiso puede estar en tres situaciones: concedido, denegado con posibilidad de volver a preguntar, y denegado de forma que el sistema ya no mostrará ningún diálogo. Desde el punto de vista de tu código, en cambio, solo dispones de dos consultas, y ninguna de ellas devuelve directamente la tercera situación.
La primera consulta es el estado actual, que responde si el permiso está concedido o no. La segunda es shouldShowRequestPermissionRationale, cuyo nombre describe su intención pero no su semántica: devuelve verdadero cuando el usuario ya denegó al menos una vez y el sistema todavía está dispuesto a mostrar el diálogo. Devuelve falso en dos situaciones radicalmente distintas que hay que aprender a separar: cuando nunca se ha preguntado, y cuando ya no se puede preguntar más.
Esa ambigüedad no es un descuido de la API sino una consecuencia de que el sistema no quiere darte una señal explícita de agotamiento, precisamente para que no construyas experiencias que castiguen al usuario por haber dicho que no. La ambigüedad, sin embargo, es tuya y hay que resolverla, porque la interfaz correcta en cada uno de los dos casos es completamente distinta.
Android 11 endureció además la mecánica. Desapareció la casilla explícita de no volver a preguntar y la sustituyó una regla implícita: dos denegaciones consecutivas equivalen a una denegación permanente, y a partir de ahí toda solicitud se resuelve inmediatamente como negativa sin mostrar nada al usuario. La llamada no falla, no lanza excepción y no avisa: devuelve un no instantáneo. Un bucle de reintentos escrito ingenuamente se convierte así en un bucle infinito silencioso.
El detalle que hace este cambio especialmente traicionero es que las dos denegaciones no tienen que ocurrir en la misma sesión. El sistema conserva el contador entre ejecuciones y entre actualizaciones de la aplicación, de modo que un usuario que rechazó una vez hace tres meses agota su segunda oportunidad la próxima vez que dice que no, sin que nada en tu código lo distinga del primer rechazo. La ventana de negociación es, en la práctica, mucho más estrecha de lo que sugiere la lectura ingenua de la documentación.
Hay además un cuarto estado que complica el modelo en los permisos de cámara, micrófono y ubicación: la concesión de una sola vez. El permiso se otorga de verdad, la funcionalidad funciona, y cuando la aplicación abandona el primer plano durante el tiempo suficiente la concesión caduca sola. Desde el punto de vista del código no es un estado nuevo sino un recordatorio de la regla general, que aquí se vuelve obligatoria en lugar de recomendable: comprobar antes de cada uso, no una vez por sesión.
stateDiagram-v2 [*] --> Virgen Virgen --> Concedido: acepta Virgen --> Denegado: rechaza Denegado --> Concedido: acepta en segunda peticion Denegado --> Permanente: rechaza otra vez Permanente --> Concedido: cambio manual en Ajustes Concedido --> Denegado: revocacion manual
Inferir la denegación permanente sin equivocarse
El algoritmo correcto exige una pieza de estado que la plataforma no guarda por ti: la memoria de haber preguntado alguna vez. Con esa pieza, la desambiguación es inmediata y no admite discusión.
Si nunca has preguntado, el falso de la consulta significa que estás ante un usuario virgen y lo que corresponde es pedir directamente, quizá con una explicación previa si el permiso es delicado. Si ya preguntaste y la consulta devuelve verdadero, el usuario dijo que no una vez y merece una explicación mejor antes del segundo intento. Y si ya preguntaste y devuelve falso, la puerta del diálogo está cerrada y la única vía es Ajustes.
enum class EstadoPermiso { CONCEDIDO, PEDIBLE, EXPLICABLE, BLOQUEADO }
fun estadoDe(activity: Activity, permiso: String, yaSePregunto: Boolean) = when {
ContextCompat.checkSelfPermission(activity, permiso) ==
PackageManager.PERMISSION_GRANTED -> EstadoPermiso.CONCEDIDO
ActivityCompat.shouldShowRequestPermissionRationale(activity, permiso) ->
EstadoPermiso.EXPLICABLE
yaSePregunto -> EstadoPermiso.BLOQUEADO
else -> EstadoPermiso.PEDIBLE
}
La bandera yaSePregunto debe persistirse por permiso y sobrevivir a la muerte del proceso, porque la solicitud y el diagnóstico ocurren en sesiones distintas. Un mapa en almacenamiento de preferencias, escrito en el mismo instante en que se lanza la petición, resuelve el problema con exactitud suficiente.
Conviene envolver todo esto en un tipo propio en lugar de repartir las comprobaciones por la interfaz, porque el valor de la clasificación está justamente en que la pantalla no tenga que razonar sobre permisos sino elegir entre cuatro presentaciones. La diferencia entre una base de código que maneja bien la denegación y una que no suele reducirse a si existe o no existe este enumerado.
class GestorDePermisos(private val prefs: SharedPreferences) {
fun marcarPreguntado(permiso: String) =
prefs.edit().putBoolean(clave(permiso), true).apply()
fun yaSePregunto(permiso: String) =
prefs.getBoolean(clave(permiso), false)
private fun clave(permiso: String) = "preguntado_$permiso"
}
Si el usuario borra los datos de la aplicación, tu bandera desaparece pero la memoria del sistema sobre las denegaciones también, de modo que ambos quedan sincronizados. El caso incómodo es el contrario: el usuario concede el permiso desde Ajustes y luego lo revoca, lo que en ciertas versiones restablece el contador interno del sistema pero deja tu bandera activa. El resultado es que clasificas como bloqueado algo que todavía se podría preguntar. La mitigación práctica es no tratar el estado bloqueado como definitivo sino como una preferencia de interfaz: en lugar de eliminar el botón, cambia su destino y déjalo intentar.
Degradar con dignidad en lugar de cancelar
La respuesta correcta a una denegación casi nunca es un mensaje de error. Es una ruta alternativa que entrega parte del valor sin el dato que se negó. Construir ese catálogo de alternativas antes de escribir la solicitud invierte el orden habitual del trabajo y produce aplicaciones mucho mejores, porque obliga a preguntarse qué queda cuando falta el permiso, que es exactamente la pregunta que el usuario acaba de hacerse.
En lugar de galería
Sin acceso a medios, el Photo Picker del sistema entrega la imagen elegida sin requerir ningún permiso. La funcionalidad se conserva íntegra y la única pérdida es la de tu propia interfaz de selección.
En lugar de ubicación
Sin posición, un campo de búsqueda de localidad resuelve el noventa por ciento de los casos de uso. La diferencia entre una aplicación del tiempo excelente y una inutilizable es un cuadro de texto.
Conviene además medir. Registrar, por permiso y por punto de solicitud, cuántas veces se muestra el diálogo y cómo se resuelve, convierte una discusión de opiniones sobre textos en una comparación de cifras. Es una de las pocas métricas de producto que cabe en una tabla de tres columnas y que suele producir un cambio de diseño en la primera semana de datos.
La regla general que ordena todo el catálogo se puede enunciar así: el permiso es una optimización de la experiencia, no un requisito de la funcionalidad, salvo en el puñado de casos donde la aplicación entera es el permiso. Una aplicación de cámara sin cámara no tiene alternativa razonable, y en ese caso la honestidad consiste en explicarlo con claridad y ofrecer la ruta a Ajustes, no en fingir que hay un plan B.
Hay además una categoría de degradación que se olvida sistemáticamente: la degradación parcial dentro de una misma funcionalidad. Sin ubicación exacta se puede seguir mostrando el mapa centrado en la ciudad. Sin acceso a contactos se puede seguir enviando el mensaje escribiendo el número. Sin notificaciones se puede seguir mostrando el aviso dentro de la aplicación cuando el usuario la abre. Cada una de estas rutas cuesta unas pocas horas y elimina un motivo de desinstalación.
when (estado) {
EstadoPermiso.CONCEDIDO -> mostrarClimaLocal()
EstadoPermiso.PEDIBLE -> pedirUbicacion.launch(PERMISO_APROXIMADO)
EstadoPermiso.EXPLICABLE -> explicar { pedirUbicacion.launch(PERMISO_APROXIMADO) }
EstadoPermiso.BLOQUEADO -> mostrarBuscadorDeCiudad()
}
Llevar a Ajustes sin convertirlo en un peaje
Cuando el estado es bloqueado y la funcionalidad no admite alternativa, la única salida es la pantalla de detalles de la aplicación en los ajustes del sistema. Abrirla es sencillo; lo que hay que hacer bien es el regreso, porque el usuario puede volver habiendo concedido, habiendo denegado o sin haber tocado nada, y no recibirás ninguna notificación de ello.
fun abrirAjustesDeLaApp(context: Context) {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
context.startActivity(intent)
}
La detección del cambio se hace revisando el estado en cada reanudación del ciclo de vida, no al recibir un resultado. Es una de las pocas ocasiones en que consultar el estado en onResume es la solución correcta y no un síntoma de mal diseño: el sistema ha cambiado a tus espaldas y tu única oportunidad de enterarte es mirar cuando vuelves al primer plano.
Merece la pena señalar que la ruta a Ajustes no es un destino único. Varias capacidades tienen su propia pantalla —la ubicación, las notificaciones, las alarmas exactas, el acceso a todos los archivos— y llevar al usuario al ajuste específico en lugar de a la ficha general reduce drásticamente la tasa de abandono, porque elimina el paso en que la persona tiene que buscar entre una lista de opciones cuyo nombre no coincide con el de tu mensaje.
La mayoría de los equipos prueban sus permisos exactamente una vez, la primera, y después el dispositivo de desarrollo conserva la concesión para siempre. El resultado es que la rama de denegación no se ejecuta jamás durante el desarrollo y llega a producción sin haber sido observada por nadie. Restablecer los permisos de la aplicación entre pruebas debería ser un paso rutinario de la lista de verificación previa a cada entrega, al mismo nivel que probar sin conexión de red.
El asunto delicado no es técnico sino ético, y tiene consecuencias directas en la revisión de la tienda. Un enlace a Ajustes que aparece una vez, con un texto que explica exactamente qué interruptor buscar, es una ayuda. El mismo enlace mostrado en un diálogo que no se puede cerrar, o repetido en cada arranque, o presentado como condición para seguir usando algo que funcionaba perfectamente sin él, es coacción, y las políticas de Google Play lo tratan como tal. La diferencia entre ambas cosas no está en el código sino en si el usuario puede seguir adelante después de decir que no.
Conviene detenerse en el error conceptual que subyace a casi todas las malas implementaciones de este terreno, porque no es un error de programación sino de encuadre. La mayoría de los equipos tratan la denegación de un permiso como una anomalía a remediar: un obstáculo entre el producto y su funcionamiento correcto, algo que hay que reducir mediante mejores textos, mejores momentos y, cuando eso no basta, mediante insistencia. Bajo ese encuadre el objetivo implícito de todo el flujo es maximizar la tasa de concesión, y una vez fijado ese objetivo el camino hacia el patrón oscuro es corto y se recorre casi sin querer: primero el diálogo que reaparece, después el mensaje que sugiere que la aplicación está rota, al final la pantalla que no deja continuar. Hay un encuadre alternativo, más exigente y mucho más productivo, que consiste en leer la denegación como lo que literalmente es: un dato de investigación de usuarios, entregado gratis, en tiempo real, con una precisión que ninguna entrevista alcanza. Una persona acaba de decirte que el valor que le ofreces no compensa el coste de privacidad que le pides, y lo ha dicho en el instante exacto en que tenía delante ambas cosas. Si tu tasa de denegación en una funcionalidad concreta es alta, has descubierto que esa funcionalidad no está justificando lo que cuesta, y ninguna cantidad de persuasión arregla un intercambio desequilibrado; lo único que lo arregla es cambiar el intercambio, es decir, pedir menos, pedir más tarde o entregar más antes de pedir. Esta lectura tiene además una virtud que la otra no tiene: alinea perfectamente lo correcto con lo rentable, porque las tres respuestas que produce —minimizar lo que se pide, retrasar la petición al punto de valor demostrado y construir alternativas sólidas— son exactamente las que aumentan la retención, reducen las desinstalaciones tempranas y sobreviven a cualquier endurecimiento futuro de la política de la tienda. La aplicación que trata cada no como información acaba necesitando menos permisos y obteniendo más; la que lo trata como un fallo acaba peleando contra su propio usuario, y esa es una guerra que nadie ha ganado nunca.
- Implementa la clasificación en cuatro estados con la bandera persistida y verifica cada transición en un dispositivo real, incluida la doble denegación.
- Escribe, para cada permiso peligroso de tu aplicación, la alternativa concreta que ofrecerás cuando quede bloqueado; si alguna no tiene alternativa, justifica por escrito por qué.
- Comprueba qué ocurre hoy en tu código si el usuario deniega dos veces seguidas y cronometra cuántos segundos tarda en aparecer un bucle.
- Añade la revisión del estado en la reanudación y confirma que la interfaz se actualiza sola al volver de Ajustes.
- Revisa tus textos de explicación y elimina cualquier frase que insinúe que la aplicación no funciona sin el permiso cuando en realidad sí funciona.