If you are building anything that reacts to which app is on screen — a screen-time tool, a blocker, a focus timer — the first hard question is how to know what is in front. The second is how to do that without asking Play for permissions you cannot justify.
What does not work
ActivityManager.getRunningTasks is deprecated and, since Android 5.1, returns only your own app’s tasks. Dead end.
UsageStatsManager.queryUsageStats gives you the foreground package with a granularity of roughly a second, and it is what most tools use for reporting. It is perfect for totals and much less good for real-time reaction: the data is batched, you cannot poll faster than the system aggregates, and on some OEM builds the freshest entry lags by several seconds. Good enough to measure a session; not good enough to open a challenge at the moment a protected app appears.
AccessibilityService with TYPE_WINDOW_STATE_CHANGED is the one that works in real time. Every time the window state changes, you get an event with a package name. That is the signal.
The trade-off is obvious: accessibility is a sensitive permission with its own Play policy. It requires a prominent disclosure in the app, and the service must be genuinely justified.
The event loop
The core of the service is small; the correctness lives in what you ignore.
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
val pkg = event.packageName?.toString() ?: return
// ... filter, then start or stop the session timer
}
TYPE_WINDOW_STATE_CHANGED fires for far more than “a new app opened”. It fires for dialogs, notifications shade expansions, permission prompts, keyboards, in-call overlays, launchers and system UI. If you treat every event as a foreground change you will start and cancel sessions dozens of times a minute, and the user sees a challenge appear for no reason.
So the filters matter more than the handler:
- Ignore your own package. Your challenge activity is itself a foreground window, and reacting to it creates a loop.
- Ignore the keyguard. Screen-off and lock events are not app usage. The service listens for
ACTION_SCREEN_OFFandACTION_USER_PRESENTseparately and accumulates elapsed time on those transitions. - Ignore transient overlays. The list of packages and window-name patterns that behave like overlays rather than apps is long: system UI, keyboards (via
InputMethodManager), in-call UI, bubbles, accessibility toolbars, picture-in-picture hosts. Ship it as data, not as awhenblock nobody dares edit. - Grant the launcher grace. Home screen appears constantly between apps. Cache
CATEGORY_HOMEresolution for a minute and treat a launcher event as “not a switch” for about 800 ms, otherwise every app exit looks like entering the launcher and every launcher tap looks like entering an app.
Getting package visibility right
Before Android 11 you could call getInstalledApplications and get everything. Now, without QUERY_ALL_PACKAGES, you see your own packages plus whatever your manifest declares in <queries>.
There are two places this bites:
- Listing installed apps so the user can pick what to protect. Declaring the launcher intent and the home intent in
<queries>is enough to enumerate apps a user can actually launch — which is exactly the set worth protecting. You do not need visibility into services, providers or hidden system packages, and asking for them is what turns a review into a policy conversation. - Resolving the app you just saw in an accessibility event. The event gives you a package string, and
getApplicationInfo(pkg, 0)throwsNameNotFoundExceptionfor anything not visible. Because the picker only ever exposes launcher apps, and the event loop only ever looks up packages the user selected, the visibility you declared is sufficient. Any surprise package is treated as “not protected” rather than as an error worth crashing on.
QUERY_ALL_PACKAGES is a declaration that your app needs to see everything. A screen-time tool needs to see launchable apps and nothing else, so ScrollBreak does not declare it.
Timing without drift
Session accounting with a 1-second loop is easy to get subtly wrong. The version that behaves:
- Store
accumulatedmillis per session and anopenTimetimestamp. - Each tick compute
remaining = intervalMillis - (accumulated + (now - openTime))and fire when it goes negative. - On screen-off, fold the elapsed time into
accumulatedand cancel the ticker; on user-present, restart it. Otherwise a phone left in a pocket accumulates an entire night of “usage” and the challenge fires the instant the screen wakes. - Cancel and restart on every package switch rather than carrying a timer across apps, or a two-minute visit to a protected app will include five minutes in the camera.
The policy cost of accessibility
Using accessibility obliges you to disclose it prominently and to be able to explain, in one sentence, why nothing else works. “Android exposes the current foreground app through the accessibility API, and ScrollBreak uses it only to start and stop the session timer” is the honest answer. Do not declare isAccessibilityTool unless the app genuinely serves disability support needs — a screen-time tool does not, and a false declaration is worse than the disclosure.
The engineering lesson is that the permission is the cheap part. The expensive part is respecting it: filtering aggressively, never reading screen content, and never keeping the data you saw.