android: the website guard sees what it promised to see
Four defects in the capture-and-match path, each able to look like "the blocker works incorrectly" on its own. Masks were unenforced exactly while online. The online matcher was built with `blockedDomains = dto.allDomains`, and `all_domains` is concrete hostnames only — a mask is precisely what the backend could *not* expand, and it comes back in `unexpandable`. Overwriting the raw entries emptied `DomainMatcher.rawEntries`, so step 3 of `decide()` — the step a hosts file cannot do — was dead code with the network up. `*gram*` blocked only offline, while the Guard screen and the README both said it worked on the phone. The rules now go in unchanged, with `all_domains` as `expandedHosts`, and `unexpandable` + a newly-read `allowed` folded in so masks and exceptions survive a process that has never read the settings document. The event-text path never matched anything. `event.text` is a list and was joined with a space before being handed over; `normalizeHost` rejects anything containing a space, which is correct of it and left the as-you-type capture dead for every event carrying more than one item. Each item is offered on its own now. Any editable node counted as an address bar — including every text field the *page* renders. Typing a blocked hostname into a search box, a form or a chat ejected you to the launcher, which is worse than a miss: it throws you off a page you are allowed to be on. An unlabelled editable node is now believed only near the top of the tree, a node whose view id names a URL field is believed anywhere and ends the walk, and web-content containers are not descended into at all — which also stops a content-heavy tab from spending the 250-node budget before the toolbar is reached. The guard armed up to 45 seconds late. `active` only changed on the blocker's own poll, which is 45s apart while idle — the exact gap between tapping "Start focus" and opening the thing you blocked. The application now subscribes the guard to the timer, and `onTimerChanged()` applies `:core`'s local verdict at once while the server is asked in the background. The rules are also read once per `start()` rather than only from MainActivity and the Guard screen. 9 new tests. The per-node judgement moved out of the traversal to be reachable from them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
8 files changed
+267
-13
No files matched your search
@@ -38,6 +38,14 @@ class BlockerEngineTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private var clock = 1_000_000L
|
||||
|
||||
/**
|
||||
* Whether `GET /api/settings` answers.
|
||||
*
|
||||
* It does not, in the one case that matters most: a process Android started to bind
|
||||
* the accessibility service, with no Activity to have ever read the document.
|
||||
*/
|
||||
private var settingsAvailable = true
|
||||
private var bounces = 0
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
|
||||
@@ -76,7 +84,8 @@ class BlockerEngineTest {
|
||||
val path = request.url.encodedPath
|
||||
val body = when {
|
||||
path.endsWith("/api/blocker/policy") -> fixture("blocker_policy_active.json")
|
||||
path.endsWith("/api/settings") -> fixture("settings.json")
|
||||
path.endsWith("/api/settings") ->
|
||||
if (settingsAvailable) fixture("settings.json") else return error()
|
||||
else -> "{}"
|
||||
}
|
||||
return MockResponse.Builder()
|
||||
@@ -85,6 +94,8 @@ class BlockerEngineTest {
|
||||
.body(body)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun error() = MockResponse.Builder().code(503).body("nope").build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,4 +182,44 @@ class BlockerEngineTest {
|
||||
|
||||
assertEquals(0, bounces)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mask the server could not expand is enforced while online`() {
|
||||
// The bug: `all_domains` holds concrete hostnames, because a mask is precisely
|
||||
// what the backend could not expand — it comes back in `unexpandable`. Building
|
||||
// the online matcher from `all_domains` therefore dropped every mask, so
|
||||
// `*gram*` blocked only when the phone was offline, while the Guard screen and
|
||||
// the README both said it worked.
|
||||
val engine = engine()
|
||||
|
||||
engine.onUrlSeen("telegram.org", "com.android.chrome")
|
||||
|
||||
assertEquals(1, bounces, "*gram* is on the list and the phone is online")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the masks survive a process that never read the settings document`() {
|
||||
settingsAvailable = false
|
||||
val engine = engine()
|
||||
|
||||
engine.onUrlSeen("telegram.org", "com.android.chrome")
|
||||
|
||||
assertEquals(1, bounces, "the policy's own `unexpandable` is enough")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exception beats a mask, with or without the settings document`() {
|
||||
settingsAvailable = false
|
||||
val engine = engine()
|
||||
|
||||
// `*gram*` catches `instagram.com`, and `instagram.com` is an exception. The
|
||||
// expanded host list cannot settle this — a mask is not in it — so the mask step
|
||||
// has to consult the allow list itself, and with no settings document the only
|
||||
// place it can read one is the policy's own `allowed`.
|
||||
engine.onUrlSeen("instagram.com", "com.android.chrome")
|
||||
assertEquals(0, bounces, "an exception beats the mask that would catch it")
|
||||
|
||||
engine.onUrlSeen("telegram.org", "com.android.chrome")
|
||||
assertEquals(1, bounces, "and nothing else is let off with it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package tech.kadence.android
|
||||
|
||||
import org.junit.Test
|
||||
import tech.kadence.android.blocker.Candidacy
|
||||
import tech.kadence.android.blocker.barCandidacy
|
||||
import tech.kadence.android.blocker.isWebContent
|
||||
import tech.kadence.android.blocker.looksLikeAddressBar
|
||||
import tech.kadence.core.normalizeHost
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Which text in a browser's window is the address bar's.
|
||||
*
|
||||
* The guard ejects you from a blocked page, so a false positive is worse than a miss:
|
||||
* it throws you off a page you are allowed to be on, with no explanation beyond a
|
||||
* notification. The service used to accept any editable node in the window, which
|
||||
* includes every text field the *page* renders — so typing a blocked word into a search
|
||||
* box was indistinguishable from visiting the site.
|
||||
*/
|
||||
class UrlBarTest {
|
||||
|
||||
@Test
|
||||
fun `a node whose view id names a url field is the bar`() {
|
||||
assertTrue(looksLikeAddressBar("com.android.chrome:id/url_bar"))
|
||||
assertTrue(looksLikeAddressBar("org.mozilla.firefox:id/mozac_browser_toolbar_url_view"))
|
||||
assertTrue(looksLikeAddressBar("com.android.chrome:id/search_box_text"))
|
||||
assertEquals(
|
||||
Candidacy.NAMED,
|
||||
barCandidacy("com.android.chrome:id/url_bar", editable = false, depth = 40),
|
||||
"a named bar is believed however deep the tree put it",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary view id is not`() {
|
||||
assertFalse(looksLikeAddressBar("com.android.chrome:id/title"))
|
||||
assertFalse(looksLikeAddressBar(null))
|
||||
assertFalse(looksLikeAddressBar(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an editable field is believed near the toolbar and nowhere else`() {
|
||||
assertEquals(Candidacy.PLAUSIBLE, barCandidacy(null, editable = true, depth = 3))
|
||||
assertEquals(
|
||||
Candidacy.NO,
|
||||
barCandidacy(null, editable = true, depth = 14),
|
||||
"a text input this deep belongs to the page, not the browser",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a node that is neither named nor editable is never a candidate`() {
|
||||
assertEquals(Candidacy.NO, barCandidacy("com.android.chrome:id/line_1", false, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the rendered page is recognised so the walk can skip it`() {
|
||||
assertTrue(isWebContent("android.webkit.WebView"))
|
||||
assertTrue(isWebContent("org.mozilla.geckoview.GeckoView"))
|
||||
assertTrue(isWebContent("org.chromium.content.browser.ContentView"))
|
||||
assertFalse(isWebContent("android.widget.EditText"))
|
||||
assertFalse(isWebContent(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `what a window event actually carries, one item at a time`() {
|
||||
// `event.text` is a list, and it used to be joined with a space before being
|
||||
// handed over — which `normalizeHost` rejects outright, so the whole path was
|
||||
// dead for every event carrying more than one item.
|
||||
val event = listOf("YouTube", "youtube.com")
|
||||
assertNull(normalizeHost(event.joinToString(" ")), "the old behaviour: nothing")
|
||||
assertEquals("youtube.com", event.firstNotNullOf { normalizeHost(it) })
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"redirect_ip": "127.0.0.1",
|
||||
"domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
|
||||
"apps": ["steam*", "*discord*"],
|
||||
"allowed": ["music.youtube.com", "instagram.com"],
|
||||
"unexpandable": ["*gram*"],
|
||||
"invalid_expansions": ["not a label"],
|
||||
"kill_apps": true,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"redirect_ip": "127.0.0.1",
|
||||
"domains": [],
|
||||
"apps": [],
|
||||
"allowed": ["music.youtube.com", "instagram.com"],
|
||||
"unexpandable": ["*gram*"],
|
||||
"kill_apps": true,
|
||||
"notify_on_block": true,
|
||||
|
||||
Reference in new issue
Block a user