Volver al Blog
Desarrollo15 de septiembre de 202613 min de lectura

Los permisos de Android cambian según la marca del móvil

Xiaomi, Samsung, Huawei... cada fabricante tiene su propio infierno de permisos. Por qué acabas escribiendo un gestor propio y cómo domar las peculiaridades por marca.

IM
Ignacio MelendezDesarrollador Full-Stack y de Videojuegos
Los permisos de Android cambian según la marca del móvil

La primera vez que me pasó, pensaba que era un bug. La app pedía al usuario el permiso de ubicación. Se aceptaba. El sistema devolvía PERMISSION_GRANTED. Acto seguido, el proveedor de localización no devolvía nada; ni error, ni excepción, ni timeout. Ninguna ubicación. En el Pixel de desarrollo funcionaba perfectamente, y en el Xiaomi del cliente, no funcionaba nunca.

En ese momento, descubres que el modelo de permisos de Android que has leído en la documentación, no es la historia completa. Esa documentación describe AOSP, el Android Open Source Project: el sistema base que Google publica antes de que Google y cada fabricante le monten cosas encima. Cada fabricante escribe una parte (que además no está documentada en ningún sitio), y con cada actualización, la cambia.

1. El contrato que AOSP promete

El modelo de AOSP es razonable y está bien especificado. Divide los permisos en dos grandes grupos:

  • Normales: Se conceden en la instalación. Basta con declararlos en el manifest.
  • Especiales: Estos son los peligrosos. Requieren una confirmación explícita del usuario, generalmente en tiempo de ejecución.

Para los segundos, se debe consultar el estado actual con checkSelfPermission, lanzar el diálogo para confirmar el permiso con requestPermissions y recibir el resultado (decidido por el usuario) en onRequestPermissionsResult. Es decir, se construye una máquina de estados con: concedido, denegado y denegado permanentemente (existe una variable llamada shouldShowRequestPermissionRationale que devuelve false después de una denegación).

Con los años AOSP se ha actualizado, añadiendo matices que buscan dar más control al usuario. Algunos ejemplos son: permisos de una sola vez, que caducan al cerrar la app, reseteo automático de permisos para apps que llevan meses sin abrirse, separación de la ubicación en primer plano y en segundo plano, que obliga a dos peticiones distintas...

Esta implementación es predecible. Está documentada, tiene una API estable, y lo más importante, es honesto. Cuando el sistema dice que tienes un permiso, lo tienes.

2. La capa que nadie documenta

Como he comentado al principio, lo que corre en un móvil real no es AOSP. Es un conjunto de tres capas apiladas, y solo la primera está documentada:

  • AOSP, el sistema base. Kernel, framework, APIs, el gestor de permisos oficial. Es lo que define el contrato de la sección anterior.
  • GMS (Google Mobile Services), la capa propietaria de Google que los fabricantes licencian: Play Store, Play Services, Maps. No forma parte de AOSP, y es la razón por la que existen móviles Android sin Play Store.
  • La capa del fabricante: MIUI y HyperOS en Xiaomi, One UI en Samsung, EMUI y HarmonyOS en Huawei, ColorOS en Oppo, Funtouch en Vivo.

"AOSP puro" es lo que corre un emulador y, con GMS encima y poco más, un Pixel. Es también, casi siempre, el único sitio donde los desarrolladores probamos la app que estamos desarrollando. Y el problema es que, la capa del fabricante suele ser la que decide que significa de verdad un permiso concedido.

Esa tercera capa no es solo visual. Incluye su propio gestor de permisos, su propio gestor de arranque automático y su propio gestor de batería. Cada uno de esos gestores, puede interponerse entre tu app y el sistema.

El caso más claro suele ser el de la batería. El fabricante añade una política agresiva sobre lo que las apps pueden hacer en segundo plano para vender autonomía y ahorro de batería. Para que eso sea efectivo, debe pasar por encima de lo que AOSP concedió. El resultado es una segunda lista de "permisos" o interruptores, paralela a la de permisos estándar, que el usuario no sabe que existe y que, generalmente, tu app no puede consultar por ninguna API pública.

Los mecanismos concretos varían por marca, pero se pueden agrupar en tres familias:

  • Listas de arranque automático. Si la app no está en la lista, no recibe los broadcasts ni las alarmas que la despertarían.
  • Gestores de batería propios. Independientes del Doze de AOSP y del interruptor estándar de optimización de batería. AOSP puede indicar que la app está exenta de optimización, pero el fabricante la añade a una lista negra.
  • Diálogos de permisos paralelos. Permisos que en AOSP no existen o ni siquiera necesitan que el usuario acepte, y que la capa del fabricante marca como peligrosos con su propio diálogo, fuera del ciclo de requestPermissions.

