Einen Android-App-Blocker ohne AccessibilityService bauen
Ich mache HonorFit, einen App-Blocker, bei dem du dir Bildschirmzeit mit Liegestützen verdienst (Ehrensystem, keine Kamera). Die erste Android-Version hat Apps so gesperrt wie die meisten Android-Blocker: mit einem AccessibilityService. Im August habe ich den rausgeworfen und durch UsageStatsManager, einen Foreground-Service und ein Overlay-Fenster ersetzt. Die Sperre ist dadurch etwas weicher geworden, das Onboarding viel weniger furchteinflößend und die Prüfung bei Google Play viel einfacher.
Hier steht, wie diese Architektur aussieht und worüber ich unterwegs gestolpert bin. Der ganze Code ist Kotlin in einem lokalen Expo-Modul, aber nichts davon ist spezifisch für React Native.
Warum die Accessibility API rausfliegt
Ein Accessibility-Service ist das mächtigste Werkzeug, das du auf Android bekommst. Du erfährst von jedem Fensterwechsel in dem Moment, in dem er passiert, und performGlobalAction(GLOBAL_ACTION_HOME) wirft den Nutzer sofort aus einer gesperrten App. Deshalb nutzen ihn so viele Blocker. Der Preis dafür:
- Die Berechtigungsabfrage ist zum Fürchten. Android sagt dem Nutzer, dass die App alles auf dem Bildschirm sehen und in seinem Namen handeln kann. Für eine App, deren Versprechen „nichts verlässt dein Handy“ lautet, macht sich das am ersten Tag schlecht, und viele steigen genau da aus.
- Google Play behandelt sie als sensible API. Wenn du kein Bedienungshilfe-Tool bist, brauchst du einen gut sichtbaren Hinweis in der App, eine Erklärung in der Play Console und einen Prüfer, der deinen Anwendungsfall für in Ordnung hält. Das Modal mit dem Hinweis hatte ich schon gebaut. Ich brauche es lieber gar nicht.
- Sie sieht viel mehr, als ein Blocker braucht. Ein Blocker braucht genau eine Information: welche App im Vordergrund ist.
Genau diese Information gibt es auch woanders: in den Nutzungsereignissen, auf denen Digital Wellbeing aufbaut.
Der Aufbau
Drei Berechtigungen, alle vom Nutzer in den Einstellungen erteilt:
| Berechtigung | Wofür |
|---|---|
PACKAGE_USAGE_STATS („Zugriff auf Nutzungsdaten“) | UsageEvents lesen, um zu erfahren, welche App vorne ist. Wird nie zur Laufzeit erteilt; du schickst den Nutzer in die Einstellungen und prüfst den App-Op. |
SYSTEM_ALERT_WINDOW („Über anderen Apps einblenden“) | Den Sperrbildschirm über eine gesperrte App zeichnen. |
FOREGROUND_SERVICE_SPECIAL_USE | Der Monitor-Service. App-Sperren passt in keinen der aufgelisteten Foreground-Service-Typen, also ist es specialUse, mit einem Subtyp-String, der erklärt, warum. |
Die Bausteine:
ForegroundMonitorServiceläuft nur, solange es etwas durchzusetzen gibt (eine nicht leere Sperrliste und erteilter Zugriff auf Nutzungsdaten). Einmal pro Sekunde, und nur bei eingeschaltetem Bildschirm, fragt erUsageStatsManager, was in den Vordergrund gekommen ist. Eine gesperrte App mit Restzeit zehrt vom angesparten Guthaben; bei null zeigt er das Overlay.- Seine Pflicht-Benachrichtigung ist die Kontingentanzeige („noch 12 Min.“), so erledigt das, was Android dir aufzwingt, eine echte Aufgabe.
- Der gesamte Zustand liegt in
SharedPreferences, nicht in JS. Der Service muss weiterlaufen, wenn die React-Native-Runtime tot ist, und das ist sie meistens. Die JS-Seite zahlt Zeit ein und liest das Guthaben über das Modul; die Durchsetzung gehört dem Service.
Der Manifest-Eintrag für den 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>
Außerdem begründest du specialUse in der Foreground-Service-Erklärung in der Play Console. Es hilft, an beiden Stellen dieselbe Geschichte zu erzählen.
UsageStats abfragen (und die Schreibverzögerung)
Die Kernschleife ist klein. Geh die Ereignisse seit der letzten Abfrage durch und merk dir das Paket des letzten MOVE_TO_FOREGROUND (das ist dieselbe Konstante wie ACTIVITY_RESUMED, in API 29 umbenannt):
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
}
}
// Beim nächsten Mal die letzten 15 s erneut scannen: siehe unten.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
Ein schöner Nebeneffekt gegenüber Accessibility-Events: Die Tastatur und die Benachrichtigungsleiste sind keine Activities, also tauchen sie nie als falsches „der Nutzer hat die App verlassen“ auf.
Stolperfalle: Ereignisse kommen verspätet an
Meine erste Version hat das Fenster strikt vorwärts geschoben: Jede Abfrage hat [lastEnd, now] abgefragt. Das hat meistens funktioniert, aber auf einem frisch gestarteten Handy wurde das allererste Öffnen einer gesperrten App nie erkannt. UsageStats schreibt Ereignisse mit Verzögerung in seinen Speicher, ein Ereignis mit dem Zeitstempel 10:00:00.900 kann also nach einer Abfrage auftauchen, die 10:00:01 schon abgedeckt hat. Ein strikt vorrückendes Fenster überspringt es für immer.
Die Lösung: Jede Abfrage scannt eine nachlaufende Überlappung erneut (ich nehme 15 Sekunden). Dasselbe Ereignis zweimal zu sehen schadet nicht, denn das letzte Resume im Fenster gewinnt so oder so. Ein Ereignis, das mehr als 15 Sekunden zu spät eintrifft, geht trotzdem verloren, aber das habe ich noch nicht erlebt.
Stolperfalle: Die erste Abfrage braucht einen langen Rückblick
Wenn der Service startet (nach einem Neustart oder wenn der Bildschirm angeht), gibt es keine „zuletzt bekannte App“. Wenn der Nutzer schon 20 Minuten in TikTok sitzt und die erste Abfrage nur eine Minute zurückschaut, sieht sie nichts, bis er die App wechselt. Deshalb scannt die erste Abfrage die letzte Stunde. Das ist unbedenklich, weil der Launcher auch eine Activity ist: Was zuletzt fortgesetzt wurde, ist wirklich das, was vorne ist.
Stolperfalle: Entzogener Zugriff sieht aus wie Stille
Wenn der Nutzer den Zugriff auf Nutzungsdaten abschaltet, während der Service läuft, wirft queryEvents keinen Fehler. Es liefert einfach nichts. Dein letzter Stand („Instagram ist vorne“) veraltet, und das Guthaben läuft für immer ab. Deshalb prüft jede Abfrage zuerst den App-Op und hält die Uhr an, wenn er weg ist:
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
}
Bildschirm aus heißt Stopp
Wenn der Bildschirm ausgeht, verbucht der Service die offene Sitzung und hört komplett auf abzufragen. Bei ausgeschaltetem Bildschirm nutzt niemand eine App, und eine 1-Hz-Schleife, die nur läuft, während du aufs Handy schaust, kostet wenig. Die erste Abfrage nach dem Wiedereinschalten kümmert sich außerdem um den Tageswechsel (Zurücksetzen um Mitternacht, Tagesbasis), damit das passiert, bevor der Nutzer eine gesperrte App erreicht, auch wenn er HonorFit nie öffnet.
Das Sperr-Overlay
Ohne Accessibility gibt es kein GLOBAL_ACTION_HOME, du kannst den Nutzer also nicht aus einer App werfen. Stattdessen deckst du sie ab: ein TYPE_APPLICATION_OVERLAY-Fenster mit einem Button „Mehr Zeit verdienen“ und einem Button „zum Startbildschirm“.
- Deck das ganze Display ab. Standardmäßig wird das Overlay um die Systemleisten eingerückt, und die gesperrte App lugt an den Rändern hervor.
FLAG_LAYOUT_NO_LIMITSbehebt das.FLAG_LAYOUT_IN_SCREENist seit API 30 veraltet, also füg es nur unterhalb von R hinzu. - „Zum Startbildschirm“ muss wirklich zum Startbildschirm gehen. Das Overlay auszublenden legt nur die gesperrte App wieder frei, und die nächste Abfrage setzt die Sperre wieder drauf. Starte stattdessen den Launcher:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Erst starten, dann ausblenden. Der Verdienen-Button öffnet HonorFit, und das ist ein Activity-Start aus dem Hintergrund. Android erlaubt ihn, weil die App ein sichtbares Overlay-Fenster hat. Starte die Activity also, solange das Overlay noch angezeigt wird, und entferne es danach. Andersherum wird der Start stillschweigend blockiert.
- Keine Overlay-Berechtigung? Trotzdem weiterzählen. Wenn „Über anderen Apps einblenden“ nicht erteilt ist, zieht der Service das Guthaben trotzdem ehrlich ab. Er kann die Sperre nur nicht zeichnen, bis die Berechtigung wieder da ist.
Den Service am Leben halten
Das ist der Teil, bei dem es am längsten dauert, bis er sitzt, und meistens liegt es nicht an deinem Code.
START_STICKY reicht nicht
Samsungs „schlafende Apps“, MIUI, OnePlus und Co. beenden Hintergrund-Services und schlucken dann den Sticky-Neustart. Ein Blocker ist dafür der schlimmste Fall: Solange die Leute noch Zeit übrig haben, öffnen sie ihn nicht, also merkt niemand, dass der Monitor weg ist. HonorFit stellt alle 15 Minuten einen ungenauen, nicht weckenden, wiederkehrenden Alarm, der den Service wieder startet. Er weckt das Handy nie, und Alarme, die bei ausgeschaltetem Bildschirm aufgeschoben wurden, feuern kurz nach dem Einschalten, also genau dann, wenn der Monitor wichtig ist.
Android 12+ lässt dich ihn nicht immer starten
Einen Foreground-Service aus dem Hintergrund zu starten (ein Alarm, ein Sticky-Neustart) wirft ForegroundServiceStartNotAllowedException, außer du hast eine Ausnahme. Zwei, die hier zählen: Der Nutzer hat die Akkuoptimierung für die App ausgeschaltet, oder die App hat SYSTEM_ALERT_WINDOW (das hilft nur vor Android 15). Also:
- Fang die Exception ab und brich still ab. Den Prozess abstürzen zu lassen hilft niemandem, und beim nächsten Öffnen der App startet der Service sowieso.
- Bitte den Nutzer, die Akkunutzung auf „Uneingeschränkt“ zu stellen. In den Einstellungen von HonorFit gibt es eine Karte „Zuverlässigkeit im Hintergrund“, die ihn zum richtigen Bildschirm führt. Auf manchen Handys entscheidet diese Einstellung darüber, ob es funktioniert oder nicht.
Neustarts und Updates
Ein Receiver für BOOT_COMPLETED und MY_PACKAGE_REPLACED startet das Monitoring neu. Den zweiten vergisst man leicht: Ein automatisches Update über Play beendet deinen Prozess und startet seine Services nie neu, ohne ihn schaltet also jedes Update deinen Blocker still ab, bis der Nutzer die App öffnet. Beide Broadcasts sind von der Einschränkung für Starts aus dem Hintergrund ausgenommen, und ein Receiver mit exported="false" bekommt sie trotzdem.
Kleinere Dinge
- Manche Apps dürfen nie sperrbar sein: die Einstellungen, die Uhr (Wecker!) und Persönliche Sicherheit, plus Hersteller-Varianten wie das Uhr-Paket von Samsung. Filter sie im nativen Code aus der Sperrliste, nicht nur in der Auswahl.
- Der Service läuft ohne JS, also kann er
t()nicht aufrufen. Die App schiebt die Texte für Overlay und Benachrichtigung in der aktuellen Sprache in dieSharedPreferences, und bis sie da sind, fällt der Service auf Englisch zurück. - Eine Sitzung, die über einen Service-Neustart hinweg offen war, konnte nicht abziehen, solange der Service tot war. Beim Start verbuchst du die vergangene Zeit (begrenzt) und beginnst eine neue Sitzung, damit niemandem Zeit berechnet wird, die der Service gar nicht sehen konnte.
- Ab Android 14 können Nutzer Foreground-Service-Benachrichtigungen wegwischen. Der Service aktualisiert sie laufend (mit einer Deduplizierung, damit er sie nicht jede Sekunde neu postet), also kommt sie zurück.
Die Kompromisse
Dieser Ansatz ist weicher als Accessibility, und das will ich offen sagen:
- Eine gesperrte App ist bis zu einem Abfrageintervall lang (etwa eine Sekunde) sichtbar, bevor das Overlay erscheint.
- Die gesperrte App läuft unter dem Overlay weiter. Wenn ein Video lief, hörst du es noch, bis du auf „zum Startbildschirm“ tippst.
- Es hängt davon ab, dass der Hersteller deinen Service in Ruhe lässt, deshalb ist die Akku-Einstellung so wichtig.
Für einen Blocker nach dem Ehrensystem ist das der richtige Kompromiss. HonorFit will nicht unknackbar sein. Wer unbedingt schummeln will, kann einfach Wiederholungen eintragen, die er nicht gemacht hat. Es geht um eine Pause und einen Preis, nicht um ein Gefängnis. Im Gegenzug bekommst du einen Berechtigungsbildschirm, auf dem „Zugriff auf Nutzungsdaten“ statt „volle Kontrolle“ steht, eine viel einfachere Play-Prüfung und einen Service, der genau eine Sache liest: den Paketnamen der App, die vorne ist.
Wenn du auch für iOS baust: Da drüben ist es eine komplett andere Welt. Ich habe aufgeschrieben, was Apples Screen-Time-Dokumentation dir nicht sagt.
HonorFit Blocker ist ein kostenloser App-Blocker für iPhone und Android. Verdien dir Bildschirmzeit mit Liegestützen, Kniebeugen oder jeder Übung, die du willst. Ehrensystem, keine Kamera, kein Konto, nichts verlässt dein Handy.