Een Android app blocker bouwen zonder AccessibilityService

UsageStatsManager, een foreground service en een overlay, uit een app die echt in de store staat. Door Thomas Willheim · 9 november 2026

Ik maak HonorFit, een app blocker waarmee je schermtijd verdient door op te drukken (op erewoord, geen camera). De eerste Android-versie blokkeerde apps zoals de meeste Android-blockers dat doen: met een AccessibilityService. In augustus heb ik die eruit gesloopt en vervangen door UsageStatsManager, een foreground service en een overlayvenster. Het blokkeren werd een tikje zachter, de onboarding een stuk minder eng, en de review bij Google Play een stuk eenvoudiger.

Hier lees je hoe die architectuur eruitziet en waar ik onderweg tegenaan liep. Alle code is Kotlin in een lokale Expo-module, maar niets hiervan is specifiek voor React Native.

Waarom de Accessibility API eruit moest

Een accessibility service is het krachtigste gereedschap dat je op Android krijgt. Je hoort van elke vensterwissel op het moment dat die gebeurt, en performGlobalAction(GLOBAL_ACTION_HOME) gooit de gebruiker meteen uit een geblokkeerde app. Daarom gebruiken zoveel blockers het. De kosten:

Dat ene gegeven is ook ergens anders te krijgen: in de gebruiksgebeurtenissen waar Digital Wellbeing op draait.

De opzet

Drie toestemmingen, allemaal door de gebruiker gegeven in Instellingen:

ToestemmingWaarvoor
PACKAGE_USAGE_STATS (‘Usage access’)UsageEvents lezen om te weten welke app vooraan staat. Wordt nooit tijdens runtime gegeven; je stuurt de gebruiker naar het scherm in Instellingen en controleert de app-op.
SYSTEM_ALERT_WINDOW (‘Display over other apps’)Het vergrendelscherm over een geblokkeerde app tekenen.
FOREGROUND_SERVICE_SPECIAL_USEDe monitorservice. Apps blokkeren past bij geen van de genoemde foreground-servicetypes, dus het wordt specialUse, met een subtype-string die uitlegt waarom.

De onderdelen:

De manifestregel voor de service:

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

Je onderbouwt specialUse ook in de foreground-serviceverklaring in Play Console. Het helpt om op beide plekken hetzelfde verhaal te vertellen.

UsageStats pollen (en de schrijfvertraging)

De kernlus is klein. Loop de gebeurtenissen sinds de vorige poll door en onthoud het pakket van de laatste MOVE_TO_FOREGROUND (dat is dezelfde constante als ACTIVITY_RESUMED, hernoemd in 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
  }
}
// Volgende keer de laatste 15 s opnieuw scannen: zie hieronder.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)

Een mooi bijeffect ten opzichte van accessibility-events: het toetsenbord en het meldingenpaneel zijn geen activities, dus die verschijnen nooit als nep-‘de gebruiker heeft de app verlaten’.

Valkuil: gebeurtenissen komen te laat binnen

Mijn eerste versie schoof het venster strikt vooruit: elke poll vroeg [lastEnd, now] op. Dat werkte meestal, maar op een telefoon die net was opgestart werd de allereerste keer dat een geblokkeerde app openging nooit gezien. UsageStats schrijft gebeurtenissen met vertraging weg, dus een gebeurtenis met tijdstempel 10:00:00.900 kan opduiken na een poll die 10:00:01 al had gedekt. Een venster dat strikt vooruitschuift, slaat die voor altijd over.

De oplossing: elke poll scant een overlap naar achteren opnieuw (ik gebruik 15 seconden). Dezelfde gebeurtenis twee keer zien kan geen kwaad, want de laatste resume in het venster wint hoe dan ook. Een gebeurtenis die meer dan 15 seconden te laat binnenkomt, raakt nog steeds kwijt, maar die heb ik nog niet gezien.

Valkuil: de eerste poll moet ver terugkijken

Als de service start (na een herstart, of als het scherm aangaat), is er geen ‘laatst bekende app’. Zit de gebruiker al 20 minuten in TikTok en kijkt de eerste poll maar een minuut terug, dan ziet hij niets tot de gebruiker van app wisselt. Daarom scant de eerste poll het afgelopen uur. Dat is veilig, want de launcher is ook een activity: wat het laatst werd hervat, staat echt vooraan.

