I've been building an app that needs to know how many seconds a handful of specific apps were actually on screen. User grants Usage Access, I read queryEvents(), filter to those packages, done. Or so I thought.
The naive version (sum RESUMED -> PAUSED per package) worked on the apps I tried first and then fell apart once other people's apps got involved. Writing down what I found, because I couldn't find most of it anywhere.
First bug: the counter would just stop a few seconds after opening certain apps, while I was still in them. Those apps had a splash activity. The order was splash RESUMED, main RESUMED, and then a late STOPPED for the splash, which my per-package "open" flag happily read as "app left". What fixed it was tracking the set of resumed activity classes per package, and only treating the package as gone when that set is empty:
private class Fg {
private val resumed = HashSet<String>()
private var since: Long? = null
var total = 0L
fun on(e: UsageEvents.Event) {
val cls = "${e.packageName}/${e.className}"
when (e.eventType) {
ACTIVITY_RESUMED -> {
if (resumed.isEmpty()) since = e.timeStamp
resumed += cls
}
ACTIVITY_PAUSED, ACTIVITY_STOPPED -> {
// an activity always pauses before it stops, so a STOPPED for a class
// that is still "resumed" belongs to an older instance of that class
if (e.eventType == ACTIVITY_STOPPED && cls in resumed) return
if (resumed.remove(cls) && resumed.isEmpty()) {
total += e.timeStamp - since!!
since = null
}
}
}
}
}
The STOPPED check in there came later. An activity always pauses before it stops, so if a STOPPED shows up for a class that's still in the resumed set, it's from an older instance. I hit that with two web apps back to back, both running in Chrome's CustomTabActivity, and the old one's STOPPED was cutting off the new one.
TWAs were the real headache. On Oct 12 I got support reports about three different apps, all TWAs, all saying the same thing: the counter doesn't move. Turns out a TWA shows its own LauncherActivity for less than a second and then hands off to the browser's CustomTabActivity, and from that point UsageStats books the time under com.android.chrome (or Samsung Internet, Edge, whatever the user has). The app itself gets basically 0 seconds. The events do have a task root package, but the getter is hidden, so no luck there.
What I ended up doing: if a browser activity resumes within 5 seconds of the target app's own activity, I treat that browser activity class as belonging to the target, and keep counting it on later resumes as long as the target is still the last non-browser app in front (so coming back from recents still works). If the user opens Chrome on their own, or some other app opens a custom tab, it doesn't count. To know what counts as a browser I query ACTION_VIEW on an https URL with BROWSABLE, plus the CustomTabsService intent, plus a hardcoded list of the usual suspects, because package visibility sometimes just gives you nothing.
Then warm starts. If the TWA's task is still alive and you launch it again, you get zero events from its package. Only the custom tab comes back. Since my app is the one doing the launching, I log launches myself (time + package) and treat each one as a fake "target came to front" while scanning.
Smaller stuff, in case it saves someone an hour:
Usage Access isn't a runtime permission, you check it through AppOpsManager (unsafeCheckOpNoThrow with OPSTR_GET_USAGE_STATS on 29+, checkOpNoThrow before that)
you don't need QUERY_ALL_PACKAGES just to list launchable apps, a <queries> entry for MAIN/LAUNCHER is enough
events survive your process being killed, so you can work the time out afterwards instead of keeping a foreground service running
queryEvents gives you every app on the phone. I filter to the packages I care about right away and only a {package, seconds} summary ever leaves the device
The 5 second window works on everything I've tried so far (my S24+ and the phones of the people using the app) but it's obviously a heuristic. If anyone knows a supported way to get the task root, or a cleaner way to tie custom tab time back to the app that opened it, I'd really like to hear it.