If your Android app draws a small floating button over another app's screen, you learn quickly that the other app does not repaint politely. For a few hundred milliseconds while a screen redraws, the thing you key off (a title, a header, a class name) can read blank or stale. React to every one of those frames and your button flickers: hide, show, hide, show. Paper over it with a blanket delay and you get the opposite bug: the button lingers on a screen where it does not belong.
This post is the small rule I settled on in QuickDial: keep the button through a short hold window, but list the exits that end the hold immediately, and keep the whole decision in a pure function so tests can pin it down.
Disclosure: I'm the developer of QuickDial, the app this code comes from, and this post is published from my account. It was written and published by an AI agent working on my behalf. QuickDial is an independent app, not affiliated with WhatsApp LLC or Meta.
The setup
QuickDial's accessibility service watches for an open one-to-one chat in WhatsApp or WhatsApp Business. When it can read a personal chat and resolve the number, it shows a small call button; one tap opens the normal phone dialer with the number filled in. Two rules decide whether the button should be on screen right now:
internal object OverlayDecisions {
/** Never on the chat list (home), never on a group, only on a readable chat screen. */
fun shouldShow(isActionableChatScreen: Boolean, homeClassHint: Boolean, isGroupChat: Boolean): Boolean =
isActionableChatScreen && !homeClassHint && !isGroupChat
shouldShow is the easy half. The interesting half is what to do on a frame where shouldShow is false, but only because the screen is mid-repaint.
"Hold" is a decision, not a timer
The naive fix is a debounce: wait 300 ms before hiding. That works until the user really did leave the chat, and now the button sits over the chat list for 300 ms, or longer if events keep arriving. A hold window is only safe if you can say exactly when it does not apply. So the retain rule is a list of exits, checked in order, and only then the window:
fun shouldRetain(
retainedPackage: String?,
retainedTitleNormalized: String?,
retainedResolutionKnown: Boolean,
packageName: String,
chatTitleNormalized: String,
isGroupChat: Boolean,
homeClassHint: Boolean,
explicitNonChatScreen: Boolean,
conversationClassHint: Boolean,
msSinceReliable: Long,
holdMs: Long,
): Boolean {
if (retainedPackage == null || retainedTitleNormalized == null || !retainedResolutionKnown) return false
if (packageName != retainedPackage || homeClassHint || isGroupChat) return false
if (explicitNonChatScreen) return false
if (msSinceReliable > holdMs) return false
if (chatTitleNormalized.isBlank()) return true
return conversationClassHint || chatTitleNormalized == retainedTitleNormalized
}
}
Reading it top to bottom:
- Nothing worth holding. If the last stable chat never resolved to a number, there is no button to keep.
- Never across apps, onto the chat list, or onto a group. These are the places the button must never appear, so they end the hold no matter how recent the last good frame was.
- Never onto a screen you know is not a chat (settings, a media viewer and so on, matched by class name).
- Never past the window. The hold is 3 seconds, measured from the last frame that was reliably a readable chat.
- A blank title is the repaint case: hold.
- A different, non-blank title is only accepted while the class name still says "conversation". Outside that, a new title means a different chat, and the old button must go.
Everything here is a plain value. No AccessibilityNodeInfo, no Context, no clock. The service gathers the inputs, including System.currentTimeMillis() - lastReliableChatAt, and asks the function.
Don't let the hold end silently
A hold window has a quiet failure: the last event arrives inside the window, nothing else happens on screen, and the button stays up after the window has expired because no new event came to re-evaluate it. So when the service decides to retain, it also schedules one re-check for the moment the window closes:
private fun scheduleReliableStateRecheck() {
val remainingMs = (lastReliableChatAt + TRANSIENT_CHAT_HOLD_MS) -
System.currentTimeMillis()
if (remainingMs <= 0L) return
mainHandler.removeCallbacks(processWindowRunnable)
mainHandler.postDelayed(processWindowRunnable, remainingMs + 50L)
}
The extra 50 ms puts the re-check just past the boundary, so it lands on the "past the window" exit rather than racing it.
The hold can outlive the chat, so re-read on tap
There is one more consequence, and it matters more than the flicker. Because the button is allowed to survive a repaint, the screen under it can, in rare cases, already be a different conversation. If the tap dialled from the button's own memory, it could call the previous person.
So the tap handler does not trust the overlay's cached state. It re-reads the conversation on screen at the moment of the tap. A tap is rare and user-initiated, so one extra tree read there costs almost nothing, and it turns "the button might be stale for a moment" from a wrong-number risk into a cosmetic one.
The general lesson: if you add a grace period anywhere in UI state, find the one action that must not use stale state, and re-validate there.
Tests that name the regressions
Because the rule is pure, the tests read like the bug reports they guard against:
@Test fun `holds the bubble through a blank-title repaint so it does not flicker`() {
assertTrue(retain(chatTitle = "", msSince = 400))
}
@Test fun `does not hold past the hold window`() {
assertFalse(retain(chatTitle = "", msSince = 3_001))
}
@Test fun `does not hold onto the chat list`() {
assertFalse(retain(chatTitle = "", homeHint = true))
}
@Test fun `does not hold onto a group`() {
assertFalse(retain(chatTitle = "family", isGroup = true))
}
@Test fun `does not carry the bubble onto a different chat outside a conversation screen`() {
assertFalse(retain(chatTitle = "another person", conversationHint = false))
}
@Test fun `does not hold when the retained chat never resolved`() {
assertFalse(retain(chatTitle = "", resolutionKnown = false))
}
retain(...) is a small helper with sensible defaults (same package, 200 ms since the last good frame, resolution known), so each test changes exactly one thing. When someone later makes the button appear faster, these are the tests that stop the flicker, or the chat-list button, from quietly coming back.
Takeaways
- Treat "keep showing it for a moment" as a rule with explicit exits, not a debounce.
- Put the decision in a pure function and pass the elapsed time in, so tests need no clock.
- Schedule a re-check for when the hold expires; don't rely on another event arriving.
- Wherever a grace period lets UI outlive its data, re-validate at the point of action.
QuickDial is on Google Play as "QuickDial: Direct Chat & Call": https://play.google.com/store/apps/details?id=com.ashwar.quickdialbridge. Independent app, not affiliated with WhatsApp LLC or Meta.
Top comments (0)