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

@@ -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)
}
}