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