blockers: the report's cooldown was deciding whether to block

Both clients made the same mistake, in the same shape: the throttle that keeps
the daily report from filling with one row per poll was placed in front of the
enforcement call. So blocking worked exactly once per target, and the second
launch of a banned app — or the second visit to a banned site — simply went
through.

Android, `BlockerEngine`: `claim()` now gates only `report()`/`announce()`.
Enforcement runs on every decision behind a 1.2s debounce whose only job is to
stop the accessibility event and the usage-stats poll both pressing home for one
visit. An app still in front on the next pass is pressed again — one
GLOBAL_ACTION_HOME is a request, not a result — and a visit is no longer
consumed when no accessibility service is attached to consume it with, which
turned a missing grant into a silently unblocked app. `lastForeground` and
`lastBlockedApp` are @Volatile like every other field here; they are written
from the service's main thread and the poll coroutine both. The usage-stats
cadence now follows the server's `app_poll_seconds`, and the first look happens
at once rather than four seconds in.

Linux, `BlockerManager._enforce_apps`: the pid went into the dedupe set *before*
the kill was attempted, so a process whose signal was refused was exempt for the
rest of the interval, and a pid is reused — a relaunch landing on a recycled
number inherited the exemption. Processes are now identified by (pid, start
time) from /proc/<pid>/stat, the kill is attempted on every pass, and only the
report is throttled. The books are pruned of dead processes so an all-day
interval cannot leak.

`processes.terminate()` no longer busy-waits two seconds for the process to die
before escalating — on the desktop that wait ran on the thread drawing the
countdown. SIGTERM now, SIGKILL on a later pass once the grace has gone by,
which is also gentler than the old fixed two seconds. With nothing blocking,
the scan gets its own timer at the cadence the server asks for instead of
riding the five-second policy poll.

Neither enforcement path had any test: the desktop's blocker suite hardcoded an
empty app list and nothing anywhere touched BlockerEngine. 16 new tests, on
fakes and the committed fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
timka123andClaude Opus 5 committed 2026-10-22 17:16:56 +03:00
1 parent 5c6710ae3d
commit 7b7b50bc8e
6 files changed
+716 -51

No files matched your search

