Building an Android app blocker without an AccessibilityService

UsageStatsManager, a foreground service and an overlay, from a shipping app. By Thomas Willheim · November 9, 2026

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:

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:

PermissionWhat 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_USEThe 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:

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.

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:

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

The trade-offs

This approach is softer than accessibility, and I want to be upfront about that:

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.

Keep reading