Foreground app detection without QUERY_ALL_PACKAGES

How ScrollBreak notices when a protected app becomes active, why the accessibility service is the only reliable route, and how to do it without declaring broad package visibility.

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_OFF and ACTION_USER_PRESENT separately 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 a when block nobody dares edit.
  • Grant the launcher grace. Home screen appears constantly between apps. Cache CATEGORY_HOME resolution 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:

  1. 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.
  2. Resolving the app you just saw in an accessibility event. The event gives you a package string, and getApplicationInfo(pkg, 0) throws NameNotFoundException for 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 accumulated millis per session and an openTime timestamp.
  • Each tick compute remaining = intervalMillis - (accumulated + (now - openTime)) and fire when it goes negative.
  • On screen-off, fold the elapsed time into accumulated and 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.

Common questions

Why not use UsageStatsManager for real-time app detection?
UsageStatsManager is batched. It is accurate for totals and session history, but the freshest entry can lag several seconds on some OEM builds, so it cannot reliably open a challenge at the moment a protected app appears.
Why does ScrollBreak need the accessibility service?
TYPE_WINDOW_STATE_CHANGED is the only Android signal that reports a foreground package in real time. ScrollBreak uses it only to start and stop its session timer; it does not read screen content or keep what it sees.
Does ScrollBreak need QUERY_ALL_PACKAGES?
No. Declaring the launcher and home intents in the queries element is enough to list apps a user can launch, which is exactly the set worth protecting. Resolving any other package is treated as not protected.
How does ScrollBreak avoid false positives?
It ignores its own package, the keyguard and transient overlays such as keyboards, system UI, bubbles and picture-in-picture. It also gives the launcher a short grace period so moving between apps is not counted as a new session.
Is accessibility allowed on Google Play for a screen-time app?
Yes, with a prominent in-app disclosure and a genuine justification. Do not declare isAccessibilityTool unless the app actually serves disability support needs; a screen-time tool does not.
Keep reading

More from the blog

All articles

How to see which apps you use most on Android

Android already measures your app usage — it just hides it. Where the real numbers live, what they actually count, and how to read them without turning screen time into a scoreboard.

Read

The best screen time apps for Android in 2027

Hard blocks, breathing pauses and puzzle breaks do different things to you. What each Android screen-time app actually does at the limit, and how to pick one that survives week two.

Read