disallow to disable guard during focus
docker / image (push) Failing after 24s
lint / ruff (push) Failing after 28s
tests / backend (push) Successful in 37s
tests / desktop (push) Successful in 22s
tests / android-core (push) Successful in 49s
tests / web (push) Successful in 1m20s
android / apk (push) Successful in 8m49s

This commit is contained in:
timka123 committed 2026-10-28 09:50:58 +03:00
1 parent 157a113c39
commit d280101bce
21 files changed
+481 -43

No files matched your search

@@ -62,6 +62,7 @@ import tech.kadence.core.DomainMatcher
import tech.kadence.core.IntervalFacts
import tech.kadence.core.evaluatePolicy
import tech.kadence.core.expandDomains
import tech.kadence.core.guardLocked
import tech.kadence.core.invalidExpansions
import tech.kadence.core.unexpandableMasks
import tech.kadence.core.zoneOrDefault
@@ -83,7 +84,10 @@ data class BlockerUi(
val reason: String = "",
val enabled: Boolean = false,
val mode: String = MODE_DURING_WORK,
val strict: Boolean = false,
/** The guard is locked on — an interval is live, so it cannot be switched off. */
val locked: Boolean = false,
/** The server's sentence for why, shown under the toggle. */
val lockReason: String = "",
val killApps: Boolean = true,
val notifyOnBlock: Boolean = true,
/** Hostnames in the expanded set — `all_domains`, so it is non-zero even when idle. */
@@ -481,7 +485,8 @@ class BlockerEngine(
reason = dto.reason,
enabled = dto.enabled,
mode = dto.mode,
strict = dto.strict,
locked = dto.locked,
lockReason = dto.lockReason,
killApps = dto.killApps,
notifyOnBlock = dto.notifyOnBlock,
hostnames = dto.allDomains.size,
@@ -509,7 +514,8 @@ class BlockerEngine(
reason = verdict.reason,
enabled = current?.enabled ?: false,
mode = current?.mode ?: MODE_DURING_WORK,
strict = current?.strict ?: false,
locked = guardLocked(current ?: BlockerRules(), timerFacts()),
lockReason = "",
killApps = current?.killBlockedApps ?: true,
notifyOnBlock = current?.notifyOnBlock ?: true,
hostnames = current?.let { expandDomains(it).size } ?: 0,
@@ -648,6 +654,5 @@ fun BlockerSettingsDto.toCore(): BlockerRules = BlockerRules(
blockedApps = blockedApps,
killBlockedApps = killBlockedApps,
schedule = schedule.map { BlockWindow(it.label, it.start, it.end, it.days) },
strict = strict,
notifyOnBlock = notifyOnBlock,
)
@@ -140,7 +140,9 @@ data class BlockerPolicyDto(
val reason: String = "",
val mode: String = "during_work",
val enabled: Boolean = false,
val strict: Boolean = false,
/** The guard is on and an interval is live, so it may not be switched off. */
val locked: Boolean = false,
@SerialName("lock_reason") val lockReason: String = "",
@SerialName("redirect_ip") val redirectIp: String = "127.0.0.1",
/** Empty whenever `active` is false — read `all_domains` instead. */
val domains: List<String> = emptyList(),
@@ -395,7 +397,6 @@ data class BlockerSettingsDto(
@SerialName("blocked_apps") val blockedApps: List<String> = emptyList(),
@SerialName("kill_blocked_apps") val killBlockedApps: Boolean = true,
val schedule: List<BlockWindowDto> = emptyList(),
val strict: Boolean = false,
@SerialName("notify_on_block") val notifyOnBlock: Boolean = true,
)
@@ -237,18 +237,26 @@ fun BlockerScreen(
Panel(padding = 0.dp) {
ToggleRow(
label = "Focus guard on",
detail = if (ui.enabled) {
"Everything below applies."
} else {
"Off everywhere — the phone, the desktop app and the web UI all " +
detail = when {
ui.locked -> ui.lockReason.ifBlank {
"Finish or drop the interval to switch the guard off."
}
ui.enabled -> "Everything below applies."
else -> "Off everywhere — the phone, the desktop app and the web UI all " +
"stop enforcing."
},
checked = ui.enabled,
// The rules are the server's document and there is no outbox for it:
// a settings PUT is a deep merge of a shared object, and queueing one
// would mean replaying a stale version over whatever the web UI did
// meanwhile. So offline these say no, rather than appearing to work.
enabled = ui.connection == Connection.Online,
// Two reasons this says no, both said out loud rather than left as a
// grey switch:
// * offline — the rules are the server's document and there is no
// outbox for it: a settings PUT is a deep merge of a shared object,
// and queueing one would mean replaying a stale version over
// whatever the web UI did meanwhile;
// * locked — the server refuses to switch the guard off while an
// interval is running or paused, which is the whole point of it.
// Switching it *on* is never refused, which is why `locked` already has
// "and the guard is on" folded into it server-side.
enabled = ui.connection == Connection.Online && !ui.locked,
onChange = { value -> patch(mapOf("enabled" to value)) },
)
}
@@ -75,6 +75,15 @@ class BlockerEngineTest {
return engine
}
@Test
fun `the lock reaches the screen from the server's policy`() {
// The screen never decides this itself — it renders what the server said,
// and the server has already folded "and the guard is on" into `locked`.
val ui = engine().state.value
assertTrue(ui.locked)
assertEquals("Finish or drop the interval to switch the focus guard off.", ui.lockReason)
}
@Before
fun setUp() {
server = MockWebServer()
@@ -49,7 +49,6 @@ class BlockerSettingsTest {
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)
@@ -106,7 +105,6 @@ class BlockerSettingsTest {
assertTrue(blocker.blockedApps.isEmpty())
assertTrue(blocker.killBlockedApps)
assertTrue(blocker.notifyOnBlock)
assertFalse(blocker.strict)
assertEquals(BlockerRules.DEFAULT_SUBDOMAINS, blocker.subdomainExpansions)
}
@@ -118,7 +116,7 @@ class BlockerSettingsTest {
{"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
"kill_blocked_apps":null,"notify_on_block":null
}}}
""".trimIndent(),
)
@@ -138,4 +136,24 @@ class BlockerSettingsTest {
val inactive = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_inactive.json"))
assertTrue(inactive.invalidExpansions.isEmpty())
}
@Test
fun `the policy carries whether the guard is locked on`() {
val active = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_active.json"))
assertTrue(active.locked)
assertEquals("Finish or drop the interval to switch the focus guard off.", active.lockReason)
val inactive = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_inactive.json"))
assertFalse(inactive.locked)
assertEquals("", inactive.lockReason)
}
@Test
fun `a backend too old to send the lock reads as unlocked`() {
// The switch then looks operable and the server still refuses the write with a
// 409 — which is why the lock cannot live in the UI alone.
val policy = KadenceJson.decodeFromString<BlockerPolicyDto>("""{"enabled":true}""")
assertFalse(policy.locked)
assertEquals("", policy.lockReason)
}
}
@@ -3,7 +3,8 @@
"reason": "a work interval is running",
"mode": "during_work",
"enabled": true,
"strict": false,
"locked": true,
"lock_reason": "Finish or drop the interval to switch the focus guard off.",
"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*"],
@@ -3,7 +3,8 @@
"reason": "no work interval is running",
"mode": "during_work",
"enabled": true,
"strict": false,
"locked": false,
"lock_reason": "",
"redirect_ip": "127.0.0.1",
"domains": [],
"apps": [],
@@ -27,7 +27,6 @@
"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
@@ -39,7 +39,6 @@ data class BlockerRules(
val blockedApps: List<String> = emptyList(),
val killBlockedApps: Boolean = true,
val schedule: List<BlockWindow> = emptyList(),
val strict: Boolean = false,
val notifyOnBlock: Boolean = true,
) {
companion object {
@@ -246,6 +245,20 @@ fun evaluatePolicy(
}
}
/**
* The port of `guard_locked()`: whether the guard may be switched off right now.
*
* A free function rather than a field on [PolicyVerdict], because this is a verdict
* about *editing* the rules and not about whether to block — and the two answers do
* disagree: scheduled mode outside its window is `active = false`, `locked = true`.
*
* Any kind of interval counts, running or paused. Switching the guard *on* is never
* locked, which is why "and the guard is on" is folded in here rather than left to
* the screen.
*/
fun guardLocked(rules: BlockerRules, current: IntervalFacts?): Boolean =
rules.enabled && current != null && current.isActiveStatus
/** Convenience for callers that only have an IANA zone id string. */
fun zoneOrDefault(timezone: String?): ZoneId =
runCatching { ZoneId.of(timezone?.trim().orEmpty()) }.getOrElse { ZoneId.systemDefault() }
@@ -303,4 +303,42 @@ class BlockerTest {
assertEquals(listOf("reddit.com", "www.reddit.com", "m.reddit.com"), expandDomains(rules))
}
// ---- the lock -----------------------------------------------------------
// Parity with `blocker_service.guard_locked()`.
@Test
fun `a live interval of any kind locks the guard on`() {
val on = BlockerRules(enabled = true)
assertTrue(guardLocked(on, working))
assertTrue(guardLocked(on, IntervalFacts("work", "paused")))
assertTrue(guardLocked(on, breaking))
assertTrue(guardLocked(on, IntervalFacts("long_break", "paused")))
}
@Test
fun `nothing live leaves the guard switchable`() {
val on = BlockerRules(enabled = true)
assertFalse(guardLocked(on, null))
assertFalse(guardLocked(on, IntervalFacts("work", "completed")))
assertFalse(guardLocked(on, IntervalFacts("work", "abandoned")))
}
@Test
fun `a guard that is off is never locked, so it can always be switched on`() {
assertFalse(guardLocked(BlockerRules(enabled = false), working))
}
@Test
fun `scheduled mode outside its window is inactive and still locked`() {
// The distinction `active` cannot express: nothing is being blocked right now,
// and the guard still must not be switched off before the window opens.
val rules = BlockerRules(
enabled = true,
mode = "scheduled",
schedule = listOf(BlockWindow("Work hours", "09:00", "18:00", listOf(0))),
)
assertFalse(evaluatePolicy(rules, working, at("2026-10-19", "20:00")).active)
assertTrue(guardLocked(rules, working))
}
}