Créer un bloqueur d’applis Android sans AccessibilityService

UsageStatsManager, un service de premier plan et un overlay, vus depuis une appli publiée. Par Thomas Willheim · 9 novembre 2026

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 :

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_USELe 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 :

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 ».

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 :

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

Les compromis

Cette approche est moins stricte que l’accessibilité, et je veux être franc là-dessus :

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.

À lire aussi