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:
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"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user