Valkuil: ingetrokken toegang ziet eruit als stilte

Zet de gebruiker gebruikstoegang uit terwijl de service draait, dan gooit queryEvents geen fout. Het geeft gewoon niets terug. Je laatste meting (‘Instagram staat vooraan’) raakt verouderd en het saldo loopt eindeloos leeg. Daarom controleert elke poll eerst de app-op en zet hij de klok stil als die weg is:

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
}

Scherm uit betekent stoppen

Als het scherm uitgaat, boekt de service de open sessie en stopt hij helemaal met pollen. Niemand gebruikt een app met het scherm uit, en een lus van 1 Hz die alleen draait terwijl je naar je telefoon kijkt, kost weinig. De eerste poll nadat het scherm weer aangaat, regelt ook de dagwissel (reset om middernacht, dagelijkse basis), zodat dat gebeurt voordat de gebruiker bij een geblokkeerde app komt, ook als hij HonorFit nooit opent.

De vergrendeloverlay

Zonder accessibility is er geen GLOBAL_ACTION_HOME, dus je kunt de gebruiker niet uit een app gooien. In plaats daarvan dek je hem af: een TYPE_APPLICATION_OVERLAY-venster met een knop ‘verdien meer tijd’ en een knop ‘naar het startscherm’.

De service in leven houden

Dit is het deel waar het het langst duurt voordat het goed zit, en meestal ligt het niet aan je code.

START_STICKY is niet genoeg

De ‘slapende apps’ van Samsung, MIUI, OnePlus en consorten stoppen achtergrondservices en slikken daarna de sticky herstart in. Een blocker is hier het slechtste geval: mensen openen hem niet zolang ze nog tijd over hebben, dus niemand merkt dat de monitor weg is. HonorFit zet elke 15 minuten een onnauwkeurig, niet-wekkend, herhalend alarm dat de service opnieuw start. Dat wekt de telefoon nooit, en alarmen die zijn uitgesteld terwijl het scherm uit stond, gaan kort nadat het weer aangaat af, precies wanneer de monitor ertoe doet.

Android 12+ laat je hem niet altijd starten

Een foreground service starten vanaf de achtergrond (een alarm, een sticky herstart) gooit ForegroundServiceStartNotAllowedException, tenzij je een uitzondering hebt. Twee die hier tellen: de gebruiker heeft batterijoptimalisatie voor de app uitgezet, of de app heeft SYSTEM_ALERT_WINDOW (die helpt alleen vóór Android 15). Dus:

Herstarts en updates

Een receiver voor BOOT_COMPLETED en MY_PACKAGE_REPLACED start het monitoren opnieuw. De tweede vergeet je makkelijk: een automatische update via Play stopt je proces en start de services nooit opnieuw, dus zonder die receiver zet elke update je blocker stilletjes uit tot de gebruiker de app opent. Beide broadcasts zijn uitgezonderd van de beperking op starten vanaf de achtergrond, en een receiver met exported="false" krijgt ze toch.

Kleinere dingen

De afwegingen

Deze aanpak is zachter dan accessibility, en daar wil ik eerlijk over zijn:

Voor een blocker op erewoord is dat de juiste afweging. HonorFit probeert niet onkraakbaar te zijn. Wie per se wil valsspelen, kan gewoon reps loggen die hij niet heeft gedaan. Het gaat om een pauze en een prijs, niet om een gevangenis. In ruil daarvoor krijg je een toestemmingsscherm waarop ‘gebruikstoegang’ staat in plaats van ‘volledige controle’, een veel eenvoudigere Play-review en een service die precies één ding leest: de pakketnaam van de app die vooraan staat.

Bouw je ook voor iOS? Daar is het een totaal andere wereld. Ik heb opgeschreven wat de Screen Time-documentatie van Apple je niet vertelt.

HonorFit Blocker is een gratis app blocker voor iPhone en Android. Verdien schermtijd met push-ups, squats of elke oefening die je kiest. Op erewoord, geen camera, geen account, en er gaat niks van je telefoon af.

Lees verder