Blocking an app on Android has a clean signal: a window from a blocked package comes to the front. Blocking a website doesn't. To anything watching foreground packages, instagram.com in Chrome is just com.android.chrome, same as a recipe site.
We're building ReClaim, a minimalist Android launcher with app blocking built in. The first post covered the app side of its accessibility service: noticing which app is in front. The same service enforces website rules (blocks, schedules, reminders, limits and hidden sites) by reading one thing, the browser's address bar. This post covers finding that bar without walking the page, telling typing from visiting, and moving a tab off a site the user chose not to open.
(There are other routes, like a local VpnService doing DNS filtering. ReClaim doesn't use one; this is the accessibility route.)
Stack: Kotlin, Hilt, Room, coroutines, minSdk 26, targetSdk 36. Snippets are trimmed from the real code.
1. Asking for a little more
Reading an address bar needs window content and view ids, so the service config grew from window events to this:
<accessibility-service
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowsChanged|typeWindowContentChanged|typeViewTextChanged"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagReportViewIds|flagIncludeNotImportantViews"
android:canPerformGestures="true"
android:canRetrieveWindowContent="true"
android:notificationTimeout="0" />
The key one is FLAG_REPORT_VIEW_IDS. Without it, nodes don't report their view id, and you'd have to guess which field is the address bar from its contents. FLAG_RETRIEVE_INTERACTIVE_WINDOWS matters too, as we'll see.
The service also re-applies them in onServiceConnected(). Its comment: "Ask for address-bar text changes as they happen, including after an app update."
private fun listenForAddressBar() {
val info = serviceInfo ?: return
info.eventTypes = info.eventTypes or
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED or
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED or
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or
AccessibilityEvent.TYPE_WINDOWS_CHANGED
info.flags = info.flags or
AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS or
AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS or
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS
info.notificationTimeout = 0
serviceInfo = info
}
The disclosure shown before the user enables the service says it plainly: for website rules, ReClaim reads the browser's address bar, "the site address only, never the page itself."
2. Find the bar, skip the page
Address-bar lookup only runs for an allowlist of 30 browser packages (BROWSER_PACKAGES: Chrome and its channels, Firefox, Samsung Internet, Edge, Brave, DuckDuckGo and others). The bar is found by view id, and since browsers don't share one, there's a small set of known names:
private val ADDRESS_BAR_IDS = setOf(
"url_bar", "url_bar_title", "url_bar_edit_text", "location_bar_edit_text",
"mozac_browser_toolbar_url_view", "omnibarTextInput", "url_field",
"address_bar_edit_text", "origin_bar",
)
private fun isAddressBarId(viewId: String?): Boolean {
if (viewId.isNullOrEmpty()) return false
return viewId.substringAfterLast('/') in ADDRESS_BAR_IDS
}
substringAfterLast('/') turns com.android.chrome:id/url_bar into url_bar, so one set covers every Chromium build.
The first version used findAccessibilityNodeInfosByViewId(). The code comment records why it went: "searching it by view id walks every node on a site like YouTube and held the prompt back for seconds." The page's accessibility tree can be enormous, and the bar is never in it. So the search became a breadth-first walk that refuses to enter page content and stops after 80 nodes:
private const val MAX_SHELL_NODES = 80
private fun isPageContent(node: AccessibilityNodeInfo): Boolean {
val cls = node.className?.toString().orEmpty()
if (cls.contains("WebView", ignoreCase = true)) return true
if (cls.contains("WebContents", ignoreCase = true)) return true
val id = node.viewIdResourceName.orEmpty()
return id.endsWith("/compositor_view_holder") || id.endsWith("/web_contents")
}
// inside the BFS loop
val reading = readingOf(node) // non-null only for an address-bar id
if (reading != null && !reading.focused) return reading
if (reading != null && focusedHit == null) focusedHit = reading
if (reading != null || isPageContent(node)) continue // never descend into the page
for (i in 0 until node.childCount) node.getChild(i)?.let { queue.add(it) }
Two more details:
-
The bar may not be in the active window.
rootInActiveWindowis often the page, while the omnibox sits in a separate toolbar window. The service collects the active root plus every root fromwindowsbelonging to that browser package, which is whatFLAG_RETRIEVE_INTERACTIVE_WINDOWSunlocks. -
Recycling.
AccessibilityNodeInfo.recycle()is deprecated in API 33 and does nothing there. WithminSdk 26it still matters on older devices, so every child picked up during the walk is recycled in afinally.
3. From address-bar text to a host
What the bar shows isn't always a URL. Chrome often shows a breadcrumb like reddit.com › r › nosurf. Others show https://www.reddit.com/r/nosurf, or a bare host. normalizeHost() reduces all of them to reddit.com by stripping the scheme, any user@ prefix and a leading www., then cutting at the first delimiter. The delimiter list is the interesting part:
private fun isHostDelimiter(c: Char): Boolean =
c == '/' || c == '\\' || c == '?' || c == '#' || c == ':' ||
c.isWhitespace() || c == '>' || c == '<' || c == '›' || c == '‹' ||
c == '»' || c == '«' || c == '❯' || c == '|' || c == '·' || c == '•' || c == '/'
› is the separator in Chrome's breadcrumb form, and / is a full-width slash.
Matching a host against rules is a suffix check on a dot boundary, plus aliases for products with several domains:
fun matchingRuleHost(urlHost: String, rules: Set<String>): String? {
val host = normalizeHost(urlHost) ?: return null
rules.firstOrNull { host == it || host.endsWith(".$it") }?.let { return it }
for (site in KNOWN_WEBSITES) {
if (site.host !in rules) continue
if (site.aliases.any { host == it || host.endsWith(".$it") }) return site.host
}
return null
}
A reddit.com rule covers old.reddit.com but not notreddit.com. KNOWN_WEBSITES maps 14 products to their site and aliases (youtu.be for YouTube, t.co and twitter.com for X), so a rule is stored once under the canonical host.
4. Typing is not visiting
The trickiest part: the omnibox can show a host the user hasn't opened. As a code comment puts it, a bare host is what "the omnibox shows ... while a suggestion is still only highlighted." Gating on that would block someone for a suggestion they never picked.
The rule that came out of it: an unfocused bar is the page that's open, and so is any value that already has a path, query or breadcrumb. A focused bare host is the editor, and only counts after it sits still:
fun addressBarShowsPage(raw: String): Boolean {
// ...scheme and userinfo stripped
return s.any { c ->
c == '/' || c == '\\' || c == '?' || c == '#' || c == '/' ||
c == '›' || c == '‹' || c == '»' || c == '«' || c == '❯' || c == '>' || c == '<'
}
}
"Focused" means input focus (node.isFocused), not accessibility focus. A comment in readingOf() explains why: Chrome often leaves accessibility focus on the omnibox during normal browsing, and treating that as typing "made the gate wait until they left the page."
On the event path, TYPE_VIEW_TEXT_CHANGED also says how the text changed. One character added (at most one removed) while focused, with no path, is a keystroke. Anything else counts as a committed navigation:
val keystroke = event.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED &&
focused &&
!addressBarShowsPage(raw) &&
event.addedCount == 1 &&
event.removedCount <= 1
Untrusted candidates go through a 300 ms settle (FOCUSED_COMMIT_MS): a coroutine waits, and starts over if the bar moves to a different host first. The comment calls that "long enough to skip a keystroke and short enough that landing on a site still gates before the user can settle in."
5. Three ways to notice
There are three signals:
-
Text-changed events on the address bar. The event carries the new text (and
beforeText), so no tree walk is needed. - Content-changed events from a browser, throttled to one read per 200 ms. They also handle a case where our own gate closed without a fresh window event and Chrome was quietly back in front.
- A 250 ms poll, which the code calls a "backup read" for a bar that never sends a text event.
The poll is tightly scoped:
private fun websiteWatchActive(): Boolean =
hiddenWebsites.isNotEmpty() ||
blockedHosts.isNotEmpty() ||
reminderHosts.isNotEmpty() ||
websiteBudgets.isNotEmpty() ||
websiteDeparture.pending() != null
It only runs while a known browser is the foreground window and at least one website rule exists. The moment another app comes forward, stopBrowserPoll() cancels it. With no website rules, it never starts.
6. Leaving the tab
Blocking an app ends with "go Home". For a website that's not enough: the tab is still on the site, so the gate fires again next time the browser comes forward. So when a website gate closes, ReClaim moves the tab off the site.
WebsiteDeparture arms the request and brings the same browser task back with FLAG_ACTIVITY_REORDER_TO_FRONT, so the open tab is reused instead of launching a new one. Then the service drives the address bar, at most one step every 280 ms:
if (departPhase % 2 == 0) {
val clicked = bar.performAction(AccessibilityNodeInfo.ACTION_CLICK) ||
bar.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
} else {
val url = browserDepartureUrl(browserPackage, departPhase / 2, websiteDeparture.destinationUrl())
val args = Bundle().apply {
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, url)
}
bar.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) bar.performAction(ACTION_IME_ENTER)
}
departPhase++
ACTION_IME_ENTER is the constant 16908372, the value of android.R.id.accessibilityActionImeEnter (API 30). It submits the omnibox without touching the keyboard.
The first two attempts load a static hold page on tryreclaim.io. Its copy ("You blocked Instagram. Hold on. You set this aside for a reason.") rides in the query string, so one noindex page covers blocks, limits, cancelled reminders and hidden sites. If that never sticks, later attempts use about:blank. (Without a hold URL, the first attempts use the browser's start page: chrome://newtab for Chromium builds, about:home for Firefox.)
Two safeguards keep this from becoming a loophole:
-
It gives up. After
MAX_DEPART_PHASE = 8steps, or 3 seconds, the request clears. Then the gate is allowed to fire again: as the code puts it, "a failed navigation is not a way past the rule." - Cancel isn't a pass. If the bar shows anything else after Cancel and then the site again, that's a new visit and the gate asks immediately.
7. Same rules, different key
Websites reuse the app machinery. Blocked hosts come from the same three-way combine as blocked packages in our scheduled blocks post, with a schedule_websites table alongside schedule_apps. Reminders reuse the app reminder scheduler under a prefixed key:
// Package names are reverse-DNS and never use this prefix.
const val SITE_KEY_PREFIX = "site:"
fun siteKey(host: String): String = "$SITE_KEY_PREFIX$host"
Time is the one thing that couldn't be reused. UsageStatsManager only sees the browser, not the site, so ReClaim records time itself: a Room table keyed by (host, day) that stores only reminded or limited sites, flushed every 20 seconds while the visit is open. The entity's doc comment: "Other browsing is not stored."
8. Trade-offs
This depends on browser internals. View ids like url_bar aren't a public API, and a browser update could rename one. A browser missing from the allowlist isn't covered. The upside: no root, no VPN, no device admin. The service reads view ids and class names of at most 80 toolbar nodes per window, plus the address-bar text.
Wrapping up
- Find the bar by view id, and never descend into the WebView.
- An unfocused bar, or one with a path, is a visit. A focused bare host has to settle first.
- Prefer events, and scope any polling to "browser in front and rules exist."
- After a gate, move the tab away, and make sure failure re-arms the gate instead of muting it.
For the user side of these rules, we wrote up app limits vs. blocking vs. reminders. ReClaim isn't on Google Play yet; it's waitlist-only for now at tryreclaim.io.
Have you built anything on top of accessibility events, or found a cleaner way to tell an omnibox suggestion from a real visit? Tell us in the comments.
This post was drafted with AI assistance and reviewed by the ReClaim team.
Top comments (0)