@@ -111,6 +111,13 @@ class BlockerEngine(
private val configured: () -> Boolean,
/** The running interval, if any — `during_work` cannot be decided without it. */
private val timerFacts: () -> IntervalFacts?,
/**
* Wall clock for the two cooldowns, injectable so the tests can step it.
*
* Not `nanoTime`: these windows are seconds long and a dozy phone's monotonic clock
* is not the thing being measured — "has twenty seconds of my day passed" is.
*/
private val now: () -> Long = System::currentTimeMillis,
) {
private val _state = MutableStateFlow(BlockerUi())
val state: StateFlow<BlockerUi> = _state.asStateFlow()
@@ -147,15 +154,29 @@ class BlockerEngine(
private var leaveForegroundAppFn: (() -> Unit)? = null
/**
* Targets we have already dealt with, and when.
* Targets we have already **reported**, and when.
*
* The same page fires `TYPE_WINDOW_CONTENT_CHANGED` several times a second and the
* same app sits in front for minutes; without this the report would fill with one
* entry per event. Keyed by `kind:target`, so the cooldowns do not collide.
*
* It throttles the *report*, and nothing else. It used to sit in front of the
* enforcement call as well, which is a different thing entirely: for twenty seconds
* after the first bounce the site or app was simply free, so opening it a second
* time worked. [enforcing] is the only cooldown enforcement consults, and it is
* barely longer than one event burst.
*/
private val recentlyBlocked = ConcurrentHashMap<String, Long>()
/** When each target was last sent home, so one visit does not fire home twice. */
private val recentlyEnforced = ConcurrentHashMap<String, Long>()
// Written from the accessibility service's main-thread callback and from the
// app-poll coroutine both, like everything else in this class.
@Volatile
private var lastForeground: String? = null
@Volatile
private var lastBlockedApp: String? = null
private var pollJob: Job? = null
@@ -204,8 +225,17 @@ class BlockerEngine(
// ---- the accessibility service's two entry points -----------------------
/** Wire the service's ability to send the user home while [service] is connected. */
fun attachCloser(service: BlockerAccessibilityService) {
leaveForegroundAppFn = { service.goHome() }
fun attachCloser(service: BlockerAccessibilityService) = attachCloser { service.goHome() }
/**
* The same, as a bare function.
*
* Enforcement is the half of this class that was broken, so it has to be reachable
* from a test — and a real [BlockerAccessibilityService] is not something a JVM test
* can bind.
*/
fun attachCloser(closer: () -> Unit) {
leaveForegroundAppFn = closer
}
fun detachCloser() {
@@ -222,6 +252,14 @@ class BlockerEngine(
if (!ui.active) return
val domains = matchers().first
val decision = domains.decide(candidate) as? BlockDecision.Block ?: return
// Enforce first, and on every visit: the report's cooldown has no business
// deciding whether a blocked site opens. `goHome()` does not close the tab, so
// coming back to it through Recents re-fires the window event and lands here
// again — which is the behaviour anyone would expect from a blocker.
if (enforcing("domain:${decision.target}")) {
leaveForegroundAppFn?.invoke()
}
if (!claim("domain:${decision.target}", DOMAIN_COOLDOWN_MS)) return
report("domain", decision.target, BlockAction.BLOCKED, browserPackage ?: "browser")
@@ -229,7 +267,6 @@ class BlockerEngine(
decision.target,
"Blocked by `${decision.matchedRule}`.",
)
leaveForegroundAppFn?.invoke()
}
/**
@@ -238,6 +275,11 @@ class BlockerEngine(
* Reporting is once per *visit*, not once per poll: [lastBlockedApp] is cleared the
* moment the foreground moves anywhere else, so reopening a blocked app counts
* again while sitting in it does not.
*
* Enforcement is *not* once per visit, and not on the report's cooldown either: if
* the app is still in front on the next poll, home is pressed again. One
* `GLOBAL_ACTION_HOME` is a request, not a proof — an app that survives it, or that
* the user walks straight back into, must not be left alone for twenty seconds.
*/
fun onForegroundApp(packageName: String, label: String = "") {
if (packageName == context.packageName) {
@@ -253,8 +295,19 @@ class BlockerEngine(
val decision = matchers().second.decide(packageName, label, ui.killApps)
if (decision !is BlockDecision.Block) return
if (packageName == lastBlockedApp) return
lastBlockedApp = packageName
val eject = decision.action == BlockAction.KILLED
if (eject) {
// Every pass, debounced only against this one visit's duplicate signals.
// Whether the attempt is worth *reporting* is decided below and separately;
// conflating the two is what let the second launch through.
if (enforcing("app:${decision.target}")) leaveForegroundAppFn?.invoke()
} else {
// Warn mode ejects nothing, so there is nothing to repeat: one notice per
// visit, cleared when the foreground moves on.
if (packageName == lastBlockedApp) return
lastBlockedApp = packageName
}
if (!claim("app:${decision.target}", APP_COOLDOWN_MS)) return
report(
@@ -263,9 +316,8 @@ class BlockerEngine(
action = decision.action,
details = if (label.isBlank()) packageName else "$label ($packageName)",
)
if (decision.action == BlockAction.KILLED) {
if (eject) {
announce(decision.target, "Closed while it is on the block list.")
leaveForegroundAppFn?.invoke()
} else {
announce(decision.target, "On the block list — `kill_blocked_apps` is off.")
}
@@ -298,6 +350,9 @@ class BlockerEngine(
}
private suspend fun appLoop() {
// No initial delay: if the guard is already on when the process starts — a
// scheduled window, or an interval running on the desktop — the app in front
// right now is the one that needs looking at.
while (scope.isActive) {
// Only while enforcing: usage stats are a battery cost worth paying when a
// block is possible, and pure waste when it is not.
@@ -305,10 +360,22 @@ class BlockerEngine(
val pkg = SystemProbes.foregroundPackage(context)
if (pkg != null) onForegroundApp(pkg, SystemProbes.appLabel(context, pkg))
}
delay(APP_POLL_MS)
delay(appPollMs())
}
}
/**
* How long to wait between usage-stats looks.
*
* The server says how often it wants the app list checked
* (`blocker.app_poll_seconds`); [APP_POLL_MS] is what we use before the first policy
* lands, and the bounds are because this value comes from a document a human edits.
*/
private fun appPollMs(): Long {
val asked = policy?.pollSeconds?.times(1000L) ?: return APP_POLL_MS
return asked.coerceIn(MIN_APP_POLL_MS, MAX_APP_POLL_MS)
}
/** Settings are read once on start and after every edit, not on every poll. */
private suspend fun loadSettings() {
if (!configured()) return
@@ -445,14 +512,31 @@ class BlockerEngine(
* accessibility service's main-thread callback and from the app-poll coroutine at
* once, and the only shared fact is a timestamp.
*/
/**
* Whether to act on this target now.
*
* Not a cooldown in the [claim] sense — a debounce. Its only job is to stop the
* accessibility event and the usage-stats poll both pressing home for the same
* visit, which would bounce the launcher as well. Short enough that a deliberate
* second attempt always gets through.
*/
private fun enforcing(target: String): Boolean {
val stamp = now()
val previous = recentlyEnforced[target]
if (previous != null && stamp - previous < ENFORCE_DEBOUNCE_MS) return false
recentlyEnforced[target] = stamp
return true
}
private fun claim(target: String, windowMs: Long): Boolean {
val now = System.currentTimeMillis()
val stamp = now()
while (true) {
val previous = recentlyBlocked[target]
when {
previous != null && now - previous < windowMs -> return false
previous == null -> if (recentlyBlocked.putIfAbsent(target, now) == null) return true
recentlyBlocked.replace(target, previous, now) -> return true
previous != null && stamp - previous < windowMs -> return false
previous == null ->
if (recentlyBlocked.putIfAbsent(target, stamp) == null) return true
recentlyBlocked.replace(target, previous, stamp) -> return true
}
}
}
@@ -465,9 +549,16 @@ class BlockerEngine(
/** Usage stats update in coarse buckets; more often than this buys nothing. */
const val APP_POLL_MS = 4_000L
/** One bounce per target at a time — the URL bar re-fires constantly. */
/** Nobody's `app_poll_seconds` may spin the loop or let a game run a minute. */
const val MIN_APP_POLL_MS = 1_000L
const val MAX_APP_POLL_MS = 30_000L
/** One *report* per target at a time — the URL bar re-fires constantly. */
const val DOMAIN_COOLDOWN_MS = 20_000L
const val APP_COOLDOWN_MS = 20_000L
/** How long one visit's duplicate signals are folded into one bounce. */
const val ENFORCE_DEBOUNCE_MS = 1_200L
}
}
@@ -0,0 +1,174 @@
package tech.kadence.android
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import mockwebserver3.MockResponse
import mockwebserver3.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import tech.kadence.android.blocker.BlockerEngine
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.net.AuthConfig
import tech.kadence.android.data.net.KadenceApi
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The guard's enforcement, which is the half that was broken.
*
* One bug, in two places: the twenty-second cooldown that keeps the daily report
* readable was also gating `goHome()`. Blocking worked exactly once per target per
* twenty seconds — open the app or the site again and nothing happened at all, which is
* the whole of "the blocker lets the second launch through".
*
* So these tests step a fake clock and count bounces against reports. They need no
* device and no backend: MockWebServer serves the committed policy fixture, as the rest
* of this suite does.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class BlockerEngineTest {
private lateinit var server: MockWebServer
private var clock = 1_000_000L
private var bounces = 0
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name"))
.bufferedReader().readText()
private fun engine(withCloser: Boolean = true): BlockerEngine {
val api = KadenceApi(config = {
object : AuthConfig {
override val baseUrl = server.url("/").toString().trimEnd('/')
override val apiToken = "token"
}
})
val engine = BlockerEngine(
context = ApplicationProvider.getApplicationContext(),
repo = KadenceRepository(api),
scope = scope,
configured = { true },
timerFacts = { null },
now = { clock },
)
if (withCloser) engine.attachCloser { bounces++ }
// One poll, so the engine holds the server's "a work interval is running".
runBlocking { engine.refreshNow().join() }
assertTrue(engine.state.value.active, "the fixture says the guard is on")
return engine
}
@Before
fun setUp() {
server = MockWebServer()
server.start()
server.dispatcher = object : mockwebserver3.Dispatcher() {
override fun dispatch(request: mockwebserver3.RecordedRequest): MockResponse {
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")
else -> "{}"
}
return MockResponse.Builder()
.code(200)
.addHeader("Content-Type", "application/json")
.body(body)
.build()
}
}
}
@After
fun tearDown() {
server.close()
}
@Test
fun `a blocked app is ejected every time it is opened, not once per cooldown`() {
val engine = engine()
engine.onForegroundApp("com.valvesoftware.steam", "Steam")
assertEquals(1, bounces)
// Home was pressed, so the launcher is in front: the visit is over.
engine.onForegroundApp("com.android.launcher", "Launcher")
// A second launch, well inside the twenty-second report cooldown.
clock += 3_000
engine.onForegroundApp("com.valvesoftware.steam", "Steam")
assertEquals(2, bounces, "the second launch must be ejected too")
}
@Test
fun `sitting in a blocked app keeps it being ejected`() {
val engine = engine()
// What the usage-stats poll produces: the same package, over and over.
repeat(4) {
engine.onForegroundApp("com.valvesoftware.steam", "Steam")
clock += 4_000
}
assertEquals(4, bounces, "one press of home is a request, not a result")
}
@Test
fun `one visit's duplicate signals are folded into a single bounce`() {
val engine = engine()
// The accessibility event and the app poll both land on the same visit.
engine.onForegroundApp("com.valvesoftware.steam", "Steam")
clock += 200
engine.onForegroundApp("com.valvesoftware.steam", "Steam")
assertEquals(1, bounces)
}
@Test
fun `a blocked site is bounced on every visit`() {
val engine = engine()
engine.onUrlSeen("youtube.com", "com.android.chrome")
clock += 1_500
engine.onUrlSeen("youtube.com", "com.android.chrome")
assertEquals(2, bounces, "goHome does not close the tab — coming back must bounce")
}
@Test
fun `a missing accessibility service does not look like a successful block`() {
val engine = engine(withCloser = false)
engine.onForegroundApp("com.valvesoftware.steam", "Steam")
engine.onForegroundApp("com.android.launcher", "Launcher")
clock += 3_000
engine.onForegroundApp("com.valvesoftware.steam", "Steam")
assertEquals(0, bounces, "nothing to enforce with")
// And when the grant arrives, the very next look enforces.
engine.attachCloser { bounces++ }
clock += 3_000
engine.onForegroundApp("com.valvesoftware.steam", "Steam")
assertEquals(1, bounces)
}
@Test
fun `an app that is not on the list is left alone`() {
val engine = engine()
engine.onForegroundApp("org.gnu.emacs", "Emacs")
engine.onUrlSeen("kadence.timka.su", "com.android.chrome")
assertEquals(0, bounces)
}
}