Si necesitas el detalle exacto por marca y versión, dontkillmyapp.com mantiene un catálogo comunitario bastante fiable de qué hace cada capa y qué ruta de ajustes toca en cada una. Se actualiza cuando los fabricantes cambian las cosas. Y las cambian a menudo.

3. Por qué checkSelfPermission miente

El problema de fondo es uno de diseño de API. No tiene que ver con una marca concreta. checkSelfPermission responde a la pregunta "¿está este permiso concedido en el registro de permisos del sistema?". Pero realmente lo que necesitas saber es: "¿puedo hacer la operación para la que pedí este permiso?". En AOSP puro las dos preguntas tienen la misma respuesta. Con una capa de fabricante por medio, no.

Ante esto, los fallos que encontré desarrollando la app de telemetría de vehículos eléctricos son:

  1. Permiso concedido con resultado vacío. El registro dice que el permiso está concedido, pero la consulta al proveedor dice lo contrario. Como resultado, el usuario obtiene contactos vacíos en un móvil con trescientos contactos. Esto es complicado, porque un resultado vacío no es un error, es un resultado legítimo: no puedes distinguir "no tienes contactos" de "el fabricante está filtrando la consulta y te muestra 0 contactos".
  2. Petición sin callback. Se llama a requestPermissions, el fabricante decide denegar sin mostrar nada, y onRequestPermissionsResult no se invoca. Envolver esa llamada en una promesa o en una corrutina suspendida, provoca que se quede colgada para siempre. En React Native esto se ve como un botón que deja de responder y no vuelve a activarse.
  3. Revocación silenciosa. El permiso se concede, funciona durante un rato, y al volver a la app días después ya no está. No hubo actualización, ni el usuario tocó los ajustes. El gestor del fabricante decidió que la app no lo necesitaba al cabo del tiempo.
  4. Trabajo en segundo plano que nunca ocurre. No rompe el permiso en sí, rompe todo lo que dependía de él. El servicio en primer plano se mata con su notificación puesta, la alarma no suena, el job programado no se ejecuta.


El estado de un permiso no es una propiedad del sistema que se pueda consultar. Es una hipótesis que debe ser verificada.

4. Verificar capacidad, no permiso

La solución pasa por dejar de preguntar por el permiso y comenzar a comprobar su capacidad. En vez de confiar ciegamente en el registro, se ejecuta la operación más pequeña y barata posible, aquella que permita demostrar que la funcionalidad está realmente disponible.

Siguiendo el ejemplo de los contactos, una consulta con limit 1 sobre el proveedor. En un caso de notificaciones, comprobar el canal además del permiso. Cada una de estas pruebas, es una operación que ya sabes hacer, y devuelve algo mucho más útil que un true/false.

La clave está en el tipo de retorno. Un permiso no tiene dos estados, tiene cuatro, y hay que modelarlos y controlarlos:

1export type PermissionOutcome =
2  | { status: "usable" }
3  | { status: "grantedButUnusable"; reason: string }
4  | { status: "denied" }
5  | { status: "blocked" }
6
7export type CapabilityProbe = () => Promise<boolean>
8
9// El consumidor distingue los dos casos que antes se confundían.
10export function messageFor(outcome: PermissionOutcome): string {
11  switch (outcome.status) {
12    case "usable":
13      return ""
14    case "denied":
15      return "Necesitamos este permiso para continuar."
16    case "blocked":
17      return "Activa el permiso desde los ajustes del sistema."
18    case "grantedButUnusable":
19      // El caso que no existe en la API de AOSP y que es el 90% de los tickets.
20      return "El permiso está concedido pero tu móvil lo está bloqueando."
21  }
22}

Es interesante separar Denied de BlockedUsable de GrantedButUnusable. De esta forma, el mensaje de error que se muestra al usuario (y que el desarrollador puede obtener) es más expresivo y permite una mejor guía. Que ocurra un error y el usuario vea un mensaje "concede el permiso", aunque ya lo haya concedido, es una muy mala experiencia. Este es el problema de confiar en checkSelfPermission.

La sonda tiene que ser barata y silenciosa, porque se va a ejecutar en el arranque de pantallas. Si abrir la cámara para comprobarla enciende el LED o tarda medio segundo, cachea el resultado por sesión e invalídalo cuando la app vuelve del segundo plano, que es justo el momento en el que el usuario pudo haber cambiado algo en los ajustes.

