Créer un bloqueur d’applis Android sans AccessibilityService
Je développe HonorFit, un bloqueur d’applis où tu gagnes ton temps d’écran en faisant des pompes (système de l’honneur, pas de caméra). La première version Android bloquait les applis comme le font la plupart des bloqueurs Android : avec un AccessibilityService. En août, j’ai tout arraché pour le remplacer par UsageStatsManager, un service de premier plan et une fenêtre en overlay. Le blocage est devenu un peu moins strict, l’onboarding beaucoup moins effrayant, et la validation sur Google Play beaucoup plus simple.
Voici à quoi ressemble cette architecture, et les pièges dans lesquels je suis tombé en chemin. Tout le code est en Kotlin dans un module Expo local, mais rien ici n’est propre à React Native.
Pourquoi abandonner l’API d’accessibilité
Un service d’accessibilité, c’est l’outil le plus puissant que tu aies sur Android. Tu es prévenu de chaque changement de fenêtre à l’instant où il se produit, et performGlobalAction(GLOBAL_ACTION_HOME) éjecte l’utilisateur d’une appli bloquée sur-le-champ. C’est pour ça que tant de bloqueurs l’utilisent. Ce que ça coûte :
- La demande d’autorisation fait peur. Android dit à l’utilisateur que l’appli peut voir tout ce qui s’affiche à l’écran et agir à sa place. Pour une appli dont l’argument est « rien ne quitte ton téléphone », demander ça dès le premier jour, ça fait mauvais effet, et beaucoup de gens abandonnent à ce moment-là.
- Google Play la traite comme une API sensible. Si tu n’es pas un outil d’accessibilité, il te faut un avertissement bien visible dans l’appli, une déclaration dans la Play Console et un examinateur qui accepte ton cas d’usage. J’avais déjà construit la fenêtre d’avertissement. J’aurais préféré ne pas en avoir besoin.
- Elle voit bien plus que ce dont un bloqueur a besoin. Un bloqueur a besoin d’une seule information : quelle appli est au premier plan.
Cette information-là est disponible ailleurs : dans les événements d’utilisation sur lesquels repose Bien-être numérique.
L’architecture
Trois autorisations, toutes accordées par l’utilisateur dans les Paramètres :
| Autorisation | À quoi elle sert |
|---|---|
PACKAGE_USAGE_STATS (« Accès aux données d’utilisation ») | Lire les UsageEvents pour savoir quelle appli est devant. Elle ne s’accorde jamais à l’exécution : tu envoies l’utilisateur sur l’écran des Paramètres et tu vérifies l’app-op. |
SYSTEM_ALERT_WINDOW (« Superposition sur d’autres applis ») | Dessiner l’écran de blocage par-dessus une appli bloquée. |
FOREGROUND_SERVICE_SPECIAL_USE | Le service de surveillance. Le blocage d’applis ne rentre dans aucun des types de service de premier plan prévus, donc c’est specialUse, avec une chaîne de sous-type qui explique pourquoi. |
Les briques :
ForegroundMonitorServicene tourne que lorsqu’il y a quelque chose à faire respecter (une liste de blocage non vide et l’accès aux données d’utilisation accordé). Une fois par seconde, et seulement quand l’écran est allumé, il demande àUsageStatsManagerce qui est passé au premier plan. Une appli bloquée à qui il reste du temps fait baisser le solde en banque ; à zéro, il affiche l’overlay.- Sa notification obligatoire sert à afficher le quota (« 12 min restantes »), donc le truc qu’Android t’impose fait un vrai travail.
- Tout l’état vit dans les
SharedPreferences, pas dans le JS. Le service doit continuer à marcher quand le runtime React Native est mort, c’est-à-dire la plupart du temps. Le côté JS dépose du temps et lit le solde via le module ; c’est le service qui fait respecter le blocage.
L’entrée du service dans le manifeste :
<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>
Tu dois aussi justifier specialUse dans la déclaration des services de premier plan de la Play Console. Raconter la même histoire aux deux endroits aide.
Interroger UsageStats (et son retard d’écriture)
La boucle principale est courte. Parcours les événements depuis la dernière interrogation et garde le dernier paquet en MOVE_TO_FOREGROUND (c’est la même constante qu’ACTIVITY_RESUMED, renommée dans l’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
}
}
// Rescanner les 15 dernières secondes au prochain tour : voir plus bas.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
Un effet secondaire sympa par rapport aux événements d’accessibilité : le clavier et le volet des notifications ne sont pas des activités, donc ils n’apparaissent jamais comme de fausses sorties du genre « l’utilisateur a quitté l’appli ».
Piège : les événements arrivent en retard
Ma première version faisait avancer la fenêtre strictement : chaque interrogation demandait [lastEnd, now]. Ça marchait presque toujours, puis, sur un téléphone fraîchement démarré, la toute première ouverture d’une appli bloquée n’était jamais vue. UsageStats écrit les événements dans son stockage avec un retard, donc un événement horodaté 10:00:00.900 peut apparaître après une interrogation qui couvrait déjà 10:00:01. Une fenêtre qui avance strictement le rate pour toujours.
La solution : chaque interrogation rescanne un chevauchement en arrière (j’utilise 15 secondes). Voir le même événement deux fois ne pose aucun problème, puisque c’est la dernière reprise de la fenêtre qui l’emporte dans tous les cas. Un événement qui arrive avec plus de 15 secondes de retard est quand même perdu, mais je n’en ai jamais vu.
Piège : la première interrogation doit remonter loin
Quand le service démarre (après un redémarrage, ou quand l’écran s’allume), il n’y a pas de « dernière appli connue ». Si l’utilisateur est sur TikTok depuis 20 minutes et que la première interrogation ne remonte qu’à une minute, elle ne voit rien tant qu’il ne change pas d’appli. Donc la première interrogation parcourt la dernière heure. C’est sans risque, parce que le launcher est lui aussi une activité : ce qui a repris en dernier est vraiment ce qui est devant.
Piège : un accès révoqué ressemble à du silence
Si l’utilisateur désactive l’accès aux données d’utilisation pendant que le service tourne, queryEvents ne lève pas d’exception. Il ne renvoie juste rien. Ta dernière lecture (« Instagram est devant ») devient périmée et le solde baisse indéfiniment. Donc chaque interrogation vérifie d’abord l’app-op et arrête le chrono si l’accès a disparu :
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
}
Écran éteint, on arrête tout
Quand l’écran s’éteint, le service met en banque la session ouverte et arrête complètement d’interroger. Personne n’utilise une appli écran éteint, et une boucle à 1 Hz qui ne tourne que quand tu regardes ton téléphone ne coûte pas grand-chose. La première interrogation après le rallumage de l’écran gère aussi le changement de jour (remise à zéro à minuit, base quotidienne), donc ça se fait avant que l’utilisateur n’arrive sur une appli bloquée, même s’il n’ouvre jamais HonorFit.
L’overlay de blocage
Sans accessibilité, pas de GLOBAL_ACTION_HOME, donc tu ne peux pas éjecter l’utilisateur d’une appli. À la place, tu la recouvres : une fenêtre TYPE_APPLICATION_OVERLAY avec un bouton « gagner du temps » et un bouton « retour à l’accueil ».
- Couvre tout l’écran. Par défaut, l’overlay est décalé par les barres système et l’appli bloquée dépasse sur les bords.
FLAG_LAYOUT_NO_LIMITSrègle ça.FLAG_LAYOUT_IN_SCREENest obsolète depuis l’API 30, donc ajoute-le seulement en dessous de R. - « Retour à l’accueil » doit vraiment ramener à l’accueil. Masquer l’overlay ne fait que révéler à nouveau l’appli bloquée, et l’interrogation suivante remet le blocage. Lance plutôt le launcher :
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Lance d’abord, masque ensuite. Le bouton pour gagner du temps ouvre HonorFit, ce qui est un démarrage d’activité depuis l’arrière-plan. Android l’autorise parce que l’appli a une fenêtre d’overlay visible. Donc démarre l’activité pendant que l’overlay est encore affiché, puis retire-le. Dans l’autre sens, le lancement est bloqué sans un bruit.
- Pas d’autorisation de superposition ? Continue de compter quand même. Si « Superposition sur d’autres applis » n’est pas accordée, le service continue de faire baisser le solde honnêtement. Il ne peut juste pas dessiner l’écran de blocage tant que l’autorisation n’est pas revenue.
Garder le service en vie
C’est la partie la plus longue à mettre au point, et ça ne dépend presque pas de ton code.
START_STICKY ne suffit pas
Les « applications en veille » de Samsung, MIUI, OnePlus et compagnie tuent les services en arrière-plan, puis avalent le redémarrage sticky. Un bloqueur, c’est le pire cas : les gens ne l’ouvrent pas tant qu’il leur reste du temps, donc personne ne remarque que la surveillance a disparu. HonorFit programme toutes les 15 minutes une alarme répétitive inexacte et sans réveil qui relance le service. Elle ne réveille jamais le téléphone, et les alarmes reportées pendant que l’écran était éteint se déclenchent peu après son rallumage, ce qui est exactement le moment où la surveillance compte.
Android 12+ ne te laisse pas toujours le démarrer
Démarrer un service de premier plan depuis l’arrière-plan (une alarme, un redémarrage sticky) lève ForegroundServiceStartNotAllowedException, sauf si tu bénéficies d’une exemption. Deux comptent ici : l’utilisateur a désactivé l’optimisation de la batterie pour l’appli, ou l’appli détient SYSTEM_ALERT_WINDOW (celle-là n’aide qu’avant Android 15). Donc :
- Attrape l’exception et abandonne sans bruit. Faire planter le processus n’aide personne, et la prochaine ouverture de l’appli démarrera le service de toute façon.
- Demande à l’utilisateur de régler l’utilisation de la batterie sur « Non restreinte ». Les Réglages d’HonorFit ont une carte « Fiabilité en arrière-plan » qui l’emmène sur le bon écran. Sur certains téléphones, ce réglage fait toute la différence entre « ça marche » et « ça ne marche pas ».
Redémarrages et mises à jour
Un receiver pour BOOT_COMPLETED et MY_PACKAGE_REPLACED relance la surveillance. Le deuxième est facile à oublier : une mise à jour automatique depuis Play tue ton processus et ne redémarre jamais ses services, donc sans lui, chaque mise à jour éteint discrètement ton bloqueur jusqu’à ce que l’utilisateur ouvre l’appli. Les deux broadcasts sont exemptés de la restriction de démarrage en arrière-plan, et un receiver exported="false" les reçoit quand même.
Les petits détails
- Certaines applis ne doivent jamais pouvoir être bloquées : les Paramètres, l’horloge (les alarmes !) et Sécurité personnelle, plus les variantes des constructeurs comme le paquet d’horloge de Samsung. Filtre-les de la liste de blocage dans le code natif, pas seulement dans le sélecteur.
- Le service tourne sans JS, donc il ne peut pas appeler
t(). L’appli pousse les textes de l’overlay et de la notification dans lesSharedPreferencesdans la langue actuelle, et le service se rabat sur l’anglais tant qu’ils ne sont pas arrivés. - Une session restée ouverte pendant un redémarrage du service ne pouvait pas faire baisser le solde tant que le service était mort. Au démarrage, mets en banque le temps écoulé (plafonné) et commence une nouvelle session, pour que personne ne soit facturé pour du temps que le service n’était pas là pour voir.
- Android 14+ permet aux utilisateurs de balayer les notifications de service de premier plan. Le service continue de la mettre à jour (avec une déduplication pour ne pas la republier chaque seconde), donc elle revient.
Les compromis
Cette approche est moins stricte que l’accessibilité, et je veux être franc là-dessus :
- Une appli bloquée reste visible pendant au plus un intervalle d’interrogation (environ une seconde) avant que l’overlay n’arrive.
- L’appli bloquée continue de tourner sous l’overlay. Si une vidéo était en cours, tu peux encore l’entendre jusqu’à ce que tu touches « retour à l’accueil ».
- Ça dépend du bon vouloir du constructeur, qui doit laisser ton service tranquille, d’où l’importance du réglage de la batterie.
Pour un bloqueur fondé sur le système de l’honneur, c’est le bon compromis. HonorFit n’essaie pas d’être incassable. Quelqu’un qui veut vraiment tricher peut juste noter des répétitions qu’il n’a pas faites. Le but, c’est une pause et un prix, pas une prison. En échange, tu as un écran d’autorisation qui parle d’« accès aux données d’utilisation » plutôt que de « contrôle total », une validation sur Play bien plus simple, et un service qui ne lit qu’une seule chose : le nom de paquet de l’appli au premier plan.
Si tu développes aussi pour iOS, c’est un tout autre monde là-bas. J’ai écrit sur ce que la doc de Temps d’écran d’Apple ne dit pas.
HonorFit Blocker est un bloqueur d’applis gratuit sur iPhone et Android. Gagne ton temps d’écran avec des pompes, des squats ou n’importe quel exercice de ton choix. Système de l’honneur, pas de caméra, pas de compte, rien ne quitte ton téléphone.