Een Android app blocker bouwen zonder AccessibilityService
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:
- De toestemmingsprompt is doodeng. Android vertelt de gebruiker dat de app alles op het scherm kan zien en namens hem kan handelen. Voor een app waarvan de belofte ‘er gaat niks van je telefoon af’ is, staat het slecht om daar op dag één om te vragen, en veel mensen haken precies daar af.
- Google Play behandelt het als een gevoelige API. Ben je geen toegankelijkheidstool, dan heb je een duidelijke melding in de app nodig, een verklaring in Play Console en een reviewer die vindt dat jouw gebruik oké is. De modal met die melding had ik al gebouwd. Ik heb hem liever helemaal niet nodig.
- Het ziet veel meer dan een blocker nodig heeft. Een blocker heeft precies één gegeven nodig: welke app op de voorgrond staat.
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:
| Toestemming | Waarvoor |
|---|---|
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_USE | De monitorservice. Apps blokkeren past bij geen van de genoemde foreground-servicetypes, dus het wordt specialUse, met een subtype-string die uitlegt waarom. |
De onderdelen:
ForegroundMonitorServicedraait alleen zolang er iets af te dwingen valt (een blokkeerlijst die niet leeg is en gebruikstoegang die is gegeven). Eén keer per seconde, en alleen als het scherm aan staat, vraagt hijUsageStatsManagerwat er naar de voorgrond kwam. Een geblokkeerde app met tijd over loopt het opgespaarde saldo leeg; bij nul toont hij de overlay.- Zijn verplichte melding is de tegoedweergave (‘nog 12 min’), dus het ding dat Android je oplegt, doet echt iets nuttigs.
- Alle state staat in
SharedPreferences, niet in JS. De service moet blijven werken als de React Native-runtime dood is, en dat is hij meestal. De JS-kant stort tijd en leest het saldo via de module; de service is eigenaar van het afdwingen.
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’.
- Dek het hele scherm af. Standaard springt de overlay in voor de systeembalken, en de geblokkeerde app piept langs de randen tevoorschijn.
FLAG_LAYOUT_NO_LIMITSlost dat op.FLAG_LAYOUT_IN_SCREENis verouderd sinds API 30, dus voeg die alleen toe onder R. - ‘Naar het startscherm’ moet echt naar het startscherm gaan. De overlay verbergen laat alleen de geblokkeerde app weer zien, en de volgende poll zet de vergrendeling er weer op. Start in plaats daarvan de launcher:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Eerst starten, dan verbergen. De verdienknop opent HonorFit, en dat is een activity-start vanaf de achtergrond. Android staat dat toe omdat de app een zichtbaar overlayvenster heeft. Start de activity dus terwijl de overlay nog zichtbaar is, en haal hem daarna weg. Doe je het andersom, dan wordt de start stilletjes geblokkeerd.
- Geen overlaytoestemming? Blijf toch tellen. Is ‘Display over other apps’ niet gegeven, dan schrijft de service het saldo nog steeds eerlijk af. Hij kan alleen de vergrendeling niet tekenen tot de toestemming terug is.
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:
- Vang de exception af en stop stilletjes. Het proces laten crashen helpt niemand, en de volgende keer dat de app opent, start de service toch.
- Vraag de gebruiker om het batterijgebruik op ‘Onbeperkt’ te zetten. In de instellingen van HonorFit staat een kaart ‘Betrouwbaarheid op de achtergrond’ die hem naar het juiste scherm leidt. Op sommige telefoons is die instelling het verschil tussen werken en niet werken.
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
- Sommige apps mogen nooit blokkeerbaar zijn: Instellingen, de klok (wekkers!) en Persoonlijke veiligheid, plus varianten van fabrikanten zoals het klokpakket van Samsung. Filter ze in de native code uit de blokkeerlijst, niet alleen in de kiezer.
- De service draait zonder JS, dus hij kan
t()niet aanroepen. De app zet de teksten voor de overlay en de melding in de huidige taal inSharedPreferences, en tot die er zijn, valt de service terug op Engels. - Een sessie die openstond tijdens een herstart van de service kon niet aftellen zolang de service dood was. Boek bij het starten de verstreken tijd (met een maximum) en begin een nieuwe sessie, zodat niemand betaalt voor tijd die de service niet kon zien.
- Vanaf Android 14 kunnen gebruikers meldingen van foreground services wegvegen. De service blijft de melding bijwerken (met een dedupe zodat hij hem niet elke seconde opnieuw plaatst), dus hij komt terug.
De afwegingen
Deze aanpak is zachter dan accessibility, en daar wil ik eerlijk over zijn:
- Een geblokkeerde app is tot één pollinterval (ongeveer een seconde) zichtbaar voordat de overlay verschijnt.
- De geblokkeerde app blijft onder de overlay draaien. Speelde er een video, dan hoor je die nog tot je op ‘naar het startscherm’ tikt.
- Het hangt ervan af of de fabrikant je service met rust laat, en daarom is die batterij-instelling zo belangrijk.
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.