5. El gestor propio

Con los cuatro estados y las sondas, el gestor de permisos propio casi se escribe solo. Su trabajo es centralizar las peticiones de permisos y añadir timeouts (permitiendo que si el diálogo no responde en N segundos, se marca como bloqueado para evitar esperar un callback que nunca va a llegar), sondas posteriores y telemetría práctica.

1import { NativeModules } from "react-native"
2
3const { PermissionManager } = NativeModules
4
5// Una sola puerta de entrada para toda la app. Nadie llama
6// a PermissionsAndroid directamente.
7export async function ensurePermission(
8  permission: AppPermission,
9): Promise<PermissionOutcome> {
10  try {
11    return await PermissionManager.ensure(permission)
12  } catch (error) {
13    // Un fallo del puente nativo no debe tumbar la pantalla.
14    return { status: "grantedButUnusable", reason: "bridge_error" }
15  }
16}
17
18export async function withCamera<T>(action: () => Promise<T>): Promise<T | null> {
19  const outcome = await ensurePermission("camera")
20
21  if (outcome.status !== "usable") {
22    showPermissionSheet(outcome)
23    return null
24  }
25
26  return action()
27}

Concentrar todo en un punto tiene un efecto secundario que vale más que el propio gestor: la telemetría. Cuando cada resolución de permiso emite un evento etiquetado con el fabricante, en dos semanas tienes datos reales de qué combinaciones de marca y permiso fallan en tu base de usuarios. Deja de ser folclore de Stack Overflow y pasa a ser una tabla que puedes ordenar.

6. Peculiaridades por marca sin hardcodear versiones

Una vez que sabemos que MIUI se comporta distinto, lo más sencillo es escribir un if sobre Build.MANUFACTURER y meter ahí el caso especial de este fabricante. Pero este código envejece fatal. Cuantos más casos, más ifs y más lógica necesaria.

Y hay varias razones para que esto falle:

  • Los nombres de las actividades internas de ajustes cambian entre versiones y desaparecen sin aviso. Un Intent que apunta a una pantalla concreta de un fabricante es una excepción en potencia a largo plazo.
  • El valor de MANUFACTURER no es fiable como identidad. Hay dispositivos de marcas distintas con la misma capa por debajo. Y dispositivos de la misma marca con capas diferentes.
  • El comportamiento que quieres detectar no depende de la marca, sino de una configuración que el usuario puede cambiar en cualquier momento.

La alternativa es preguntar al sistema. Antes de lanzar una pantalla de ajustes específica, es mejor comprobar que existe la de dicho fabricante. AOSP puede servir como pantalla genérica:

1import { Linking } from "react-native"
2
3// Varios ciclos perdidos seguidos son evidencia suficiente.
4const MISSED_RUNS_THRESHOLD = 3
5
6// La pantalla genérica funciona en todos los dispositivos.
7// Las específicas del fabricante son un extra, nunca el camino principal.
8export async function openPermissionSettings(): Promise<void> {
9  await Linking.openSettings()
10}
11
12/**
13 * El arranque automático no se puede consultar por API, así que se infiere:
14 * si el trabajo periódico no se ha ejecutado en mucho más tiempo del
15 * programado, algo lo está matando.
16 */
17function looksThrottled(log: BackgroundWorkLog, intervalMs: number): boolean {
18  if (log.lastSuccessfulRun === null) return false
19  return Date.now() - log.lastSuccessfulRun > intervalMs * MISSED_RUNS_THRESHOLD
20}
21
22// El aviso solo se muestra si hay evidencia de estrangulamiento,
23// no porque el móvil sea de una marca concreta.
24export function shouldWarnAboutBackground(
25  log: BackgroundWorkLog,
26  intervalMs: number,
27): boolean {
28  return looksThrottled(log, intervalMs) && !log.userDismissedWarning
29}

Saber que el usuario está en un Xiaomi permite especificar las configuraciones al usuario, con rutas de ajustes más directas. Parece sutil, pero esa diferencia se nota en cuánta gente completa el proceso. Es una decisión de copy, no de lógica.

La detección tiene que ser pasiva: registra cuándo se ejecutó por última vez tu trabajo periódico y compáralo con el intervalo esperado. Si acumula varios ciclos perdidos, tienes evidencia suficiente para avisar al usuario, y no has gastado batería para conseguirla.

7. Cuando el segundo plano es el producto

