Building an Android app blocker without an AccessibilityService
I make HonorFit, an app blocker where you earn screen time by doing pushups (honor system, no camera). The first Android version blocked apps the way most Android blockers do: with an AccessibilityService. In August I ripped that out and replaced it with UsageStatsManager, a foreground service and an overlay window. Blocking got a little softer, onboarding got a lot less scary, and the Play review got a lot simpler.
This is what that architecture looks like and the stuff that bit me along the way. All the code is Kotlin inside a local Expo module, but nothing here is React Native specific.
Why drop the Accessibility API
An accessibility service is the strongest tool you get on Android. You hear about every window change the instant it happens, and performGlobalAction(GLOBAL_ACTION_HOME) kicks the user out of a blocked app on the spot. That's why so many blockers use it. The costs:
- The permission prompt is terrifying. Android tells the user the app can see everything on screen and act on their behalf. For an app whose pitch is “nothing leaves your phone”, asking for that on day one is a bad look, and a lot of people bail right there.
- Google Play treats it as a sensitive API. If you aren't an accessibility tool, you need a prominent in-app disclosure, a declaration in Play Console, and a reviewer who agrees your use case is OK. I had the disclosure modal built. I'd rather not need it.
- It sees way more than a blocker needs. A blocker needs exactly one fact: which app is in the foreground.
That one fact is available elsewhere: the usage events behind Digital Wellbeing.
The shape of it
Three permissions, all granted by the user in Settings:
| Permission | What it's for |
|---|---|
PACKAGE_USAGE_STATS (“Usage access”) | Reading UsageEvents to learn which app is in front. Never granted at runtime; you send the user to the Settings screen and check the app-op. |
SYSTEM_ALERT_WINDOW (“Display over other apps”) | Drawing the lock screen on top of a blocked app. |
FOREGROUND_SERVICE_SPECIAL_USE | The monitor service. App blocking doesn't fit any of the listed foreground-service types, so it's specialUse with a subtype string that explains why. |
The pieces:
ForegroundMonitorServiceruns only while there's something to enforce (a non-empty blocklist and Usage access granted). Once a second, and only while the screen is on, it asksUsageStatsManagerwhat came to the foreground. A blocked app with time left drains the banked balance; at zero it shows the overlay.- Its mandatory notification is the quota display (“12 min left”), so the thing Android forces on you does a real job.
- All state lives in
SharedPreferences, not in JS. The service has to keep working when the React Native runtime is dead, which is most of the time. The JS side deposits time and reads the balance through the module; the service owns enforcement.
The manifest entry for the 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>
You also justify specialUse in Play Console's foreground service declaration. Telling the same story in both places helps.
Polling UsageStats (and its write lag)
The core loop is small. Scan the events since the last poll and keep the last MOVE_TO_FOREGROUND package (it's the same constant as ACTIVITY_RESUMED, renamed 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
}
}
// Re-scan the last 15 s next time: see below.
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)
A nice side effect over accessibility events: the keyboard and the notification shade aren't activities, so they never show up as fake “the user left the app” exits.
Gotcha: events arrive late
My first version moved the window strictly forward: each poll queried [lastEnd, now]. It mostly worked, then on a cold-started phone the very first blocked app open was never seen. UsageStats writes events to its store with a lag, so an event stamped 10:00:00.900 can show up after a poll that already covered 10:00:01. A strictly advancing window skips it forever.
The fix: every poll re-scans a trailing overlap (I use 15 seconds). Seeing the same event twice is harmless, since the last resume in the window wins either way. An event that lands more than 15 seconds late still gets lost, but I haven't seen one.
Gotcha: the first poll needs a long lookback
When the service starts (after a restart, or when the screen comes on) there's no “last known app”. If the user has sat in TikTok for 20 minutes and the first poll only looks back a minute, it sees nothing until they switch apps. So the first poll scans the last hour. That's safe because the launcher is an activity too: whatever resumed last really is what's in front.
Gotcha: revoked access looks like silence
If the user turns Usage access off while the service runs, queryEvents doesn't throw. It just returns nothing. Your last reading (“Instagram is in front”) goes stale and the balance drains forever. So every poll checks the app-op first and stops the clock if it's gone:
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
}
Screen off means stop
When the screen goes off, the service banks the open session and stops polling entirely. Nobody is using an app with the screen off, and a 1 Hz loop that only runs while you're looking at your phone is cheap. The first poll after the screen comes back on also handles the day rollover (midnight reset, daily baseline), so it happens before the user reaches a blocked app, even if they never open HonorFit.
The lock overlay
Without accessibility there's no GLOBAL_ACTION_HOME, so you can't throw the user out of an app. You cover it instead: a TYPE_APPLICATION_OVERLAY window with an “earn more time” button and a “go home” button.
- Cover the whole display. By default the overlay is inset by the system bars and the blocked app peeks out around the edges.
FLAG_LAYOUT_NO_LIMITSfixes that.FLAG_LAYOUT_IN_SCREENwas deprecated in API 30, so only add it below R. - “Go home” has to really go home. Hiding the overlay just reveals the blocked app again, and the next poll puts the lock back. Start the launcher instead:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - Launch, then hide. The earn button opens HonorFit, which is an activity start from the background. Android allows it because the app has a visible overlay window. So start the activity while the overlay is still showing, then remove it. Do it the other way around and the launch gets silently blocked.
- No overlay permission? Keep counting anyway. If “Display over other apps” isn't granted, the service still drains the balance honestly. It just can't draw the lock until the permission is back.
Keeping the service alive
This is the part that takes the longest to get right, and it's mostly not about your code.
START_STICKY isn't enough
Samsung's “sleeping apps”, MIUI, OnePlus and friends kill background services and then swallow the sticky restart. A blocker is the worst case for this: people don't open it while they have time left, so nothing notices the monitor is gone. HonorFit arms an inexact, non-wakeup repeating alarm every 15 minutes that re-asserts the service. It never wakes the phone, and alarms deferred while the screen was off fire soon after it comes back on, which is exactly when the monitor matters.
Android 12+ won't always let you start it
Starting a foreground service from the background (an alarm, a sticky restart) throws ForegroundServiceStartNotAllowedException unless you hold an exemption. Two that matter here: the user has turned battery optimization off for the app, or the app holds SYSTEM_ALERT_WINDOW (that one only helps before Android 15). So:
- Catch the exception and bail out quietly. Crashing the process helps nobody, and the next app open will start the service anyway.
- Ask the user to set battery usage to “Unrestricted”. HonorFit's Settings has a “Background reliability” card that walks them to the right screen. On some phones that setting is the difference between working and not.
Reboots and updates
A receiver for BOOT_COMPLETED and MY_PACKAGE_REPLACED restarts monitoring. The second one is easy to forget: a Play auto-update kills your process and never restarts its services, so without it every update quietly turns your blocker off until the user opens the app. Both broadcasts are exempt from the background-start restriction, and an exported="false" receiver still gets them.
Smaller things
- Some apps must never be blockable: Settings, the clock (alarms!) and Personal Safety, plus OEM variants like Samsung's clock package. Filter them out of the blocklist in native code, not just in the picker.
- The service runs without JS, so it can't call
t(). The app pushes the overlay and notification strings intoSharedPreferencesin the current language, and the service falls back to English until they arrive. - A session left open across a service restart couldn't drain while the service was dead. On start, bank what elapsed (clamped) and begin a fresh session, so nobody gets charged for time the service wasn't there to see.
- Android 14+ lets users swipe away foreground-service notifications. The service keeps updating it (with a dedupe so it isn't reposting every second), so it comes back.
The trade-offs
This approach is softer than accessibility, and I want to be upfront about that:
- A blocked app is visible for up to one poll interval (about a second) before the overlay lands.
- The blocked app keeps running under the overlay. If a video was playing, you can still hear it until you tap “go home”.
- It depends on the OEM leaving your service alone, which is why the battery setting matters so much.
For an honor-system blocker, that's the right trade. HonorFit isn't trying to be unbreakable. Somebody determined to cheat can just log reps they didn't do. The point is a pause and a price, not a prison. In exchange you get a permission screen that says “usage access” instead of “full control”, a much simpler Play review, and a service that reads exactly one thing: the package name of the app in front.
If you're building on iOS too, it's a completely different world over there. I wrote up what Apple's Screen Time docs don't tell you.
HonorFit Blocker is a free app blocker for iPhone and Android. Earn screen time with pushups, squats or any exercise you pick. Honor system, no camera, no account, nothing leaves your phone.