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