Todo lo anterior asume que existe un momento en el que el usuario tiene la app abierta. Ahí se pide el permiso, se comprueba la capacidad, muestra el mensaje de error si algo ha ido mal.... Pero, hay productos donde ese momento no existe.

En la app de telemetría de vehículos eléctricos el requisito era exactamente ese. La app se conecta por BLE a un lector OBD-II, le manda comandos UDS y registra el trayecto del vehículo. El usuario solo tiene que subirse al coche y conducir. No tiene que abrir la app. No tiene que darle a un botón para iniciar el registro del viaje. No necesita acordarse de la aplicación en el móvil. Puede llevar cerrada desde hace días.

Eso, pone el foco del producto en el funcionamiento en segundo plano. No es una funcionalidad extra para facilitar el uso. Es la razón de existir de la app. Y rompe todas las reglas y salidas comentadas en este artículo. Cuando falla, no hay forma de enseñárselo al usuario.

El detalle que más cuesta explicar es que un trayecto en el que se da un error, es un trayecto perdido. Si la app no despierta en cuanto el coche comienza a moverse, esos kilómetros, esos datos, no existen. No hay un "abre la app y sincroniza", porque el dato solo estaba disponible mientras el coche se movía. El OBD II necesita la conexión para enviar los datos. Y sin app corriendo, no hay conexión. Comparada con esto, una notificación que no suena cuando debería es una anécdota.

La solución que propone Android para estos casos, es el servicio en primer plano con notificación persistente. De esta forma, el sistema tiene una razón explícita para mantener el proceso vivo mientras el trayecto está activo. Y la parte que tiene que sobrevivir al cierre de la app no puede vivir en el hilo principal de JavaScript, por lo que la conexión BLE debe estar en un módulo nativo en Kotlin (o en el caso de iOS, que es otro mundo completamente diferente, en Swift). Esa fue, con diferencia, el reto técnico más difícil del proyecto.

Hay además una decisión a tomar que es de diseño que no es código. Si la app depende del arranque automático, el sitio correcto para solicitar el permiso es el onboarding y no en un mensaje de error después del primer error. Cuando el aviso llega después, el usuario ya ha perdido datos (un viaje entero que no se registra) y ya se ha formado una opinión sobre si la app funciona. Un paso de la instalación, en cambio, cae en el único momento en el que alguien está dispuesto a entrar en los ajustes del sistema y seguir los pasos que garanticen el correcto funcionamiento de la aplicación.

Cerrar la app y matar el proceso no son lo mismo, y cada capa de fabricante decide dónde está la diferencia. Hacer un "swipe" de la app fuera de recientes puede detener el proceso completo o puede permitir que siga haciendo llamadas. Si el producto depende del segundo plano, esto es lo primero que hay que probar en un dispositivo de cada marca, y no se puede deducir leyendo la documentación.

Aunque por desgracia, no siempre se gana. Hay marcas y fabricantes en las que ninguna cantidad de ingeniería sustituye a que el usuario active la opción escondida entre los ajustes. Cuando se llega a ese punto, lo que queda ya no es técnico.

8. Degradar con dignidad

Después de todo esto queda un porcentaje de casos que no se pueden arreglar desde la app. El fabricante ha decidido que un trabajo en segundo plano no se ejecuta. No hay API que lo solucione. Ese porcentaje es pequeño pero no es cero, y merece un plan.

El plan es diseñar la funcionalidad para que la parte que depende del fabricante sea un extra y no el fundamento. Si las notificaciones locales programadas pueden no dispararse, que el estado también se sincronice al abrir la app. Si el trabajo periódico puede no ejecutarse, que haya un botón de refrescar manual que haga lo mismo. Si la ubicación en segundo plano puede morir, que la funcionalidad principal siga teniendo sentido con la ubicación en primer plano.

Esto suena a resignación y en realidad es buena arquitectura. Una app que asume que el segundo plano es best effort en lugar de una garantía es una app que también funciona bien en modo avión, con la batería al cinco por ciento, y en la próxima versión de Android con nuevos ajustes.

El infierno de permisos de los fabricantes es solo el caso extremo de algo que ya era cierto.

Y para el porcentaje que aún así se queda fuera, la mejor herramienta es la honestidad. Un mensaje que diga exactamente qué está pasando, con el nombre del ajuste tal y como aparece en ese móvil, y un botón que lleve lo más cerca posible de él. No arregla el problema, pero convierte un ticket de soporte en algo que el usuario puede resolver solo.

Programar para Android no es programar para un sistema operativo. Es programar para varias decenas de sistemas operativos que comparten una API y discrepan en lo que esa API significa.