android: the focus guard on the phone itself

The Guard screen shows the shared policy; two Android facilities enforce it.
An accessibility service reads the browser's URL bar (no /etc/hosts exists,
and the VPN slot belongs to the user's always-on VPN), sends blocked pages
home and records the attempt; UsageStatsManager spots blocked apps in front
and the service presses home — 'kill' cannot mean force-stop, and the KDoc
says so.

Masks mean what they mean on every client: a bare '*' in the expansion list
makes '*.site' cover every subdomain, and masks no hosts file can express
are matched as ordinary globs against every URL, so they work here even
where the desktop cannot route them. Offline, the kept blocker settings are
re-evaluated with :core's evaluatePolicy, so losing signal does not lose the
guard. Two limits are documented rather than papered over: in-app WebViews
are invisible to the URL-bar reader, and blocking degrades to reporting
without the service.

Core keeps the policy/matcher semantics testable on a plain JVM: 190 core
and 44 app tests. The screen's copy says junk labels are ignored, a '*' is
kept, and unroutable masks are enforced on this phone only.
This commit is contained in:
timka123 committed 2026-10-19 18:45:50 +03:00
1 parent 36af1e9e83
commit 50e9c68b2f
24 files changed
+1996 -65

No files matched your search

@@ -64,16 +64,20 @@ data class PolicyVerdict(val active: Boolean, val reason: String)
* `youtube.com` -> itself plus `www.` and `m.`; `*.youtube.com` -> the bare domain plus
* every configured subdomain; `*gram*` -> nothing expandable, returned as a leftover.
*
* A port of `expand_entry()`. Returns `(hostnames, unexpandablePattern)`.
* A port of `expand_entry()`. Returns `(hostnames, unexpandablePattern)`; with
* `wildcard` set, `*.youtube.com` returns both at once — the concrete
* names block on their own, and the pattern says "every other subdomain too",
* which the URL-bar matcher (here) and the desktop's resolver stub take from there.
*/
fun expandEntry(raw: String, subdomains: List<String>): Pair<List<String>, String?> {
fun expandEntry(raw: String, labels: List<String>, wildcard: Boolean = false): Pair<List<String>, String?> {
val entry = raw.trim().lowercase().trimStart('.')
if (entry.isEmpty()) return emptyList<String>() to null
if (entry.startsWith("*.")) {
val base = entry.removePrefix("*.")
if (base.isEmpty() || '*' in base) return emptyList<String>() to entry
return (listOf(base) + subdomains.map { "$it.$base" }) to null
val hosts = listOf(base) + labels.map { "$it.$base" }
return hosts to (entry.takeIf { wildcard })
}
if ('*' in entry) return emptyList<String>() to entry
@@ -90,9 +94,50 @@ fun allowMasksOf(rules: BlockerRules): List<String> = rules.allowedDomains
.filter { it.isNotBlank() }
.map { it.trim().lowercase().trimStart('.') }
private fun subdomainsOf(rules: BlockerRules): List<String> = rules.subdomainExpansions
.filter { it.isNotBlank() }
.map { it.trim().lowercase() }
/** One label of a hostname: what may legally sit in front of `.example.com`. */
private val LABEL_RE = Regex("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
/** What `subdomainExpansions` asked for, split three ways. See [parseExpansions]. */
data class Expansion(
val labels: List<String> = emptyList(),
val wildcard: Boolean = false,
val rejected: List<String> = emptyList(),
)
/**
* `subdomainExpansions` split into labels, the `*` flag, and rejects — the port of
* `parse_expansions()`.
*
* A bare `*` is the user saying *every* subdomain, and it is supported rather than
* refused: the labels still expand into hostnames (which block without help), while
* [Expansion.wildcard] tells the matcher to cover the rest by mask. Before that, `*`
* was treated as a label, so `*.telegram.org` expanded to the literal string
* `*.telegram.org` — not a hostname, which the desktop app dropped without a word,
* `unexpandable` stayed empty, and `web.telegram.org` was never blocked. Anything
* that is neither a label nor `*` is still refused, and reported as a reject.
*/
fun parseExpansions(raw: List<String>): Expansion {
val labels = LinkedHashSet<String>()
val rejected = LinkedHashSet<String>()
var wildcard = false
for (entry in raw) {
val label = entry.trim().lowercase()
if (label.isEmpty()) continue
when {
label == "*" -> wildcard = true
LABEL_RE.matches(label) -> labels.add(label)
else -> rejected.add(label)
}
}
return Expansion(labels.toList(), wildcard, rejected.toList())
}
/** Expansion entries neither a label nor `*`, so the UI can flag them. */
fun invalidExpansions(rules: BlockerRules): List<String> =
parseExpansions(rules.subdomainExpansions).rejected
private fun expansionOf(rules: BlockerRules): Expansion =
parseExpansions(rules.subdomainExpansions)
/**
* Every hostname a hosts file would be pointed at the redirect IP, allow list already
@@ -113,11 +158,11 @@ fun expandDomains(rules: BlockerRules): List<String> = expandDomainsWithOrigin(r
*/
fun expandDomainsWithOrigin(rules: BlockerRules): Map<String, String> {
val allowMasks = allowMasksOf(rules)
val subdomains = subdomainsOf(rules)
val expansion = expansionOf(rules)
val origin = LinkedHashMap<String, String>()
for (raw in rules.blockedDomains) {
val entry = raw.trim().lowercase().trimStart('.')
val (expanded, _) = expandEntry(raw, subdomains)
val (expanded, _) = expandEntry(raw, expansion.labels, expansion.wildcard)
for (host in expanded) {
if (!isAllowed(host, allowMasks)) origin.putIfAbsent(host, entry)
}
@@ -125,12 +170,16 @@ fun expandDomainsWithOrigin(rules: BlockerRules): Map<String, String> {
return origin
}
/** Block-list entries a hosts file cannot express, so the UI can flag them. */
/**
* Block-list entries a hosts file cannot express — the patterns this matcher takes
* over in the URL bar, and that the desktop app answers at DNS level when it can
* route the zone. The UI shows them as "enforced beyond the hosts file".
*/
fun unexpandableMasks(rules: BlockerRules): List<String> {
val subdomains = subdomainsOf(rules)
val expansion = expansionOf(rules)
val leftovers = LinkedHashSet<String>()
for (raw in rules.blockedDomains) {
val (_, pattern) = expandEntry(raw, subdomains)
val (_, pattern) = expandEntry(raw, expansion.labels, expansion.wildcard)
if (pattern != null) leftovers.add(pattern)
}
return leftovers.toList()
@@ -6,11 +6,12 @@
* the package name and the app's visible label — standing in for the Linux process
* name and its command line.
*
* `DomainMatcher` goes one step further than the desktop *can* go. A hosts file has no
* wildcards, so the backend has to expand every mask into concrete hostnames and
* reports whatever it cannot express as `unexpandable`. A URL-bar matcher has no such
* limit, so patterns like `*gram*` — which the desktop apologises for — are enforced
* here. That is the one place the phone is strictly stronger than the laptop.
* `DomainMatcher` goes one step further than a hosts file can. A hosts file has no
* wildcards, so the backend expands every mask into concrete hostnames and reports
* whatever it cannot express as `unexpandable`. A URL-bar matcher has no such limit:
* `*gram*` is matched here, and `*.youtube.com` covers every subdomain as soon as
* `*` is in `subdomain_expansions` — the same two things the desktop app now does
* through its local resolver stub.
*/
package tech.kadence.core
@@ -113,6 +114,7 @@ class DomainMatcher(
) {
private val exact: Set<String> = expandedHosts.map { it.trim().lowercase() }.toSet()
private val allowMasks: List<String> = allowMasksOf(rules)
private val expansion: Expansion = parseExpansions(rules.subdomainExpansions)
private val rawEntries: List<String> = rules.blockedDomains
.filter { it.isNotBlank() }
.map { it.trim().lowercase().trimStart('.') }
@@ -137,7 +139,8 @@ class DomainMatcher(
return BlockDecision.Block(host, rule, BlockAction.BLOCKED)
}
// 3. The raw entries, as patterns. This is the step the desktop cannot do.
// 3. The raw entries, as patterns. This is the step a hosts file cannot do —
// a `*gram*` mask, or every subdomain once `*` says so.
for (entry in rawEntries) {
if (entryCovers(entry, host)) {
return BlockDecision.Block(host, entry, BlockAction.BLOCKED)
@@ -148,13 +151,30 @@ class DomainMatcher(
}
/**
* A mask is glob-matched; a plain entry covers itself and any subdomain of it, so
* `youtube.com` catches `music.youtube.com` even though no expansion listed it.
* How much of a hostname one block-list entry covers.
*
* * a plain entry covers itself and any subdomain of it, so `youtube.com` catches
* `music.youtube.com` even though no expansion listed it;
* * `*.youtube.com` is a wildcard only when `*` is in `subdomain_expansions` —
* otherwise it means exactly the configured labels, which is what the desktop
* app expands into a hosts file, and what the settings screens say;
* * any other mask (`*gram*`, `*gram.com`) is glob-matched, as it always was —
* the one place the phone stays stronger than a hosts file.
*/
private fun entryCovers(entry: String, host: String): Boolean = when {
entry.isEmpty() -> false
isMask(entry) -> globMatches(entry, host)
else -> host == entry || host.endsWith(".$entry")
private fun entryCovers(entry: String, host: String): Boolean {
if (entry.isEmpty()) return false
if (entry.startsWith("*.")) {
val base = entry.removePrefix("*.")
if (base.isNotEmpty() && '*' !in base) {
if (expansion.wildcard) return globMatches(entry, host)
return host == base || expansion.labels.any { host == "$it.$base" }
}
}
return if (isMask(entry)) {
globMatches(entry, host)
} else {
host == entry || host.endsWith(".$entry")
}
}
/** The masks the server had to give up on, which this client does enforce. */
@@ -233,6 +233,41 @@ class BlockerTest {
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "12:00")).active)
}
@Test
fun `a wildcard is the user asking for every subdomain`() {
// `*` used to be treated as a subdomain label, so `*.telegram.org` expanded to
// the literal string `*.telegram.org` — which every client then dropped without
// a word. It is the wildcard now: the labels still expand into real hostnames,
// and the pattern comes back for the matcher to take the rest.
val rules = BlockerRules(
blockedDomains = listOf("*.telegram.org"),
subdomainExpansions = listOf("*", "web", "", "not a label"),
)
assertEquals(listOf("web"), parseExpansions(rules.subdomainExpansions).labels)
assertTrue(parseExpansions(rules.subdomainExpansions).wildcard)
assertEquals(listOf("not a label"), invalidExpansions(rules))
assertEquals(listOf("telegram.org", "web.telegram.org"), expandDomains(rules))
assertTrue(expandDomains(rules).none { '*' in it })
assertEquals(listOf("*.telegram.org"), unexpandableMasks(rules))
}
@Test
fun `without a wildcard the mask stays inside the expansion list`() {
// The documented meaning of `*.google.com` before `*` existed: the bare domain
// plus the labels the user configured, nothing more, and nothing for a hosts
// file to refuse.
val rules = BlockerRules(
blockedDomains = listOf("*.google.com"),
subdomainExpansions = listOf("www", "mail"),
)
assertFalse(parseExpansions(rules.subdomainExpansions).wildcard)
assertEquals(emptyList(), unexpandableMasks(rules))
assertEquals(
listOf("google.com", "www.google.com", "mail.google.com"),
expandDomains(rules),
)
}
@Test
fun `zone parsing falls back instead of throwing`() {
assertEquals(ZoneId.of("Europe/Moscow"), zoneOrDefault("Europe/Moscow"))
@@ -153,10 +153,12 @@ class DomainMatcherTest {
@Test
fun `a subdomain mask blocks the base domain too, via expansion`() {
val m = matcher(blocked = listOf("*.reddit.com"))
val m = matcher(blocked = listOf("*.reddit.com"), subs = listOf("www", "old"))
assertBlocked(m.decide("reddit.com"), "reddit.com", "*.reddit.com")
assertBlocked(m.decide("www.reddit.com"), "www.reddit.com", "*.reddit.com")
assertBlocked(m.decide("old.reddit.com"), "old.reddit.com", "*.reddit.com")
// …and only those: without `*` in the expansion list, an unlisted label is out.
assertEquals(BlockDecision.Allow, m.decide("cdn.reddit.com"))
}
@Test
@@ -205,6 +207,32 @@ class DomainMatcherTest {
assertEquals("youtube.com", block.matchedRule)
}
@Test
fun `without a wildcard a subdomain mask covers only the configured labels`() {
// What the settings screens promise for `*.google.com`: the bare domain plus
// the labels listed in `subdomain_expansions`, and nothing else.
val m = matcher(blocked = listOf("*.google.com"), subs = listOf("www", "mail"))
assertBlocked(m.decide("google.com"), "google.com", "*.google.com")
assertBlocked(m.decide("mail.google.com"), "mail.google.com", "*.google.com")
assertEquals(BlockDecision.Allow, m.decide("cdn.google.com"))
}
@Test
fun `a wildcard in the expansion list makes the mask cover every label`() {
val m = matcher(blocked = listOf("*.google.com"), subs = listOf("*", "www"))
assertBlocked(m.decide("cdn.google.com"), "cdn.google.com", "*.google.com")
assertBlocked(m.decide("a.b.google.com"), "a.b.google.com", "*.google.com")
// The bare domain still belongs to the mask, via its expansion.
assertBlocked(m.decide("google.com"), "google.com", "*.google.com")
// And the allow list still wins over the wildcard.
val allowed = matcher(
blocked = listOf("*.google.com"),
allowed = listOf("docs.google.com"),
subs = listOf("*"),
)
assertEquals(BlockDecision.Allow, allowed.decide("docs.google.com"))
}
@Test
fun `case and whitespace in the list do not matter`() {
val m = matcher(blocked = listOf(" YouTube.COM "))