Almost every app blocker on the Play Store is built on an AccessibilityService. It's the obvious choice: you get a callback every time a window changes, you read the package name, and if it's on the blocked list you throw something over it. Thirty lines and you're done.
The problem is what you had to ask for to get there. An AccessibilityService can read the content of every screen the user looks at, including their banking app. Google's policy position is that if the narrower API can do the job, you use the narrower API - and for app blocking it can. Reviewers do reject blockers over this, and users are increasingly suspicious of the permission screen regardless.
Here's the version without it. Everything below is running in production.
The short answer
Poll UsageStatsManager from a foreground service to learn which app is in the foreground, and launch a translucent activity over anything on the blocked list. Three permissions:
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
PACKAGE_USAGE_STATS isn't a runtime permission. You can't request it with a dialog - the user has to grant it in Settings, and you check it through AppOpsManager:
private fun hasUsageStatsPermission(): Boolean = try {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName
)
} else {
@Suppress("DEPRECATION") appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName
)
}
mode == AppOpsManager.MODE_ALLOWED
} catch (e: Exception) { false }
Send them there with Settings.ACTION_USAGE_ACCESS_SETTINGS. Budget for the fact that this is a real drop-off point in onboarding - it's a scary-looking screen buried in special app access, and no amount of copy makes it feel routine.
Reading the foreground app
The core loop. A foreground service with a Handler posting itself every 900ms:
private val pollRunnable = object : Runnable {
override fun run() {
checkForeground()
handler.postDelayed(this, 900L)
}
}
private fun checkForeground() {
val usm = usageStatsManager ?: return
val now = System.currentTimeMillis()
try {
val events = usm.queryEvents(lastQueryTime, now)
val event = UsageEvents.Event()
while (events.hasNextEvent()) {
events.getNextEvent(event)
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
currentForeground = event.packageName
}
}
} catch (_: Exception) { return }
lastQueryTime = now
// ...check currentForeground against the blocked set
}
Two details in there matter more than they look.
queryEvents returns events, not state. If the user has been sitting in the same app for a minute, nothing changed, so the query legitimately returns nothing. This is the single most common bug in this approach - people query a fixed window, get an empty result, reset their state to "no foreground app", and the lock flickers or drops. Keep the last known package in a field and only overwrite it when an event actually arrives.
Use a rolling window, not a fixed one. [lastQueryTime, now], with lastQueryTime advanced only after a successful read. The event buffer isn't strictly realtime and things can land a few hundred milliseconds late; a fixed [now - 1000, now] window drops those. On startup, backdate it a couple of seconds, or you'll miss the transition that happened just before monitoring began.
Showing the lock
A normal Activity, declared translucent, single instance, and kept out of recents:
<activity android:name=".applock.LockOverlayActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen" android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:exported="false"/>
Launched with FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP or FLAG_ACTIVITY_SINGLE_TOP. singleInstance plus SINGLE_TOP is what stops you stacking six copies of the lock while an app is still opening.
The three guards you will end up writing
None of these are in the tutorials, and all three are the difference between "works on my emulator" and "works on someone's phone".
An app fires several foreground events while opening. Without a guard you launch the lock, the app's next window event arrives, you launch it again, and the user watches it strobe. Track whether the lock is already visible with a @Volatile flag on the activity's companion, and skip re-launching for the same package within a second.
if (LockOverlayActivity.isShowing) return
if (packageName == lastLockApp && (now - lastLockTime) < 1000) return
Returning to your own app is itself a transition. When the user taps "go back", the blocked app briefly surfaces again while the task switches. Write a short transition marker before you leave and ignore that package for ~3 seconds.
Give people a real way out. If someone deliberately unlocks an app, honour it - a grace period keyed to that package, five minutes or so. A blocker with no escape hatch gets uninstalled, not obeyed.
Foreground service type
On Android 14+ you must declare a type. App blocking doesn't fit any of the specific ones, so it's specialUse, and that requires a property explaining yourself to the reviewer:
<service android:name=".applock.AppLockMonitorService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Enforces user-selected app blocking during a focus session by detecting the foreground app via usage access"/>
</service>
Start it with startForegroundService and call startForeground with FOREGROUND_SERVICE_TYPE_SPECIAL_USE on API 34+. Return START_STICKY so the OS brings monitoring back if it kills you mid-session.
What you give up
Being straight about the trade-offs, because they're real:
-
Latency. Polling at 900ms means up to about a second between opening a blocked app and the lock appearing. An
AccessibilityServiceis effectively instant. In practice a second is fine for the use case - the user sees their feed for a moment and then doesn't. - A permission users have to find themselves. No dialog, no one-tap grant.
- A persistent notification. Foreground service, no way around it.
-
OEM behaviour varies. Aggressive battery management on some skins will kill the service.
START_STICKYhelps; it isn't a cure.
What it buys you is that you never asked to read the user's screen. For a blocker, that's the right trade - both for review and for the person installing it.
What this doesn't solve
This is not parental-control-grade enforcement and shouldn't be sold as such. Anyone can go to Settings and revoke usage access, and the lock is gone. The design goal isn't to make bypassing impossible, it's to make it cost about thirty seconds of deliberate effort - which turns out to be longer than the urge to open Instagram usually lasts.
I build Takeover, where this runs - so take the framing with that in mind. The approach isn't specific to it, and I'd rather more blockers used it.
Top comments (0)