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

@@ -0,0 +1,141 @@
/**
* The blocker's settings subtree: wire keys in, [BlockerRules] out.
*
* The mapping lives at the top of `BlockerEngine.kt` and is exercised here rather than
* behind a screen, because it is the part of the focus guard that fails *silently* — a
* mistyped key decodes to a default, the phone reports itself enforcing, and nothing
* blocks. Offline enforcement (`evaluatePolicy`) runs at the end of the happy-path
* test for the same reason: it is the only consumer of what this function produces
* that cannot be observed while online.
*/
package tech.kadence.android
import org.junit.Test
import tech.kadence.android.blocker.toCore
import tech.kadence.android.data.dto.BlockerPolicyDto
import tech.kadence.android.data.dto.BlockerSettingsDto
import tech.kadence.android.data.dto.SettingsEnvelopeDto
import tech.kadence.android.data.net.KadenceJson
import tech.kadence.core.BlockAction
import tech.kadence.core.BlockDecision
import tech.kadence.core.BlockerRules
import tech.kadence.core.DomainMatcher
import tech.kadence.core.evaluatePolicy
import tech.kadence.core.invalidExpansions
import tech.kadence.core.unexpandableMasks
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BlockerSettingsTest {
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) {
"missing fixture: $name"
}.bufferedReader().readText()
@Test
fun `the settings envelope carries the blocker subtree`() {
val envelope = KadenceJson.decodeFromString<SettingsEnvelopeDto>(fixture("settings.json"))
val blocker = envelope.settings.blocker
assertTrue(blocker.enabled)
assertEquals("scheduled", blocker.mode)
assertTrue(blocker.blockDuringBreaks)
assertEquals(listOf("youtube.com", "*.reddit.com", "*gram*"), blocker.blockedDomains)
assertEquals(listOf("music.youtube.com"), blocker.allowedDomains)
assertEquals(listOf("*discord*", "instagram"), blocker.blockedApps)
assertFalse(blocker.killBlockedApps)
assertFalse(blocker.notifyOnBlock)
assertTrue(blocker.strict)
assertEquals(1, blocker.schedule.size)
assertEquals("Work mornings", blocker.schedule[0].label)
assertEquals(listOf(0, 1, 2, 3, 4), blocker.schedule[0].days)
assertEquals("Europe/Kyiv", envelope.settings.timezone)
}
@Test
fun `the DTO maps onto BlockerRules and enforces when online rules cannot`() {
val envelope = KadenceJson.decodeFromString<SettingsEnvelopeDto>(fixture("settings.json"))
val rules = envelope.settings.blocker.toCore()
// The keys, spelled out — this is what the assertions are actually about.
assertEquals(listOf("www", "m", "web", "*"), rules.subdomainExpansions)
assertEquals(listOf("youtube.com", "*.reddit.com", "*gram*"), rules.blockedDomains)
assertFalse(rules.killBlockedApps)
assertEquals(1, rules.schedule.size)
assertEquals("09:00:00", rules.schedule[0].start)
// `*` is the wildcard flag rather than a label; only genuine junk is refused.
// The wildcard itself surfaces as an extra "unexpandable" pattern alongside
// `*gram*`, which is the matcher's to enforce on top of the hosts file.
assertEquals(emptyList<String>(), invalidExpansions(rules))
assertEquals(listOf("*.reddit.com", "*gram*"), unexpandableMasks(rules))
// Monday 10:00 Kyiv sits inside the window, so the offline verdict agrees with
// what the server would have said: "inside a blocking window".
val now = ZonedDateTime.of(2026, 10, 19, 10, 0, 0, 0, ZoneId.of("Europe/Kyiv"))
val verdict = evaluatePolicy(rules, current = null, now)
assertTrue(verdict.active)
assertEquals("inside a blocking window", verdict.reason)
// …and the matcher blocks a host on the list while letting an exception through.
// The target is the hostname actually visited, which is what the report groups
// by — `m.youtube.com`, not the `youtube.com` entry that covered it.
val matcher = DomainMatcher(rules)
val youtube = matcher.decide("https://m.youtube.com/watch?v=x")
assertTrue(youtube is BlockDecision.Block)
assertEquals(BlockAction.BLOCKED, youtube.action)
assertEquals("m.youtube.com", youtube.target)
assertEquals("youtube.com", youtube.matchedRule)
assertTrue(matcher.decide("music.youtube.com") is BlockDecision.Allow)
// The fixture lists `*` in `subdomain_expansions`, so `*.reddit.com` covers
// labels no expansion named — the wildcard doing its job end to end.
assertTrue(matcher.decide("forum.reddit.com") is BlockDecision.Block)
}
@Test
fun `an absent blocker subtree decodes to the defaults the server ships`() {
val blocker = KadenceJson.decodeFromString<BlockerSettingsDto>("{}")
assertFalse(blocker.enabled)
assertEquals("during_work", blocker.mode)
assertTrue(blocker.blockedDomains.isEmpty())
assertTrue(blocker.allowedDomains.isEmpty())
assertTrue(blocker.blockedApps.isEmpty())
assertTrue(blocker.killBlockedApps)
assertTrue(blocker.notifyOnBlock)
assertFalse(blocker.strict)
assertEquals(BlockerRules.DEFAULT_SUBDOMAINS, blocker.subdomainExpansions)
}
@Test
fun `nulls where a value is declared fall back to defaults`() {
// `coerceInputValues` is what saves us here; without it this throws.
val nulled = KadenceJson.decodeFromString<SettingsEnvelopeDto>(
"""
{"settings":{"blocker":{
"enabled":null,"mode":null,"blocked_domains":null,
"allowed_domains":null,"blocked_apps":null,"subdomain_expansions":null,
"kill_blocked_apps":null,"notify_on_block":null,"strict":null
}}}
""".trimIndent(),
)
val blocker = nulled.settings.blocker
assertFalse(blocker.enabled)
assertEquals("during_work", blocker.mode)
assertTrue(blocker.blockedDomains.isEmpty())
assertTrue(blocker.killBlockedApps)
assertEquals(BlockerRules.DEFAULT_SUBDOMAINS, blocker.subdomainExpansions)
}
@Test
fun `the policy reports invalid expansions and defaults to none`() {
val active = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_active.json"))
assertEquals(listOf("not a label"), active.invalidExpansions)
val inactive = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_inactive.json"))
assertTrue(inactive.invalidExpansions.isEmpty())
}
}
@@ -247,6 +247,7 @@ class DtoToleranceTest {
"timer_state_running.json", "timer_state_idle.json", "timer_complete_reply.json",
"blocker_policy_active.json", "blocker_policy_inactive.json", "tasks.json",
"daily_report.json", "stats_summary.json", "block_events.json", "auth_me.json",
"settings.json",
)
for (name in names) {
val parsed = KadenceJson.parseToJsonElement(fixture(name))
@@ -8,6 +8,7 @@
"domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
"apps": ["steam*", "*discord*"],
"unexpandable": ["*gram*"],
"invalid_expansions": ["not a label"],
"kill_apps": true,
"notify_on_block": true,
"poll_seconds": 5,
@@ -0,0 +1,41 @@
{
"settings": {
"pomodoro": {
"work_minutes": 25,
"short_break_minutes": 5,
"long_break_minutes": 15,
"cycles_before_long_break": 4,
"daily_goal_pomodoros": 8,
"timer_enabled": true,
"strict_mode": false,
"allow_pause": true,
"auto_start_breaks": false,
"auto_start_work": false,
"presets": [
{"name": "deep", "work_minutes": 50, "short_break_minutes": 10, "long_break_minutes": 20, "cycles_before_long_break": 4}
]
},
"blocker": {
"enabled": true,
"mode": "scheduled",
"block_during_breaks": true,
"blocked_domains": ["youtube.com", "*.reddit.com", "*gram*"],
"allowed_domains": ["music.youtube.com"],
"subdomain_expansions": ["www", "m", "web", "*"],
"blocked_apps": ["*discord*", "instagram"],
"kill_blocked_apps": false,
"schedule": [
{"label": "Work mornings", "start": "09:00:00", "end": "12:00:00", "days": [0, 1, 2, 3, 4]}
],
"strict": true,
"notify_on_block": false,
"redirect_ip": "0.0.0.0",
"app_poll_seconds": 5
},
"timezone": "Europe/Kyiv"
},
"defaults": {
"blocked_domains": ["*.youtube.com", "twitter.com", "x.com"],
"blocked_apps": ["steam*", "discord", "telegram-desktop", "spotify"]
}
}