Disclosure: I'm the developer of Bolo, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the project's source code.
Bolo AI is a voice-typing app. On Android it can show a small floating mic next to a text field in other apps, so you can dictate into a chat or a notes app. A floating button over other apps raises an obvious question the moment it works: which apps should never get it?
Two answers turned out to need two different mechanisms, and a third piece of plumbing that a comment had promised and no code delivered.
1. Payment, banking and wallet apps: a hard-coded list
The mic should never sit on top of a UPI PIN screen, a bank login or a wallet. Some of that is handled by the field itself: a password field (isPassword, or an input type with any of the password variations) never gets the mic. But a bank app has plenty of plain text fields too, and "this field is not a password" is not the same as "this screen is safe to draw over".
So there is a list of package ids that are always excluded, whatever the user's settings:
val alwaysExcludedPackages = setOf( // India (UPI, wallets, bank apps). Each id returned HTTP 200 on // play.google.com/store/apps/details (30-Oct-2026). "com.sbi.SBIFreedomPlus", // ... // Brazil. ... "br.com.gabba.Caixa", // Spanish-speaking markets. ... "com.nequi.MobileApp", // Indonesia. ... ) fun isAlwaysExcluded(packageName: String?): Boolean = !packageName.isNullOrBlank() && packageName in alwaysExcludedPackages Why a hand-kept list rather than something clever? Because there is no reliable signal. Android has no "finance" category an app can declare, and guessing from names or permissions would miss apps and catch innocent ones. A list is boring, auditable and testable, and a missing entry is easy to add.
What keeps the list honest:
- Every id was checked against its Play Store page before it went in. A typo in a package id fails silently forever, so the check is part of adding it, and the date is in the comment.
- A test asserts the ids are well formed, another that the allowed and excluded sets never overlap, and another that common chat and notes apps are not caught by the banking list. The last one matters as much as the first: an over-eager deny list quietly removes the feature from the apps people use most.
The accessibility service has its own short list on top (Google Wallet, Google Pay and Samsung Pay among them), and the launcher is excluded the same way.
2. Games and video: ask the app what it is
The second request was different in kind: don't put a mic over games or video, where it is an obstruction rather than a help. A list cannot keep up with that; a game installed tomorrow should be covered without an app update.
Android lets an app declare a category in its manifest, and ApplicationInfo exposes it:
fun isExcludedCategory(context: Context, packageName: String?): Boolean { if (packageName.isNullOrBlank()) return false categoryCache[packageName]?.let { return it } val excluded = try { val info = context.packageManager.getApplicationInfo(packageName, 0) @Suppress("DEPRECATION") info.category == ApplicationInfo.CATEGORY_GAME || info.category == ApplicationInfo.CATEGORY_VIDEO || (info.flags and ApplicationInfo.FLAG_IS_GAME) != 0 } catch (e: Exception) { false // an app we cannot read is not a reason to hide the mic } categoryCache[packageName] = excluded return excluded } Notes on it:
-
FLAG_IS_GAMEis deprecated but older games still set only that, so both are checked. - The failure default is "not excluded". This is the opposite of the payment list, on purpose: the payment list fails closed because the cost of a mistake is high; the category check fails open because the cost is only a mic where it is not wanted.
- Package visibility on Android 11+.
getApplicationInfoonly works for packages your app can see. Bolo declares a<queries>entry for launcher activities, which covers every app a user can open, and that is the set the check is ever asked about. - It is cached because it is a binder call and the visibility check runs often. The cache is the reason for section 3.
"Full-screen apps" were also requested, and the honest answer in the code is that this is not fully solvable from the fallback path: whether an activity is immersive right now is not readable without accessibility. The approximation is the two categories above plus an existing rule that hides the mic in landscape, which is where nearly all full-screen video and gameplay happens.
3. The cache invalidation that only existed in a comment
clearPackageCache() was documented as being called on install and uninstall. Nothing called it. The visible effect showed up on reinstall: a stale verdict survived, so a newly installed game could keep the mic, or an app whose lookup once failed stayed in whatever state it was cached in.
The fix is a receiver for package changes, registered at runtime by the service that owns the cache:
class PackageChangeReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val action = intent?.action ?: return if (action !in HANDLED) return AccessibilityAppPolicy.clearPackageCache() // whole map: rare event, not a hot path } companion object { fun filter(): IntentFilter = IntentFilter().apply { HANDLED.forEach { addAction(it) } // Package broadcasts carry the package in the data URI; // without this scheme the filter matches nothing at all. addDataScheme("package") } } } Two traps in a dozen lines:
- Manifest-declared receivers for implicit broadcasts such as
ACTION_PACKAGE_ADDEDhave been restricted since Android 8, so the receiver is registered in code while the service is alive. That is also the battery-friendly choice: it exists only when something needs it. -
addDataScheme("package")is required. Without it the filter silently matches nothing, and you get exactly the bug you were fixing, with a receiver that looks correct.
Where the checks sit
Both paths end in one decision function each, with no Android state inside them, so they are unit-testable:
fun shouldShowFallbackMic(foregroundPackage: String?, /* ... */ isLandscape: Boolean, isExcludedCategory: Boolean = false): Boolean { if (!screenInteractive || isLandscape) return false // ... if (isAlwaysExcluded(foregroundPackage) || foregroundPackage in additionalExcludedPackages) return false if (isExcludedCategory) return false return !restrictToSelectedApps || foregroundPackage in allowedPackages } In the accessibility service, an event from an excluded package returns early. If the mic is not showing, it does not even read the active window, which matters for battery because system UI and video players produce events all day. If the mic is showing, it confirms the active window really belongs to an excluded app before hiding it, so a transient system overlay does not make the mic flicker in the app you are typing in.
What I would take from it
- Use a deny list where a mistake is costly and a declared signal where coverage matters, and give each the failure default that matches its cost.
- Test the negative case of a deny list (the apps it must not catch), not only the positive.
- A comment that says "called on X" is a claim. Grep for the caller.
Source: data/AccessibilityAppPolicy.kt, service/PackageChangeReceiver.kt and service/VoxAccessibilityService.kt in the Android app, with AccessibilityAppPolicyTest.kt. Bolo is on Google Play: search "Bolo AI" or use the link above. Accessibility in Bolo is used only to find and fill text fields.