cd /news/ai-tools/where-a-floating-button-must-never-a… · home › topics › ai-tools › article
[ARTICLE · art-169440] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Where a floating button must never appear on Android: a hand-kept deny list for payment apps, and the app's own category for games and video

The developer of Bolo, an Android voice-typing app, built a hand-maintained deny list of package IDs for banking, UPI and wallet apps where the floating mic button is always suppressed, plus a manifest-category check that hides the mic over games and video apps without requiring app updates. The developer also fixed a stale-cache bug in which clearPackageCache() was documented as running on install and uninstall but was never actually called, causing outdated verdicts to persist after reinstalls.

by read5 min views3 publishedNov 14, 2026

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.

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:

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.

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_GAME is deprecatedgetApplicationInfo only 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. "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.

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:

ACTION_PACKAGE_ADDED have 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") 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.

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.

── more in #ai-tools 4 stories · sorted by recency
── more on @bolo ai 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/where-a-floating-but…] indexed:0 read:5min 2026-11-14 · —