Cómo hacer un bloqueador de apps para Android sin AccessibilityService
Hago HonorFit, un bloqueador de apps en el que te ganas el tiempo de pantalla haciendo flexiones (sistema de honor, sin cámara). La primera versión para Android bloqueaba apps como lo hacen la mayoría de los bloqueadores de Android: con un AccessibilityService. En agosto lo arranqué de raíz y lo sustituí por UsageStatsManager, un servicio en primer plano y una ventana superpuesta. El bloqueo quedó un poco más blando, el onboarding asusta mucho menos y la revisión de Play se volvió mucho más sencilla.
Esta es la arquitectura y las cosas que me mordieron por el camino. Todo el código es Kotlin dentro de un módulo local de Expo, pero nada de esto es específico de React Native.
Por qué dejar la API de accesibilidad
Un servicio de accesibilidad es la herramienta más potente que tienes en Android. Te enteras de cada cambio de ventana en el instante en que ocurre, y performGlobalAction(GLOBAL_ACTION_HOME) saca al usuario de una app bloqueada en el acto. Por eso tantos bloqueadores lo usan. Lo que cuesta:
- La solicitud de permiso da miedo. Android le dice al usuario que la app puede ver todo lo que hay en pantalla y actuar en su nombre. Para una app cuyo argumento es «nada sale de tu teléfono», pedir eso el primer día queda fatal, y mucha gente abandona justo ahí.
- Google Play lo trata como una API sensible. Si no eres una herramienta de accesibilidad, necesitas un aviso destacado dentro de la app, una declaración en Play Console y un revisor que esté de acuerdo en que tu caso de uso es válido. Yo tenía el modal del aviso hecho. Prefiero no necesitarlo.
- Ve muchísimo más de lo que necesita un bloqueador. Un bloqueador necesita exactamente un dato: qué app está en primer plano.
Ese dato se puede conseguir por otro lado: los eventos de uso que hay detrás de Bienestar digital.
La forma general
Tres permisos, todos concedidos por el usuario en Ajustes:
| Permiso | Para qué sirve |
|---|---|
PACKAGE_USAGE_STATS («Acceso a datos de uso») | Leer UsageEvents para saber qué app está delante. Nunca se concede en tiempo de ejecución; mandas al usuario a la pantalla de Ajustes y compruebas el app-op. |
SYSTEM_ALERT_WINDOW («Mostrar sobre otras apps») | Dibujar la pantalla de bloqueo encima de una app bloqueada. |
FOREGROUND_SERVICE_SPECIAL_USE | El servicio de monitorización. Bloquear apps no encaja en ninguno de los tipos de servicio en primer plano de la lista, así que es specialUse con una cadena de subtipo que explica por qué. |
Las piezas:
ForegroundMonitorServicesolo se ejecuta mientras hay algo que hacer cumplir (una lista de bloqueo no vacía y el acceso a datos de uso concedido). Una vez por segundo, y solo con la pantalla encendida, le pregunta aUsageStatsManagerqué pasó a primer plano. Una app bloqueada con tiempo restante va gastando el saldo acumulado; al llegar a cero, muestra la superposición.- Su notificación obligatoria es la que muestra la cuota («quedan 12 min»), así que lo que Android te impone hace un trabajo real.
- Todo el estado vive en
SharedPreferences, no en JS. El servicio tiene que seguir funcionando cuando el runtime de React Native está muerto, que es casi siempre. La parte de JS deposita tiempo y lee el saldo a través del módulo; el servicio es el dueño del bloqueo.
La entrada del servicio en el manifiesto:
<service
android:name=".ForegroundMonitorService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="User-configured app blocking for digital wellbeing: monitors
which app is in the foreground (UsageStats) so apps the user chose to block
can be locked when their earned screen time runs out." />
</service>
También tienes que justificar specialUse en la declaración de servicios en primer plano de Play Console. Contar la misma historia en los dos sitios ayuda.
Consultar UsageStats (y su retraso de escritura)
El bucle principal es pequeño. Recorre los eventos desde la última consulta y quédate con el último paquete con MOVE_TO_FOREGROUND (es la misma constante que ACTIVITY_RESUMED, renombrada en la API 29):
val end = System.currentTimeMillis()
val events = usm.queryEvents(windowStart, end)
val event = UsageEvents.Event()
while (events.hasNextEvent()) {
events.getNextEvent(event)
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
currentForeground = event.packageName
}
}
// La próxima vez, volver a recorrer los últimos 15 s: ver abajo.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
Una ventaja frente a los eventos de accesibilidad: el teclado y el panel de notificaciones no son actividades, así que nunca aparecen como falsas salidas del tipo «el usuario dejó la app».
Trampa: los eventos llegan tarde
Mi primera versión movía la ventana estrictamente hacia delante: cada consulta pedía [lastEnd, now]. Casi siempre funcionaba, pero en un teléfono recién arrancado la primera apertura de una app bloqueada nunca se detectaba. UsageStats escribe los eventos en su almacén con retraso, así que un evento con marca de las 10:00:00.900 puede aparecer después de una consulta que ya cubría las 10:00:01. Una ventana que solo avanza se lo salta para siempre.
La solución: cada consulta vuelve a recorrer un solapamiento hacia atrás (yo uso 15 segundos). Ver el mismo evento dos veces no hace daño, porque en cualquier caso gana el último resume de la ventana. Un evento que llegue con más de 15 segundos de retraso se sigue perdiendo, pero no he visto ninguno.
Trampa: la primera consulta necesita mirar mucho hacia atrás
Cuando el servicio arranca (tras un reinicio, o cuando se enciende la pantalla) no hay una «última app conocida». Si el usuario lleva 20 minutos en TikTok y la primera consulta solo mira un minuto atrás, no ve nada hasta que cambie de app. Así que la primera consulta recorre la última hora. Es seguro porque el launcher también es una actividad: lo último que hizo resume es realmente lo que está delante.
Trampa: un acceso revocado parece silencio
Si el usuario desactiva el acceso a datos de uso mientras el servicio está en marcha, queryEvents no lanza ninguna excepción. Simplemente no devuelve nada. Tu última lectura («Instagram está delante») se queda obsoleta y el saldo se gasta para siempre. Así que cada consulta comprueba primero el app-op y para el reloj si ya no está:
fun hasUsageAccess(context: Context): Boolean {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
return mode == AppOpsManager.MODE_ALLOWED
}
Pantalla apagada significa parar
Cuando se apaga la pantalla, el servicio contabiliza la sesión abierta y deja de consultar por completo. Nadie está usando una app con la pantalla apagada, y un bucle de 1 Hz que solo corre mientras miras el teléfono sale barato. La primera consulta después de encender la pantalla también se encarga del cambio de día (el reinicio de medianoche, la base diaria), así que ocurre antes de que el usuario llegue a una app bloqueada, aunque nunca abra HonorFit.
La superposición de bloqueo
Sin accesibilidad no hay GLOBAL_ACTION_HOME, así que no puedes echar al usuario de una app. En su lugar la tapas: una ventana TYPE_APPLICATION_OVERLAY con un botón de «ganar más tiempo» y otro de «ir al inicio».
- Cubre toda la pantalla. Por defecto, la superposición queda recortada por las barras del sistema y la app bloqueada asoma por los bordes.
FLAG_LAYOUT_NO_LIMITSlo arregla.FLAG_LAYOUT_IN_SCREENquedó obsoleto en la API 30, así que añádelo solo por debajo de R. - «Ir al inicio» tiene que ir al inicio de verdad. Ocultar la superposición solo vuelve a mostrar la app bloqueada, y la siguiente consulta vuelve a poner el bloqueo. En su lugar, abre el launcher:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Primero abre, luego oculta. El botón para ganar tiempo abre HonorFit, y eso es iniciar una actividad desde segundo plano. Android lo permite porque la app tiene una ventana superpuesta visible. Así que inicia la actividad mientras la superposición todavía se ve, y luego quítala. Si lo haces al revés, el inicio se bloquea sin avisar.
- ¿Sin permiso de superposición? Sigue contando igual. Si «Mostrar sobre otras apps» no está concedido, el servicio sigue gastando el saldo honestamente. Simplemente no puede dibujar el bloqueo hasta que vuelva el permiso.
Mantener vivo el servicio
Esta es la parte que más cuesta dejar bien, y casi nada depende de tu código.
START_STICKY no basta
Las «apps en suspensión» de Samsung, MIUI, OnePlus y compañía matan los servicios en segundo plano y luego se tragan el reinicio sticky. Un bloqueador es el peor caso posible: la gente no lo abre mientras le queda tiempo, así que nadie nota que el monitor ha desaparecido. HonorFit programa una alarma repetitiva, inexacta y que no despierta el teléfono, cada 15 minutos, que vuelve a levantar el servicio. Nunca despierta el teléfono, y las alarmas aplazadas mientras la pantalla estaba apagada se disparan poco después de que se encienda, que es justo cuando el monitor importa.
Android 12+ no siempre te deja iniciarlo
Iniciar un servicio en primer plano desde segundo plano (una alarma, un reinicio sticky) lanza ForegroundServiceStartNotAllowedException salvo que tengas una exención. Dos que importan aquí: que el usuario haya desactivado la optimización de batería para la app, o que la app tenga SYSTEM_ALERT_WINDOW (esta solo sirve antes de Android 15). Así que:
- Captura la excepción y sal sin hacer ruido. Que el proceso se caiga no le sirve a nadie, y la siguiente vez que se abra la app el servicio arrancará igualmente.
- Pídele al usuario que ponga el uso de batería en «Sin restricciones». Los Ajustes de HonorFit tienen una tarjeta «Funcionamiento en segundo plano» que lo lleva a la pantalla correcta. En algunos teléfonos, ese ajuste es la diferencia entre funcionar y no funcionar.
Reinicios y actualizaciones
Un receiver para BOOT_COMPLETED y MY_PACKAGE_REPLACED vuelve a iniciar la monitorización. El segundo es fácil de olvidar: una actualización automática de Play mata tu proceso y nunca reinicia sus servicios, así que sin él cada actualización apaga en silencio tu bloqueador hasta que el usuario abre la app. Los dos broadcasts están exentos de la restricción de inicio en segundo plano, y un receiver con exported="false" los sigue recibiendo.
Cosas más pequeñas
- Hay apps que nunca deben poder bloquearse: Ajustes, el reloj (¡las alarmas!) y Seguridad personal, además de variantes de los fabricantes como el paquete del reloj de Samsung. Fíltralas de la lista de bloqueo en el código nativo, no solo en el selector.
- El servicio se ejecuta sin JS, así que no puede llamar a
t(). La app guarda los textos de la superposición y de la notificación enSharedPreferencesen el idioma actual, y el servicio usa el inglés hasta que llegan. - Una sesión que quedó abierta durante un reinicio del servicio no podía descontarse mientras el servicio estaba muerto. Al arrancar, contabiliza lo transcurrido (con un tope) y empieza una sesión nueva, para que a nadie se le cobre tiempo que el servicio no estaba ahí para ver.
- Android 14+ permite a los usuarios descartar las notificaciones de los servicios en primer plano. El servicio la sigue actualizando (con deduplicación para no volver a publicarla cada segundo), así que vuelve a aparecer.
Las concesiones
Este enfoque es más blando que la accesibilidad, y quiero ser claro con eso:
- Una app bloqueada se ve durante hasta un intervalo de consulta (más o menos un segundo) antes de que llegue la superposición.
- La app bloqueada sigue ejecutándose debajo de la superposición. Si había un vídeo reproduciéndose, lo sigues oyendo hasta que tocas «ir al inicio».
- Depende de que el fabricante deje en paz tu servicio, y por eso el ajuste de batería importa tanto.
Para un bloqueador con sistema de honor, es la concesión correcta. HonorFit no intenta ser irrompible. Quien esté decidido a hacer trampa puede simplemente registrar reps que no hizo. La idea es una pausa y un precio, no una cárcel. A cambio, tienes una pantalla de permisos que dice «acceso a datos de uso» en lugar de «control total», una revisión de Play mucho más sencilla y un servicio que lee exactamente una cosa: el nombre del paquete de la app que está delante.
Si también estás desarrollando para iOS, allí es un mundo completamente distinto. Escribí sobre lo que la documentación de Screen Time de Apple no te cuenta.
HonorFit Blocker es un bloqueador de apps gratis para iPhone y Android. Gana tiempo de pantalla con flexiones, sentadillas o cualquier ejercicio que elijas. Sistema de honor, sin cámara, sin cuenta, nada sale de tu teléfono.