diff --git a/android/README.md b/android/README.md
index 11e561f..1da74b7 100644
--- a/android/README.md
+++ b/android/README.md
@@ -18,7 +18,7 @@ even the 25×25 rear display — are **testable on a laptop with nothing but a J
no emulator, no SDK and no phone in the loop:
```bash
-make android-test # both modules: 225 tests, no device, no backend
+make android-test # both modules: 234 tests, no device, no backend
make android-test-core # just the pure-logic suite: needs only a JDK
```
@@ -31,17 +31,17 @@ command works on a machine that has never seen the Android tooling.
./gradlew :core:test :app:testDebugUnitTest
```
-**225 tests, none of which need a device or a running backend.** The backend lives only
+**234 tests, none of which need a device or a running backend.** The backend lives only
on the dev machine, so the suite feeds on committed JSON fixtures under
`app/src/test/resources/fixtures/` and a `MockWebServer`; there is no step that curls a
live server.
-`:core` (186 tests):
+`:core` (190 tests):
| Suite | What it pins down |
| --- | --- |
| `GlobTest` | Python `fnmatch` parity — expectations generated from CPython, because the block lists are shared with the backend |
-| `BlockerTest` | `blocker_service.py` parity: `expand_entry`, allow-list subtraction, the policy decision and its reason strings, schedule windows incl. midnight wrap |
+| `BlockerTest` | `blocker_service.py` parity: `expand_entry`, wildcard expansions (a bare `*` means every subdomain), allow-list subtraction, the policy decision and its reason strings, schedule windows incl. midnight wrap |
| `DomainMatcherTest` / `AppMatcherTest` | hostname and app matching, allow-list precedence, and the masks a hosts file cannot express |
| `NormalizeHostTest` | what a browser URL bar actually hands over: bare hosts, full URLs, search phrases, `chrome://`, punycode |
| `ReconcileTest` | one test per row of the conflict matrix — another device taking over, our own replay landing, view-only mode |
@@ -51,11 +51,12 @@ live server.
| `TokenParityTest` | reads `frontend/src/tokens.ts` and `lib/viz.ts` and fails if a single colour channel drifts |
| `AccentTest` | the system-accent policy and its contrast guard |
-`:app` (39 tests, on the JVM via Robolectric):
+`:app` (44 tests, on the JVM via Robolectric):
| Suite | What it pins down |
| --- | --- |
| `DtoToleranceTest` | every endpoint's fixture decodes — then decodes again with keys stripped, unknown keys added and nulls where values are declared. The backend has no response models, so this is the contract |
+| `BlockerSettingsTest` | the blocker's settings subtree: wire keys in, `BlockerRules` out, wildcard expansions, and the masks no hosts file can express — the part that fails silently if a key is mistyped |
| `KadenceApiTest` | `X-API-Token`, the session cookie, 401 routing (and *not* routing on the login path), both FastAPI `detail` shapes, a 422 rendered as `field: message`, an unreachable server as a typed error |
| `ThemeTest` | the palette actually reaching a composable, and that the serif clock is genuinely fixed-advance |
| `NormalizeBaseUrlTest` | what a phone keyboard produces becoming a URL okhttp accepts — and the scheme guess, since no certificate covers `192.168.1.40` |
@@ -113,6 +114,34 @@ the dot count.** `DEVICE_23112_MATRIX_LENGTH` is 25, `DEVICE_23112_SIZE` is that
squared. Treating it as a total and taking a square root gives a 5x5 frame, on which
`GlyphFrame.set` quietly discards everything outside the grid.
+## The focus guard
+
+Rules live on the server, like everywhere else; the Guard screen shows the policy and
+two Android facilities enforce it. Neither takes the VPN slot — that belongs to the
+user's always-on VPN:
+
+* **Websites** — there is no `/etc/hosts` to rewrite, so the app reads the browser's
+ URL bar through an **accessibility service**. A blocked address sends the browser
+ home and records the attempt. Masks mean what they mean on every client: a bare `*`
+ in the expansion list makes `*.site` cover every subdomain, and `:core`'s
+ `DomainMatcher` decides. Masks a hosts file cannot express are matched here as
+ ordinary globs against every URL, so `*gram*` works on the phone even though no
+ desktop resolver can route it.
+* **Applications** — `UsageStatsManager` notices a blocked app in the foreground and
+ the accessibility service presses **home**. Android gives no app permission to
+ force-stop another, so "closed" means "ejected to the home screen, every time", and
+ the attempt is still reported.
+* **Offline, the server's last answer keeps working.** The `blocker` half of
+ `/api/settings` is kept and re-evaluated with `:core`'s `evaluatePolicy` — the same
+ function and the same reason strings as `backend/app/services/blocker_service.py`.
+
+Two honest limitations follow:
+
+* **In-app WebViews are invisible.** A page opened inside another app has no address
+ bar the service can read; only the app list can catch it.
+* **"Kill" is not kill.** There is no user-space API to end another process; home is
+ the closest honest equivalent.
+
## Design notes
* **Fraunces has no tabular figures.** Its digits are proportional — `1` is 1024 units
@@ -144,6 +173,9 @@ squared. Treating it as a total and taking a square root gives a 5x5 frame, on w
| **`TimerService`** | foreground service, `specialUse`. Starts itself when an interval becomes active — including one started on the desktop or in Telegram — and stops when none is |
| **`Notifications`** | two channels (the countdown must be silent, its end must not) and the Android 16 Live Update |
| **`GlyphPresenter`** | the only thing between the timer state and the matrix; every frame is computed by `:core` |
+| **`BlockerEngine`** | one per process, on the Application, next to the timer: polls the policy, falls back to the kept settings when offline, and reports every turned-away attempt |
+| **`BlockerAccessibilityService`** | reads browser address bars and presses home for blocked apps — the only enforcement Android actually offers |
+| **`SystemProbes`** | the permission reads that fail *quietly* (usage access as an AppOps grant, accessibility as a `Settings.Secure` string), kept in one file so the Guard screen can say what is missing |
| **`KadenceGlyphToy`** | Kadence in the Glyph button's rotation. A long press toggles the interval |
The server owns the clock throughout. `pomodoro.state()` does **not** finish an interval
@@ -176,3 +208,14 @@ adb logcat -s Kadence | grep 'first frame'
# the toy is registered where the Glyph button can find it
adb shell pm query-services -a com.nothing.glyph.TOY | grep kadence
```
+
+The focus guard needs two hand-granted permissions, both under **Settings → Apps →
+Special access**: *Usage access* (or nothing is ever seen) and *Accessibility* (the
+`Kadence focus guard` service, or nothing can be ejected). The Guard screen shows a
+banner for whichever is missing. With both on, open a blocked site in the browser and
+watch it go home:
+
+```bash
+adb shell settings get secure enabled_accessibility_services
+adb logcat -s Kadence | grep -i blocker
+```
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b6adef3..2b3a8ac 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -16,6 +16,21 @@
permission since Android 13; the timer screen asks for it. -->
+
+
+
+
+
+
@@ -36,6 +51,24 @@
-->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt b/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt
index 40ace93..4120072 100644
--- a/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt
+++ b/android/app/src/main/kotlin/tech/kadence/android/KadenceApplication.kt
@@ -26,6 +26,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import tech.kadence.android.blocker.BlockerEngine
import tech.kadence.android.data.KadenceRepository
import tech.kadence.android.data.net.KadenceApi
import tech.kadence.android.data.store.KadencePrefs
@@ -34,6 +35,7 @@ import tech.kadence.android.glyph.newGlyphDisplay
import tech.kadence.android.notify.Notifications
import tech.kadence.android.service.TimerService
import tech.kadence.android.timer.TimerEngine
+import tech.kadence.core.IntervalFacts
private const val TAG = "Kadence"
@@ -50,6 +52,8 @@ class KadenceApplication : Application() {
private set
lateinit var engine: TimerEngine
private set
+ lateinit var blocker: BlockerEngine
+ private set
/**
* One bound matrix for app-driven frames.
@@ -76,14 +80,31 @@ class KadenceApplication : Application() {
configured = { prefs.current.isConfigured && prefs.current.onboarded },
)
+ // The focus guard, one per process like the timer — the accessibility service
+ // and the Blocker screen must be answering from the same verdict. `timerFacts`
+ // is a lambda rather than a subscription because `during_work` needs only the
+ // current kind and status, and the engine already holds both.
+ blocker = BlockerEngine(
+ context = this,
+ repo = repo,
+ scope = scope,
+ configured = { prefs.current.isConfigured && prefs.current.onboarded },
+ timerFacts = {
+ val ui = engine.state.value
+ if (ui.isActive) IntervalFacts(kind = ui.kind, status = ui.status) else null
+ },
+ )
+
// A 401 on anything but the login path means the session died mid-use. The
// engine will report Unauthorized on its next poll; this just makes it prompt.
api.unauthorized.subscribe {
Log.i(TAG, "backend rejected our credentials")
engine.refreshNow()
+ blocker.refreshNow()
}
engine.start()
+ blocker.start()
// Start or stop the foreground service purely as a function of whether an
// interval is running. `distinctUntilChanged` matters: without it every tick
diff --git a/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt b/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt
index 546d142..1de5413 100644
--- a/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt
+++ b/android/app/src/main/kotlin/tech/kadence/android/MainActivity.kt
@@ -30,10 +30,13 @@ class MainActivity : ComponentActivity() {
// Coming back to the app is the moment the state is most likely to be stale —
// the interval may have been paused on the desktop, or finished while the
- // phone dozed. Poll on every resume rather than trusting the last tick.
+ // phone dozed. Poll on every resume rather than trusting the last tick. The
+ // focus guard is refreshed too, since Android's Settings round-trip (usage
+ // access, accessibility) happens with this app in the background.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
app.engine.refreshNow()
+ app.blocker.refreshNow()
}
}
diff --git a/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerAccessibilityService.kt b/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerAccessibilityService.kt
new file mode 100644
index 0000000..3dfa6df
--- /dev/null
+++ b/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerAccessibilityService.kt
@@ -0,0 +1,196 @@
+/**
+ * Where websites are caught: the browser's URL bar, read through accessibility.
+ *
+ * Android has no hosts file and the manifest forbids taking the VPN slot, so the only
+ * place a blocked hostname is visible to a normal app is the text the browser itself
+ * renders into its address bar. This service watches window changes and hands anything
+ * hostname-shaped to [BlockerEngine], which owns the rules and the cooldowns.
+ *
+ * It is deliberately narrow, because a false positive ejects the user from a page they
+ * are allowed to be on:
+ *
+ * * only packages that are a browser are inspected — the known ones by name, plus
+ * whichever package currently holds the `ROLE_BROWSER` role;
+ * * only nodes that look like an address bar are read: an editable node, or one whose
+ * view id names a URL/omnibox/address field. A filtered traversal, not the whole
+ * window's text, so a note containing "youtube.com" is not a blocked site;
+ * * [`normalizeHost`] is the final arbiter, and it returns null for anything that is
+ * not confidently a host — a search phrase, a `chrome://` page, a placeholder.
+ *
+ * **What it cannot see:** a page inside an app's own WebView (Instagram, Reddit's app).
+ * Those have no URL bar belonging to a browser, so `domain` rules do not reach them —
+ * block the app instead. Said again in `README.md` so nobody files it as a bug.
+ *
+ * The same service is what makes `kill_blocked_apps` mean anything: [goHome] is the only
+ * way this app can move the user off another app, and it needs an accessibility binding.
+ */
+package tech.kadence.android.blocker
+
+import android.accessibilityservice.AccessibilityService
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityNodeInfo
+import tech.kadence.android.KadenceApplication
+
+/** Sentinel for "resolved and found nothing", so we do not resolve on every event. */
+private const val NONE = ""
+
+/** Browsers whose address bar this service knows how to find. */
+private val BROWSER_PACKAGES = setOf(
+ "com.android.chrome",
+ "com.chrome.beta",
+ "com.chrome.dev",
+ "org.mozilla.firefox",
+ "org.mozilla.firefox_beta",
+ "com.brave.browser",
+ "com.microsoft.emmx",
+ "com.opera.browser",
+ "com.opera.mini.native",
+ "com.duckduckgo.mobile.android",
+ "com.sec.android.app.sbrowser",
+ "com.vivaldi.browser",
+ "com.kiwibrowser.browser",
+ "com.yandex.browser",
+ "org.bromite.bromite",
+ "org.bromite.chromium",
+ "com.google.android.apps.chrome",
+)
+
+/** View ids that name an address bar, for the browsers that expose them. */
+private val URL_ID_HINTS = listOf("url", "omnibox", "address", "search_box_text")
+
+private const val MAX_NODES = 250
+private const val MAX_DEPTH = 25
+
+class BlockerAccessibilityService : AccessibilityService() {
+
+ private val engine: BlockerEngine? get() = (application as? KadenceApplication)?.blocker
+
+ /** Resolved once; [NONE] means "this phone has no default browser handler". */
+ private var defaultBrowser: String? = null
+
+ override fun onServiceConnected() {
+ super.onServiceConnected()
+ // So the guard can actually close what it blocks, and so the settings banner
+ // over on the Blocker screen clears without waiting for the next poll.
+ engine?.attachCloser(this)
+ engine?.refreshPermissions()
+ }
+
+ override fun onUnbind(intent: Intent?): Boolean {
+ engine?.detachCloser()
+ engine?.refreshPermissions()
+ return super.onUnbind(intent)
+ }
+
+ override fun onDestroy() {
+ engine?.detachCloser()
+ super.onDestroy()
+ }
+
+ override fun onInterrupt() = Unit
+
+ /** The one enforcement primitive Android gives us: move the user to the launcher. */
+ fun goHome() {
+ performGlobalAction(GLOBAL_ACTION_HOME)
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent?) {
+ event ?: return
+ val blocker = engine ?: return
+ val packageName = event.packageName?.toString() ?: return
+ if (packageName == this.packageName) return
+
+ when (event.eventType) {
+ AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
+ // The cheapest and most reliable foreground signal there is: better than
+ // usage stats, and available without the usage-access grant.
+ blocker.onForegroundApp(
+ packageName,
+ runCatching { SystemProbes.appLabel(this, packageName) }.getOrDefault(""),
+ )
+ if (isBrowser(packageName)) inspectWindow(blocker, packageName, event)
+ }
+
+ AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
+ if (isBrowser(packageName)) inspectWindow(blocker, packageName, event)
+ }
+ }
+ }
+
+ private fun inspectWindow(
+ blocker: BlockerEngine,
+ packageName: String,
+ event: AccessibilityEvent,
+ ) {
+ // The event's own text covers the bar-as-you-type case; the tree walk covers the
+ // bar a second later, once the page has loaded and focus moved to the content.
+ val candidates = mutableListOf()
+ if (event.text.isNotEmpty()) candidates += event.text.joinToString(" ")
+ candidates += urlCandidates(rootInActiveWindow)
+ for (candidate in candidates) {
+ if (candidate.isNotBlank()) blocker.onUrlSeen(candidate, packageName)
+ }
+ }
+
+ /**
+ * Whether this package is a browser worth reading a URL bar from.
+ *
+ * The default browser is resolved rather than held in a list: `ROLE_BROWSER`'s
+ * holder query is no longer in the public SDK, and "whatever this phone opens
+ * `http://` with" is the honest answer to "which app is the browser" — including
+ * every build this list above does not know about.
+ */
+ private fun isBrowser(packageName: String): Boolean {
+ if (packageName in BROWSER_PACKAGES) return true
+ if (defaultBrowser == null) {
+ defaultBrowser = runCatching {
+ val probe = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com"))
+ packageManager
+ .resolveActivity(
+ probe,
+ PackageManager.ResolveInfoFlags.of(
+ PackageManager.MATCH_DEFAULT_ONLY.toLong(),
+ ),
+ )
+ ?.activityInfo?.packageName
+ }.getOrNull() ?: NONE
+ }
+ return packageName == defaultBrowser
+ }
+
+ /**
+ * Address-bar text, found with a bounded breadth-first walk.
+ *
+ * Bounded because a browser's tree is enormous and this runs on every content
+ * change; the bar is always near the root, so [MAX_DEPTH] is generous rather than
+ * tight.
+ */
+ private fun urlCandidates(root: AccessibilityNodeInfo?): List {
+ root ?: return emptyList()
+ val found = mutableListOf()
+ val queue = ArrayDeque>()
+ queue.addLast(root to 0)
+ var visited = 0
+ while (queue.isNotEmpty() && visited < MAX_NODES) {
+ val (node, depth) = queue.removeFirst()
+ visited++
+ if (depth < MAX_DEPTH) {
+ for (index in 0 until node.childCount) {
+ node.getChild(index)?.let { queue.addLast(it to depth + 1) }
+ }
+ }
+ val text = node.text?.toString()?.trim()
+ if (text.isNullOrEmpty()) continue
+ if (node.isEditable || looksLikeAddressBar(node.viewIdResourceName)) found += text
+ }
+ return found
+ }
+
+ private fun looksLikeAddressBar(viewId: String?): Boolean {
+ val id = viewId?.lowercase() ?: return false
+ return URL_ID_HINTS.any { id.contains(it) }
+ }
+}
diff --git a/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt b/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt
new file mode 100644
index 0000000..806a9ae
--- /dev/null
+++ b/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt
@@ -0,0 +1,493 @@
+/**
+ * The focus guard, running for as long as the app process is.
+ *
+ * One of these lives in `KadenceApplication`, next to [tech.kadence.android.timer.TimerEngine],
+ * and it does three things:
+ *
+ * * **watches the policy.** Every few seconds it asks `GET /api/blocker/policy` and
+ * adopts the server's verdict. The server owns `active` and the reason, so the phone
+ * never argues with it.
+ * * **decides locally when the network is gone.** Losing signal must not switch the
+ * guard off, so the raw `blocker` subtree of the settings document is kept and
+ * re-evaluated with `:core`'s [evaluatePolicy] — the same function, and the same
+ * reason strings, that `backend/app/services/blocker_service.py` uses.
+ * * **enforces.** A blocked site is reported and the user is sent home; a blocked app
+ * is reported and, if `kill_blocked_apps` is on, the user is sent home as well.
+ *
+ * Three deliberate limits, all of them Android's rather than this file's:
+ *
+ * * **An app cannot be force-stopped.** No user-space API can end another app's
+ * process. `GLOBAL_ACTION_HOME` is the closest honest equivalent, so a "kill" here
+ * means "you are looking at the home screen now" — see [leaveForegroundAppFn].
+ * * **Websites are only seen through the browser's URL bar.** An in-app WebView's page
+ * never appears here; that is what `domain` blocks can and cannot catch.
+ * * **Enforcement needs the accessibility service; discovery needs usage access.**
+ * Without usage access nothing is ever seen; without accessibility nothing is ever
+ * closed, and blocking degrades to reporting plus a notification. The screen shows a
+ * banner for whichever half is missing rather than pretending.
+ *
+ * Events go to `POST /api/blocker/events` fire-and-forget. Holding a retry queue on disk
+ * is the desktop's job (`:core`'s outbox); a missed attempt here is a lost statistic,
+ * never a lost block.
+ */
+package tech.kadence.android.blocker
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import tech.kadence.android.data.KadenceRepository
+import tech.kadence.android.data.dto.BlockerPolicyDto
+import tech.kadence.android.data.dto.BlockerSettingsDto
+import tech.kadence.android.data.net.ApiResult
+import tech.kadence.android.notify.Notifications
+import tech.kadence.android.timer.Connection
+import tech.kadence.core.AppMatcher
+import tech.kadence.core.BlockAction
+import tech.kadence.core.BlockDecision
+import tech.kadence.core.BlockWindow
+import tech.kadence.core.BlockerRules
+import tech.kadence.core.DomainMatcher
+import tech.kadence.core.IntervalFacts
+import tech.kadence.core.evaluatePolicy
+import tech.kadence.core.expandDomains
+import tech.kadence.core.invalidExpansions
+import tech.kadence.core.unexpandableMasks
+import tech.kadence.core.zoneOrDefault
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.util.concurrent.ConcurrentHashMap
+
+private const val TAG = "Kadence"
+
+/** `BlockerSettingsDto.mode` as the web UI labels it: the default, and the common one. */
+private const val MODE_DURING_WORK = "during_work"
+
+/** How the guard answered at the last poll. The screen renders this and little else. */
+data class BlockerUi(
+ val connection: Connection = Connection.Unconfigured,
+ /** Whether the guard is enforcing *right now* — the server's answer, or ours offline. */
+ val active: Boolean = false,
+ /** The server's sentence: "a work interval is running", "inside a blocking window", … */
+ val reason: String = "",
+ val enabled: Boolean = false,
+ val mode: String = MODE_DURING_WORK,
+ val strict: Boolean = false,
+ val killApps: Boolean = true,
+ val notifyOnBlock: Boolean = true,
+ /** Hostnames in the expanded set — `all_domains`, so it is non-zero even when idle. */
+ val hostnames: Int = 0,
+ val blockedApps: Int = 0,
+ /** Masks that only this client can enforce, because it matches patterns not hosts. */
+ val unexpandable: List = emptyList(),
+ /** `subdomain_expansions` entries the server refused as "not a DNS label". */
+ val invalidExpansions: List = emptyList(),
+ val usageAccess: Boolean = false,
+ val accessibility: Boolean = false,
+ val notice: String? = null,
+) {
+ /** Everything needed to actually enforce is in place. */
+ val canEnforce: Boolean get() = usageAccess && accessibility
+}
+
+/** Something the guard did once, for the screen to refresh its counts on. */
+sealed interface BlockerEvent {
+ data class TurnedAway(val kind: String, val target: String, val action: String) : BlockerEvent
+}
+
+class BlockerEngine(
+ private val context: Context,
+ private val repo: KadenceRepository,
+ private val scope: CoroutineScope,
+ private val configured: () -> Boolean,
+ /** The running interval, if any — `during_work` cannot be decided without it. */
+ private val timerFacts: () -> IntervalFacts?,
+) {
+ private val _state = MutableStateFlow(BlockerUi())
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _events = MutableSharedFlow(extraBufferCapacity = 8)
+ val events: SharedFlow = _events.asSharedFlow()
+
+ /** The settings document's blocker subtree: what we enforce from when offline. */
+ @Volatile
+ private var rules: BlockerRules? = null
+
+ /** Read from the settings document; schedule windows are the *server's* clock. */
+ @Volatile
+ private var serverZone: ZoneId = ZoneId.systemDefault()
+
+ @Volatile
+ private var policy: BlockerPolicyDto? = null
+
+ /** Built from the server's expanded list when online, from the rules when not. */
+ @Volatile
+ private var onlineDomains: DomainMatcher? = null
+
+ @Volatile
+ private var onlineApps: AppMatcher? = null
+
+ @Volatile
+ private var offlineDomains = DomainMatcher(BlockerRules())
+
+ @Volatile
+ private var offlineApps = AppMatcher(emptyList())
+
+ /** Set by [BlockerAccessibilityService]; null until the user enables it. */
+ @Volatile
+ private var leaveForegroundAppFn: (() -> Unit)? = null
+
+ /**
+ * Targets we have already dealt with, 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.
+ */
+ private val recentlyBlocked = ConcurrentHashMap()
+
+ private var lastForeground: String? = null
+ private var lastBlockedApp: String? = null
+
+ private var pollJob: Job? = null
+ private var watchJob: Job? = null
+ private var appJob: Job? = null
+
+ // ---- lifecycle ----------------------------------------------------------
+
+ fun start() {
+ if (pollJob?.isActive != true) pollJob = scope.launch { pollLoop() }
+ if (appJob?.isActive != true) appJob = scope.launch { appLoop() }
+ if (watchJob?.isActive != true) watchJob = scope.launch { watchLoop() }
+ }
+
+ fun stop() {
+ pollJob?.cancel()
+ appJob?.cancel()
+ watchJob?.cancel()
+ pollJob = null
+ appJob = null
+ watchJob = null
+ }
+
+ /** On app foreground, and after the screen edits the policy. */
+ fun refreshNow() = scope.launch {
+ poll()
+ loadSettings()
+ refreshPermissions()
+ }
+
+ /** Called by the service when the user toggles accessibility on or off. */
+ fun refreshPermissions() {
+ val usage = SystemProbes.hasUsageAccess(context)
+ val accessibility = SystemProbes.hasAccessibility(
+ context,
+ BlockerAccessibilityService::class.java,
+ )
+ if (usage != _state.value.usageAccess || accessibility != _state.value.accessibility) {
+ _state.value = _state.value.copy(
+ usageAccess = usage,
+ accessibility = accessibility,
+ )
+ }
+ }
+
+ // ---- 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 detachCloser() {
+ leaveForegroundAppFn = null
+ }
+
+ /**
+ * A hostname-shaped string a browser's URL bar just showed.
+ *
+ * @param browserPackage what showed it, for the report's `details` column.
+ */
+ fun onUrlSeen(candidate: String?, browserPackage: String?) {
+ val ui = _state.value
+ if (!ui.active) return
+ val domains = matchers().first
+ val decision = domains.decide(candidate) as? BlockDecision.Block ?: return
+ if (!claim("domain:${decision.target}", DOMAIN_COOLDOWN_MS)) return
+
+ report("domain", decision.target, BlockAction.BLOCKED, browserPackage ?: "browser")
+ announce(
+ decision.target,
+ "Blocked by `${decision.matchedRule}`.",
+ )
+ leaveForegroundAppFn?.invoke()
+ }
+
+ /**
+ * The app now in front, from the accessibility service or the usage-stats poll.
+ *
+ * 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.
+ */
+ fun onForegroundApp(packageName: String, label: String = "") {
+ if (packageName == context.packageName) {
+ lastForeground = packageName
+ return
+ }
+ if (packageName != lastForeground) {
+ lastForeground = packageName
+ lastBlockedApp = null
+ }
+ val ui = _state.value
+ if (!ui.active) return
+
+ val decision = matchers().second.decide(packageName, label, ui.killApps)
+ if (decision !is BlockDecision.Block) return
+ if (packageName == lastBlockedApp) return
+ lastBlockedApp = packageName
+ if (!claim("app:${decision.target}", APP_COOLDOWN_MS)) return
+
+ report(
+ kind = "app",
+ target = decision.target,
+ action = decision.action,
+ details = if (label.isBlank()) packageName else "$label ($packageName)",
+ )
+ if (decision.action == BlockAction.KILLED) {
+ 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.")
+ }
+ }
+
+ // ---- polling ------------------------------------------------------------
+
+ private suspend fun pollLoop() {
+ while (scope.isActive) {
+ poll()
+ refreshPermissions()
+ // Enforcing is worth watching closely — a work interval can start on the
+ // desktop, and a scheduled window can open. Idle, nothing changes quickly.
+ delay(if (_state.value.active) ACTIVE_POLL_MS else IDLE_POLL_MS)
+ }
+ }
+
+ /**
+ * A second loop for the focus guard's own enforcement state.
+ *
+ * It exists so the screen can flip the "not watching" banners the moment the user
+ * comes back from Android's Settings — the poll loop alone would leave them up for
+ * up to [IDLE_POLL_MS].
+ */
+ private suspend fun watchLoop() {
+ while (scope.isActive) {
+ delay(WATCH_MS)
+ refreshPermissions()
+ }
+ }
+
+ private suspend fun appLoop() {
+ 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.
+ if (_state.value.active) {
+ val pkg = SystemProbes.foregroundPackage(context)
+ if (pkg != null) onForegroundApp(pkg, SystemProbes.appLabel(context, pkg))
+ }
+ delay(APP_POLL_MS)
+ }
+ }
+
+ /** Settings are read once on start and after every edit, not on every poll. */
+ private suspend fun loadSettings() {
+ if (!configured()) return
+ when (val result = repo.settings()) {
+ is ApiResult.Ok -> {
+ val blocker = result.value.settings.blocker
+ rules = blocker.toCore()
+ serverZone = zoneOrDefault(result.value.settings.timezone)
+ rebuildMatchers()
+ if (_state.value.connection != Connection.Online) {
+ applyOfflineVerdict(_state.value.connection)
+ }
+ }
+ is ApiResult.NetworkError -> Log.d(TAG, "settings unavailable offline")
+ else -> Unit
+ }
+ }
+
+ private suspend fun poll() {
+ if (!configured()) {
+ _state.value = _state.value.copy(connection = Connection.Unconfigured)
+ return
+ }
+ if (_state.value.connection == Connection.Unconfigured) {
+ _state.value = _state.value.copy(connection = Connection.Connecting)
+ }
+ when (val result = repo.blockerPolicy()) {
+ is ApiResult.Ok -> applyPolicy(result.value)
+ is ApiResult.Unauthorized -> applyOfflineVerdict(Connection.Unauthorized)
+ is ApiResult.NetworkError -> {
+ Log.d(TAG, "blocker poll offline: ${result.cause.message}")
+ applyOfflineVerdict(Connection.Offline)
+ }
+ is ApiResult.HttpError -> {
+ Log.w(TAG, "blocker poll failed: ${result.status} ${result.detail}")
+ applyOfflineVerdict(Connection.Online, notice = result.detail)
+ }
+ }
+ }
+
+ private fun applyPolicy(dto: BlockerPolicyDto) {
+ policy = dto
+ rebuildMatchers()
+ _state.value = _state.value.copy(
+ connection = Connection.Online,
+ active = dto.active,
+ reason = dto.reason,
+ enabled = dto.enabled,
+ mode = dto.mode,
+ strict = dto.strict,
+ killApps = dto.killApps,
+ notifyOnBlock = dto.notifyOnBlock,
+ hostnames = dto.allDomains.size,
+ blockedApps = dto.allApps.size,
+ unexpandable = dto.unexpandable,
+ invalidExpansions = dto.invalidExpansions,
+ notice = null,
+ )
+ }
+
+ /**
+ * Reach the answer ourselves, the same way the backend would.
+ *
+ * Only reached when the server did not answer, which is why it is content with the
+ * rules last read from the settings document. With nothing cached it says "disabled"
+ * rather than guessing — an empty block list blocks nothing either way, and the
+ * screen shows the offline banner.
+ */
+ private fun applyOfflineVerdict(connection: Connection, notice: String? = null) {
+ val current = rules
+ val verdict = evaluatePolicy(current ?: BlockerRules(), timerFacts(), ZonedDateTime.now(serverZone))
+ _state.value = _state.value.copy(
+ connection = connection,
+ active = verdict.active,
+ reason = verdict.reason,
+ enabled = current?.enabled ?: false,
+ mode = current?.mode ?: MODE_DURING_WORK,
+ strict = current?.strict ?: false,
+ killApps = current?.killBlockedApps ?: true,
+ notifyOnBlock = current?.notifyOnBlock ?: true,
+ hostnames = current?.let { expandDomains(it).size } ?: 0,
+ blockedApps = current?.blockedApps?.size ?: 0,
+ unexpandable = current?.let { unexpandableMasks(it) } ?: emptyList(),
+ invalidExpansions = current?.let { invalidExpansions(it) } ?: emptyList(),
+ notice = notice,
+ )
+ }
+
+ private fun matchers(): Pair = if (
+ _state.value.connection == Connection.Online && policy != null
+ ) {
+ (onlineDomains ?: offlineDomains) to (onlineApps ?: offlineApps)
+ } else {
+ offlineDomains to offlineApps
+ }
+
+ private fun rebuildMatchers() {
+ val current = rules ?: BlockerRules()
+ offlineDomains = DomainMatcher(current)
+ offlineApps = AppMatcher(current.blockedApps)
+
+ // Online, the server's `all_domains` is the expanded, allow-subtracted list —
+ // but the allow masks still have to be applied for pattern entries, and the raw
+ // entries are what step 3 of `decide()` matches patterns against.
+ policy?.let { dto ->
+ onlineDomains = DomainMatcher(
+ rules = current.copy(blockedDomains = dto.allDomains),
+ expandedHosts = dto.allDomains,
+ )
+ onlineApps = AppMatcher(dto.allApps)
+ }
+ }
+
+ // ---- reporting and announcing -------------------------------------------
+
+ private fun report(kind: String, target: String, action: String, details: String?) {
+ scope.launch {
+ when (val result = repo.recordBlockEvent(kind, target, action, details)) {
+ is ApiResult.Ok -> _events.tryEmit(BlockerEvent.TurnedAway(kind, target, action))
+ else -> Log.d(TAG, "block event not recorded: $result")
+ }
+ }
+ }
+
+ private fun announce(target: String, body: String) {
+ if (!_state.value.notifyOnBlock) return
+ Notifications.showBlocked(context, target, body)
+ }
+
+ /**
+ * Take a per-target cooldown slot, or refuse if one is still running.
+ *
+ * `ConcurrentHashMap` rather than a lock because this is called from the
+ * accessibility service's main-thread callback and from the app-poll coroutine at
+ * once, and the only shared fact is a timestamp.
+ */
+ private fun claim(target: String, windowMs: Long): Boolean {
+ val now = System.currentTimeMillis()
+ 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
+ }
+ }
+ }
+
+ private companion object {
+ const val ACTIVE_POLL_MS = 15_000L
+ const val IDLE_POLL_MS = 45_000L
+ const val WATCH_MS = 2_000L
+
+ /** 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. */
+ const val DOMAIN_COOLDOWN_MS = 20_000L
+ const val APP_COOLDOWN_MS = 20_000L
+ }
+}
+
+/**
+ * The settings document's blocker subtree, reduced to what `:core` reasons about.
+ *
+ * Top-level rather than a private method so `BlockerSettingsDtoTest` can assert the
+ * field names line up with the wire keys — a wrong key here is invisible until the
+ * phone goes offline and enforces nothing.
+ */
+fun BlockerSettingsDto.toCore(): BlockerRules = BlockerRules(
+ enabled = enabled,
+ mode = mode,
+ blockDuringBreaks = blockDuringBreaks,
+ blockedDomains = blockedDomains,
+ allowedDomains = allowedDomains,
+ subdomainExpansions = subdomainExpansions,
+ blockedApps = blockedApps,
+ killBlockedApps = killBlockedApps,
+ schedule = schedule.map { BlockWindow(it.label, it.start, it.end, it.days) },
+ strict = strict,
+ notifyOnBlock = notifyOnBlock,
+)
diff --git a/android/app/src/main/kotlin/tech/kadence/android/blocker/SystemProbes.kt b/android/app/src/main/kotlin/tech/kadence/android/blocker/SystemProbes.kt
new file mode 100644
index 0000000..f1982f8
--- /dev/null
+++ b/android/app/src/main/kotlin/tech/kadence/android/blocker/SystemProbes.kt
@@ -0,0 +1,120 @@
+/**
+ * The two Android APIs the focus guard leans on, behind small functions.
+ *
+ * Both are permission-gated in ways that fail *quietly*:
+ *
+ * * **Usage access** (`android.permission.PACKAGE_USAGE_STATS`) is an AppOps grant, not
+ * a runtime permission, so there is no `requestPermissions` for it — the user has to
+ * be sent to Settings. Without it `queryEvents` returns an empty cursor, not an
+ * error, and the guard would look like it works while never seeing anything.
+ * * **Accessibility** is a `Settings.Secure` string the user toggles by hand. The
+ * service stays unbound until then, with no callback to observe, so the only way to
+ * know is to read that string back.
+ *
+ * Keeping these reads in one file means the engine can ask plain questions and the UI
+ * can show a banner whenever the answer is "not yet".
+ */
+package tech.kadence.android.blocker
+
+import android.app.AppOpsManager
+import android.app.usage.UsageEvents
+import android.app.usage.UsageStatsManager
+import android.content.ComponentName
+import android.content.Context
+import android.os.Process
+import android.provider.Settings
+import android.util.Log
+
+private const val TAG = "Kadence"
+
+/** How far back a foreground search looks before falling back to the daily rollup. */
+private const val EVENT_WINDOW_MS = 10 * 60 * 1000L
+
+object SystemProbes {
+
+ /** Whether the user has granted "Usage access", which is what polling depends on. */
+ fun hasUsageAccess(context: Context): Boolean {
+ val appOps = context.getSystemService(AppOpsManager::class.java) ?: return false
+ val mode = appOps.unsafeCheckOpNoThrow(
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
+ Process.myUid(),
+ context.packageName,
+ )
+ return mode == AppOpsManager.MODE_ALLOWED
+ }
+
+ /**
+ * Whether the given accessibility service is listed in the enabled-services string.
+ *
+ * `Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES` is a `:`-separated list of
+ * flattened `ComponentName`s; that string, rather than any binding state, is the
+ * only component the system keeps truthful.
+ */
+ fun hasAccessibility(context: Context, service: Class<*>): Boolean {
+ val enabled = Settings.Secure.getString(
+ context.contentResolver,
+ Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
+ ).orEmpty()
+ if (enabled.isEmpty()) return false
+ val wanted = ComponentName(context, service)
+ return enabled.split(':').any { entry ->
+ ComponentName.unflattenFromString(entry)?.let { it == wanted } ?: false
+ }
+ }
+
+ /**
+ * The package most recently brought to the foreground, or null if we cannot tell.
+ *
+ * Two sources, because neither is complete on its own. `queryEvents` is precise but
+ * only sees *transitions*: an app that has been open for an hour has no
+ * `ACTIVITY_RESUMED` inside the window and would read as "nothing foregrounded".
+ * `queryUsageStats` sees that steady state, but only in coarse buckets, so it is the
+ * fallback and the event stream is preferred. `INTERVAL_BEST` keeps the bucket
+ * selection the platform's business.
+ */
+ fun foregroundPackage(context: Context): String? {
+ if (!hasUsageAccess(context)) return null
+ val usm = context.getSystemService(UsageStatsManager::class.java) ?: return null
+ val end = System.currentTimeMillis()
+
+ val events = runCatching { usm.queryEvents(end - EVENT_WINDOW_MS, end) }.getOrNull()
+ if (events != null) {
+ val event = UsageEvents.Event()
+ var latest: String? = null
+ while (events.hasNextEvent()) {
+ events.getNextEvent(event)
+ if (event.eventType == UsageEvents.Event.ACTIVITY_RESUMED) {
+ latest = event.packageName
+ }
+ }
+ if (latest != null) return latest
+ }
+
+ // Nothing resumed lately, so the app on screen has been there a while. The
+ // stats rollup keeps a `lastTimeUsed` per bucket; the largest one is the app
+ // still in front. This also catches the case where the above call returned null
+ // because the platform withheld the cursor.
+ return runCatching {
+ usm.queryUsageStats(UsageStatsManager.INTERVAL_BEST, end - DAY_MS, end)
+ ?.maxByOrNull { it.lastTimeUsed }
+ ?.packageName
+ }.getOrNull()
+ }
+
+ /** The app's visible label, for the notification. Falls back to the package name. */
+ fun appLabel(context: Context, packageName: String): String = runCatching {
+ val info = context.packageManager.getApplicationInfo(packageName, 0)
+ context.packageManager.getApplicationLabel(info).toString()
+ }.getOrElse {
+ Log.d(TAG, "no label for $packageName: ${it.message}")
+ packageName
+ }
+
+ /** Whether the package can be resolved at all — an uninstalled entry, say. */
+ fun installed(context: Context, packageName: String): Boolean = runCatching {
+ context.packageManager.getApplicationInfo(packageName, 0)
+ true
+ }.getOrElse { false }
+
+ private const val DAY_MS = 24 * 60 * 60 * 1000L
+}
diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt b/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt
index 52142fc..48e096b 100644
--- a/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt
+++ b/android/app/src/main/kotlin/tech/kadence/android/data/KadenceRepository.kt
@@ -8,7 +8,7 @@
* * `routes_timer.py` → `/api/timer/state`, `start`, `pause`, `complete`, …
* * `routes_tasks.py` → `/api/tasks`, `/api/tasks/refresh`
* * `routes_reports.py` → `/api/reports/daily`, `/api/stats/summary`
- * * `routes_blocker.py` → `/api/blocker/policy`, `/api/blocker/events`
+ * * `routes_blocker.py` → `/api/blocker/policy`, `events`, `defaults`
* * `routes_auth.py` → `/api/auth/me`, `login`; mounted outside `require_auth`
*
* Note what is *not* here: there is no `/api/timer/stop`. Stopping is `abandon`, which
@@ -19,6 +19,7 @@ package tech.kadence.android.data
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
import tech.kadence.android.data.dto.AuthMeDto
+import tech.kadence.android.data.dto.BlockDefaultsDto
import tech.kadence.android.data.dto.BlockEventsDto
import tech.kadence.android.data.dto.BlockerPolicyDto
import tech.kadence.android.data.dto.DailyReportEnvelopeDto
@@ -134,6 +135,19 @@ class KadenceRepository(val api: KadenceApi) {
suspend fun blockEvents(): ApiResult = api.get("/api/blocker/events")
+ /** Starter lists the web app seeds a fresh install with. */
+ suspend fun blockerDefaults(): ApiResult = api.get("/api/blocker/defaults")
+
+ /**
+ * Patch the blocker block of the settings document — same partial-PUT contract
+ * (and the same clobber warning) as [patchPomodoro].
+ */
+ suspend fun patchBlocker(fields: Map): ApiResult {
+ val inner = JsonObject(fields.mapValues { (_, v) -> jsonPrimitiveOf(v) })
+ val payload = JsonObject(mapOf("blocker" to inner))
+ return api.put("/api/settings", KadenceJson.encodeToString(payload))
+ }
+
/** Report an attempt the phone turned away, so it lands in the evening report. */
suspend fun recordBlockEvent(
kind: String,
@@ -159,11 +173,23 @@ class KadenceRepository(val api: KadenceApi) {
* `ge=1`, so sending `null` is fine but sending it *implicitly* by omission is what
* actually means "use the configured length".
*/
-private fun jsonPrimitiveOf(value: Any): kotlinx.serialization.json.JsonPrimitive = when (value) {
+private fun jsonPrimitiveOf(value: Any): kotlinx.serialization.json.JsonElement = when (value) {
is String -> kotlinx.serialization.json.JsonPrimitive(value)
is Int -> kotlinx.serialization.json.JsonPrimitive(value)
is Long -> kotlinx.serialization.json.JsonPrimitive(value)
is Boolean -> kotlinx.serialization.json.JsonPrimitive(value)
+ // `JsonPrimitive(value.toString())` would have turned a list into the *string*
+ // "[a, b]" — one silently wrong save rather than an error, which is exactly the
+ // kind of thing that is only found by reading the document it wrote.
+ is List<*> -> kotlinx.serialization.json.JsonArray(
+ value.map { item ->
+ when (item) {
+ is Number -> kotlinx.serialization.json.JsonPrimitive(item)
+ is Boolean -> kotlinx.serialization.json.JsonPrimitive(item)
+ else -> kotlinx.serialization.json.JsonPrimitive(item.toString())
+ }
+ },
+ )
else -> kotlinx.serialization.json.JsonPrimitive(value.toString())
}
diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt
index 586b0ed..25ae35c 100644
--- a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt
+++ b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt
@@ -126,6 +126,8 @@ data class BlockerPolicyDto(
/** Likewise empty when inactive. */
val apps: List = emptyList(),
val unexpandable: List = emptyList(),
+ /** Subdomain expansions the server refused — a bare `*`, say, which is not a label. */
+ @SerialName("invalid_expansions") val invalidExpansions: List = emptyList(),
@SerialName("kill_apps") val killApps: Boolean = true,
@SerialName("notify_on_block") val notifyOnBlock: Boolean = true,
@SerialName("poll_seconds") val pollSeconds: Int = 5,
@@ -340,9 +342,46 @@ data class PomodoroSettingsDto(
val presets: List = emptyList(),
)
+/** One `blocker.schedule[]` entry. `days` is ISO weekdays, Monday = 0. */
+@Serializable
+data class BlockWindowDto(
+ val label: String = "",
+ val start: String = "00:00",
+ val end: String = "23:59",
+ val days: List = emptyList(),
+)
+
+/**
+ * The `blocker` subtree of the settings document — the *rules*, as typed by the user.
+ * Distinct from [BlockerPolicyDto], which is the server's verdict about those rules.
+ */
+@Serializable
+data class BlockerSettingsDto(
+ val enabled: Boolean = false,
+ val mode: String = "during_work",
+ @SerialName("block_during_breaks") val blockDuringBreaks: Boolean = false,
+ @SerialName("blocked_domains") val blockedDomains: List = emptyList(),
+ @SerialName("allowed_domains") val allowedDomains: List = emptyList(),
+ @SerialName("subdomain_expansions")
+ val subdomainExpansions: List = tech.kadence.core.BlockerRules.DEFAULT_SUBDOMAINS,
+ @SerialName("blocked_apps") val blockedApps: List = emptyList(),
+ @SerialName("kill_blocked_apps") val killBlockedApps: Boolean = true,
+ val schedule: List = emptyList(),
+ val strict: Boolean = false,
+ @SerialName("notify_on_block") val notifyOnBlock: Boolean = true,
+)
+
+/** `GET /api/blocker/defaults` — starter lists, so the editor is not an empty box. */
+@Serializable
+data class BlockDefaultsDto(
+ val domains: List = emptyList(),
+ val apps: List = emptyList(),
+)
+
@Serializable
data class AppSettingsDto(
val pomodoro: PomodoroSettingsDto = PomodoroSettingsDto(),
+ val blocker: BlockerSettingsDto = BlockerSettingsDto(),
val timezone: String = "",
)
diff --git a/android/app/src/main/kotlin/tech/kadence/android/notify/Notifications.kt b/android/app/src/main/kotlin/tech/kadence/android/notify/Notifications.kt
index fe60d0a..c47c912 100644
--- a/android/app/src/main/kotlin/tech/kadence/android/notify/Notifications.kt
+++ b/android/app/src/main/kotlin/tech/kadence/android/notify/Notifications.kt
@@ -1,10 +1,11 @@
/**
* The notifications, including the Android 16 Live Update.
*
- * Two channels, because they want opposite things. The running interval must be silent
- * and unswipeable; the interval *ending* must make a noise and be dismissible. One
- * channel cannot be both, and a user who silences the alarm to stop the countdown
- * buzzing has silenced the only part that mattered.
+ * Two channels are about the timer, and they want opposite things. The running interval
+ * must be silent and unswipeable; the interval *ending* must make a noise and be
+ * dismissible. One channel cannot be both, and a user who silences the alarm to stop the
+ * countdown buzzing has silenced the only part that mattered. A third, for the focus
+ * guard, is silent-but-visible — see [showBlocked].
*
* **The Live Update.** On Android 16 an ongoing notification can be *promoted*: it
* gets a chip in the status bar and a rich row on the lock screen, which is exactly
@@ -50,11 +51,19 @@ import tech.kadence.core.dialTickCount
object Notifications {
const val CHANNEL_TIMER = "timer"
const val CHANNEL_ALARM = "alarm"
+ const val CHANNEL_BLOCKER = "blocker"
/** The foreground service's notification. Stable, because it is replaced 1 Hz. */
const val ID_TIMER = 1001
const val ID_ALARM = 1002
+ /**
+ * Not a constant for a reason: a block notification is posted *tagged* with its
+ * target, so blocking the same site again replaces the earlier line instead of
+ * stacking a second one under it.
+ */
+ const val ID_BLOCKED = 1003
+
fun ensureChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
@@ -90,6 +99,25 @@ object Notifications {
manager.createNotificationChannel(timer)
manager.createNotificationChannel(alarm)
+ manager.createNotificationChannel(blocker(context))
+ }
+
+ /**
+ * The focus guard's channel.
+ *
+ * `IMPORTANCE_DEFAULT` rather than `LOW`, because these are *events* — the user
+ * should see them in the shade when they next look. Silent and buzz-less, because
+ * the interruption has already happened: the guard is what took them off the site.
+ */
+ private fun blocker(context: Context) = NotificationChannel(
+ CHANNEL_BLOCKER,
+ context.getString(R.string.channel_blocker_name),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply {
+ description = context.getString(R.string.channel_blocker_description)
+ setShowBadge(false)
+ setSound(null, null)
+ enableVibration(false)
}
/**
@@ -245,6 +273,33 @@ object Notifications {
NotificationManagerCompat.from(context).cancel(ID_ALARM)
}
+ /**
+ * A line in the shade saying what the focus guard just turned away.
+ *
+ * Tagged with the target so a second attempt on the same site replaces the first
+ * line rather than stacking; and swallowed on failure, because a refused
+ * notification must never cost the block itself — the guard has already acted by
+ * the time this is called.
+ */
+ fun showBlocked(context: Context, target: String, body: String) {
+ if (!permitted(context)) return
+ val notification = NotificationCompat.Builder(context, CHANNEL_BLOCKER)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle("Blocked: $target")
+ .setContentText(body)
+ .setContentIntent(openApp(context))
+ .setAutoCancel(true)
+ .setCategory(NotificationCompat.CATEGORY_STATUS)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .build()
+ runCatching {
+ NotificationManagerCompat.from(context).notify(target, ID_BLOCKED, notification)
+ }.onFailure {
+ android.util.Log.d("Kadence", "block notification refused: ${it.message}")
+ }
+ }
+
/** Whether the user has actually granted the runtime permission. */
fun permitted(context: Context): Boolean =
NotificationManagerCompat.from(context).areNotificationsEnabled()
diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt
index 03491cb..4be6f1f 100644
--- a/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt
+++ b/android/app/src/main/kotlin/tech/kadence/android/ui/KadenceApp.kt
@@ -1,7 +1,7 @@
/**
- * The shell: four destinations and the bar that switches them.
+ * The shell: five destinations and the bar that switches them.
*
- * Onboarding is handled by swapping the whole shell out rather than as a fifth
+ * Onboarding is handled by swapping the whole shell out rather than as a sixth
* destination, because until a backend answers there is nothing for the other three to
* show — a bottom bar over four empty screens is worse than one screen that explains
* what is missing.
@@ -39,6 +39,7 @@ import kotlinx.coroutines.launch
import tech.kadence.android.KadenceApplication
import tech.kadence.android.data.dto.AppSettingsDto
import tech.kadence.android.data.net.ApiResult
+import tech.kadence.android.ui.blocker.BlockerScreen
import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.onboarding.OnboardingScreen
import tech.kadence.android.ui.report.ReportScreen
@@ -50,6 +51,7 @@ import tech.kadence.android.ui.timer.TimerScreen
private enum class Destination(val label: String) {
Timer("Timer"),
Tasks("Tasks"),
+ Blocker("Guard"),
Report("Today"),
Settings("Settings"),
}
@@ -98,6 +100,7 @@ fun KadenceApp(app: KadenceApplication) {
// The engine was refusing to poll while unconfigured; now that
// it is, ask straight away rather than waiting out the interval.
app.engine.refreshNow()
+ app.blocker.refreshNow()
},
)
}
@@ -148,6 +151,11 @@ fun KadenceApp(app: KadenceApplication) {
},
)
+ Destination.Blocker -> BlockerScreen(
+ repo = app.repo,
+ engine = app.blocker,
+ )
+
Destination.Report -> ReportScreen(repo = app.repo)
Destination.Settings -> SettingsScreen(
diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt
new file mode 100644
index 0000000..0af805c
--- /dev/null
+++ b/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt
@@ -0,0 +1,506 @@
+/**
+ * The focus guard, as an editor: what is blocked, when it applies, and what it caught.
+ *
+ * This is the one screen that edits a *server* setting from the phone — everything in
+ * `SettingsScreen` is local, and the block lists live in the shared settings document
+ * like they do everywhere else. That trade is deliberate: the desktop app enforces
+ * these rules and the web UI is still the canonical place to shape them, but a rule you
+ * can only edit from a desktop is a rule you will not change before work starts, and
+ * this feature exists for exactly that moment.
+ *
+ * Everything the screen *judges* — active or not, which hostnames, what to report —
+ * comes from [BlockerEngine]; the screen never decides a policy itself. What it does
+ * own is the two grants Android refuses to give silently: usage access (the engine can
+ * then see the foreground app) and accessibility (it can then close it). Both are
+ * bannered here with the exact Settings page to open, because both fail without an
+ * error anywhere else.
+ */
+package tech.kadence.android.ui.blocker
+
+import android.content.Context
+import android.content.Intent
+import android.provider.Settings as AndroidSettings
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
+import tech.kadence.android.blocker.BlockerEngine
+import tech.kadence.android.data.KadenceRepository
+import tech.kadence.android.data.net.ApiResult
+import tech.kadence.android.timer.Connection
+import tech.kadence.android.ui.Loadable
+import tech.kadence.android.ui.components.Banner
+import tech.kadence.android.ui.components.Eyebrow
+import tech.kadence.android.ui.components.GhostButton
+import tech.kadence.android.ui.components.HorizontalRule
+import tech.kadence.android.ui.components.PageHeader
+import tech.kadence.android.ui.components.Panel
+import tech.kadence.android.ui.components.PresetChip
+import tech.kadence.android.ui.components.PrimaryButton
+import tech.kadence.android.ui.components.SectionTitle
+import tech.kadence.android.ui.components.Stat
+import tech.kadence.android.ui.components.StatSize
+import tech.kadence.android.ui.components.ToggleRow
+import tech.kadence.android.ui.rememberLoad
+import tech.kadence.android.ui.theme.KadenceTheme
+
+/** The mode words, as the web app's `BLOCK_MODE_LABEL` spells them. */
+private val MODE_LABELS = mapOf(
+ "during_work" to "While a focus interval runs",
+ "always" to "Always",
+ "scheduled" to "On a schedule",
+)
+
+private val MASK_HELP = listOf(
+ "youtube.com" to "the site itself, plus www. and m.",
+ "*.google.com" to "the bare domain plus the list below",
+ "*gram*" to "a pattern — the URL matcher takes it, not the hosts file",
+ "* in the list below" to "makes *.site cover every subdomain, not just the listed ones",
+)
+
+@Composable
+fun BlockerScreen(
+ repo: KadenceRepository,
+ engine: BlockerEngine,
+) {
+ val colors = KadenceTheme.colors
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ val ui by engine.state.collectAsStateWithLifecycle()
+
+ val settings = rememberLoad("blocker-settings") { repo.settings() }
+ val defaults = rememberLoad("blocker-defaults") { repo.blockerDefaults() }
+ // Reloaded whenever the engine reports a block, which is the only honest signal
+ // that the counts moved — polling these would mostly see an unchanged day.
+ var eventsVersion by remember { mutableStateOf(0) }
+ val events = rememberLoad("blocker-events", eventsVersion) { repo.blockEvents() }
+ LaunchedEffect(engine) {
+ engine.events.collect { eventsVersion++ }
+ }
+
+ val document = (settings.value as? Loadable.Loaded)?.value?.settings
+ val blocker = document?.blocker
+
+ var error by remember { mutableStateOf(null) }
+ var dirty by remember { mutableStateOf(false) }
+ var domains by remember { mutableStateOf("") }
+ var allowed by remember { mutableStateOf("") }
+ var apps by remember { mutableStateOf("") }
+ var subdomains by remember { mutableStateOf("") }
+
+ // Seed the editors from the document — but never while the user is mid-edit,
+ // or a background reload would silently discard what they typed.
+ LaunchedEffect(blocker, dirty) {
+ if (blocker != null && !dirty) {
+ domains = blocker.blockedDomains.joinToString("\n")
+ allowed = blocker.allowedDomains.joinToString("\n")
+ apps = blocker.blockedApps.joinToString("\n")
+ subdomains = blocker.subdomainExpansions.joinToString(", ")
+ }
+ }
+
+ fun patch(fields: Map, onSaved: () -> Unit = {}) {
+ scope.launch {
+ when (val result = repo.patchBlocker(fields)) {
+ is ApiResult.Ok -> {
+ error = null
+ settings.reload()
+ // The verdict comes from the server's policy, so the screen is
+ // only telling the truth again after the engine re-asks.
+ engine.refreshNow()
+ onSaved()
+ }
+ else -> error = detailOf(result)
+ }
+ }
+ }
+
+ fun saveLists() {
+ patch(
+ mapOf(
+ "blocked_domains" to lines(domains),
+ "allowed_domains" to lines(allowed),
+ "blocked_apps" to lines(apps),
+ "subdomain_expansions" to commaList(subdomains),
+ ),
+ // Only on success. Clearing it first would let the seeding effect
+ // overwrite the editor with the still-unchanged document.
+ onSaved = { dirty = false },
+ )
+ }
+
+ Column(
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(18.dp),
+ ) {
+ PageHeader(
+ eyebrow = ui.reason.ifBlank { "—" },
+ title = if (ui.active) "Blocking is on" else "Blocking is off",
+ caption = "The rules are shared with the desktop app and the web UI. " +
+ "This phone enforces them while it is awake.",
+ )
+
+ error?.let { Banner(it) }
+ if (settings.value is Loadable.Failed) {
+ Banner(
+ text = (settings.value as Loadable.Failed).detail,
+ actionLabel = "Try again",
+ onAction = { settings.reload() },
+ )
+ }
+
+ // The two grants. Both are Settings screens Android will not put up a
+ // dialog for, so "which app is open" and "close it" fail invisibly
+ // without this.
+ if (!ui.usageAccess) {
+ Banner(
+ text = "No usage access, so this phone cannot see which app is " +
+ "open — blocked apps are reported from the last one it saw, " +
+ "and then stop being noticed.",
+ actionLabel = "Grant",
+ onAction = { openUsageAccessSettings(context) },
+ )
+ }
+ if (!ui.accessibility) {
+ Banner(
+ text = "Accessibility is off, so browser URLs are not being read " +
+ "and blocked apps cannot be closed. Websites and apps are still " +
+ "reported when the engine notices them, but nothing is enforced.",
+ actionLabel = "Enable",
+ onAction = { openAccessibilitySettings(context) },
+ )
+ }
+ if (ui.connection == Connection.Offline) {
+ Banner(
+ text = "No connection — enforcing from the rules as last saved. " +
+ "Edits made elsewhere will not reach this phone until it is back.",
+ )
+ }
+ if (ui.connection == Connection.Unauthorized) {
+ Banner("Not signed in — the guard is running on the last rules it had.")
+ }
+ ui.notice?.let { Banner(it) }
+
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Stat(
+ label = "Hostnames",
+ value = ui.hostnames.toString(),
+ caption = "after expansion",
+ modifier = Modifier.weight(1f),
+ size = StatSize.Small,
+ )
+ Stat(
+ label = "Apps",
+ value = ui.blockedApps.toString(),
+ caption = if (ui.killApps) "closed on sight" else "warned only",
+ modifier = Modifier.weight(1f),
+ size = StatSize.Small,
+ )
+ Stat(
+ label = "Attempts today",
+ value = ((events.value as? Loadable.Loaded)?.value?.total ?: 0).toString(),
+ caption = "turned away",
+ modifier = Modifier.weight(1f),
+ size = StatSize.Small,
+ )
+ }
+
+ SectionTitle("Enabled")
+ 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 " +
+ "stop enforcing."
+ },
+ checked = ui.enabled,
+ enabled = ui.connection != Connection.Unconfigured,
+ onChange = { value -> patch(mapOf("enabled" to value)) },
+ )
+ }
+
+ SectionTitle("When")
+ Panel(padding = 14.dp) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ MODE_LABELS.forEach { (value, label) ->
+ PresetChip(
+ text = label,
+ selected = ui.mode == value,
+ onClick = { if (ui.mode != value) patch(mapOf("mode" to value)) },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ HorizontalRule()
+ ToggleRow(
+ label = "Keep blocking through breaks",
+ detail = "Otherwise a break is a free window into anything on the list.",
+ checked = (blocker?.blockDuringBreaks ?: false) && ui.mode == "during_work",
+ enabled = ui.mode == "during_work",
+ onChange = { value -> patch(mapOf("block_during_breaks" to value)) },
+ )
+ HorizontalRule()
+ ToggleRow(
+ label = "Close blocked apps",
+ detail = "Off means the app stays open and Kadence only tells you " +
+ "it is on the list. Closing is as close as Android lets a " +
+ "phone app get to killing another one.",
+ checked = ui.killApps,
+ onChange = { value -> patch(mapOf("kill_blocked_apps" to value)) },
+ )
+ }
+ }
+
+ SectionTitle("Masks")
+ Panel(padding = 0.dp) {
+ MASK_HELP.forEachIndexed { index, (pattern, meaning) ->
+ if (index > 0) HorizontalRule()
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(
+ text = pattern,
+ style = KadenceTheme.type.mono,
+ color = colors.ink,
+ )
+ Text(
+ text = meaning,
+ style = KadenceTheme.type.caption,
+ color = colors.inkMuted,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+
+ if (ui.invalidExpansions.isNotEmpty()) {
+ val many = ui.invalidExpansions.size > 1
+ Banner(
+ text = "${ui.invalidExpansions.joinToString(", ")} " +
+ (if (many) "are not subdomain labels" else "is not a subdomain label") +
+ ", so " + (if (many) "they were" else "it was") + " ignored. " +
+ "Each entry is one label — web, m, mobile. A bare `*` is kept " +
+ "and means every subdomain.",
+ )
+ }
+ if (ui.unexpandable.isNotEmpty()) {
+ val many = ui.unexpandable.size > 1
+ Banner(
+ text = "${ui.unexpandable.joinToString(", ")} " +
+ (if (many) "are patterns" else "is a pattern") +
+ " a hosts file cannot express — " +
+ (if (many) "they are" else "it is") +
+ " matched here against every URL you open. A `*.site` pattern " +
+ "is also answered by the desktop app's resolver; a mask like " +
+ "`*gram*` is enforced on this phone only.",
+ )
+ }
+
+ ListEditor(
+ title = "Blocked websites",
+ value = domains,
+ placeholder = "youtube.com\n*.reddit.com\ntwitter.com",
+ onChange = { domains = it; dirty = true },
+ )
+ GhostButton(
+ text = "Add the usual suspects",
+ onClick = {
+ val current = linkedSetOf()
+ current.addAll(lines(domains))
+ current.addAll((defaults.value as? Loadable.Loaded)?.value?.domains.orEmpty())
+ domains = current.joinToString("\n")
+ dirty = true
+ },
+ )
+
+ ListEditor(
+ title = "Exceptions",
+ value = allowed,
+ placeholder = "music.youtube.com\n*.docs.google.com",
+ onChange = { allowed = it; dirty = true },
+ )
+ Text(
+ text = "Masks allowed here too — an exception wins over a block.",
+ style = KadenceTheme.type.caption,
+ color = colors.inkMuted,
+ modifier = Modifier.padding(top = 6.dp),
+ )
+
+ ListEditor(
+ title = "Blocked applications",
+ value = apps,
+ placeholder = "steam*\n*discord*\ninstagram",
+ onChange = { apps = it; dirty = true },
+ )
+ Text(
+ text = "Matched against the package name and the app's visible name, " +
+ "the way the desktop app matches a process name.",
+ style = KadenceTheme.type.caption,
+ color = colors.inkMuted,
+ modifier = Modifier.padding(top = 6.dp),
+ )
+
+ SectionTitle("Subdomains that *.site expands to")
+ OutlinedTextField(
+ value = subdomains,
+ onValueChange = { subdomains = it; dirty = true },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ isError = ui.invalidExpansions.isNotEmpty(),
+ textStyle = KadenceTheme.type.mono,
+ )
+ Text(
+ text = "Comma separated: web, m, mobile. A bare `*` here means every " +
+ "subdomain — then `*.site` covers even the ones you did not list.",
+ style = KadenceTheme.type.caption,
+ color = if (ui.invalidExpansions.isNotEmpty()) colors.clay else colors.inkMuted,
+ modifier = Modifier.padding(top = 6.dp),
+ )
+
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ PrimaryButton(text = "Save lists", enabled = dirty, onClick = ::saveLists)
+ GhostButton(
+ text = "Discard",
+ enabled = dirty,
+ onClick = { dirty = false }, // the seeding effect re-reads the document
+ )
+ }
+
+ Spacer(Modifier.height(4.dp))
+ SectionTitle("Today's temptations")
+ val today = (events.value as? Loadable.Loaded)?.value
+ if (today == null) {
+ Text(
+ text = when (events.value) {
+ is Loadable.Failed -> "Could not load today's attempts."
+ else -> "…"
+ },
+ style = KadenceTheme.type.bodySmall,
+ color = colors.inkMuted,
+ )
+ } else if (today.byTarget.isEmpty()) {
+ Text(
+ text = "Nothing yet — either the guard is off, or you are behaving.",
+ style = KadenceTheme.type.bodySmall,
+ color = colors.inkMuted,
+ )
+ } else {
+ Panel(padding = 0.dp) {
+ today.byTarget.take(8).forEachIndexed { index, entry ->
+ if (index > 0) HorizontalRule()
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = entry.target,
+ style = KadenceTheme.type.mono,
+ color = colors.ink,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = entry.count.toString(),
+ style = KadenceTheme.type.caption,
+ color = colors.inkMuted,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/** One list box with its eyebrow, matching the web editor's three-column layout. */
+@Composable
+private fun ListEditor(
+ title: String,
+ value: String,
+ placeholder: String,
+ onChange: (String) -> Unit,
+) {
+ val colors = KadenceTheme.colors
+ Column(Modifier.fillMaxWidth()) {
+ Eyebrow(title)
+ OutlinedTextField(
+ value = value,
+ onValueChange = onChange,
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ minLines = 7,
+ maxLines = 14,
+ placeholder = { Text(placeholder, style = KadenceTheme.type.mono, color = colors.inkFaint) },
+ textStyle = KadenceTheme.type.mono,
+ )
+ }
+}
+
+private fun lines(value: String): List =
+ value.split("\n").map { it.trim() }.filter { it.isNotBlank() }
+
+private fun commaList(value: String): List =
+ value.split(",").map { it.trim() }.filter { it.isNotBlank() }
+
+private fun detailOf(result: ApiResult<*>): String = when (result) {
+ is ApiResult.HttpError -> result.detail
+ is ApiResult.Unauthorized -> "Not signed in."
+ is ApiResult.NetworkError -> "No connection to the backend."
+ is ApiResult.Ok -> "Something went wrong."
+}
+
+/** Usage access has no per-app dialog; the system has a page for it. */
+private fun openUsageAccessSettings(context: Context) {
+ runCatching {
+ context.startActivity(
+ Intent(AndroidSettings.ACTION_USAGE_ACCESS_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ }
+}
+
+private fun openAccessibilitySettings(context: Context) {
+ runCatching {
+ context.startActivity(
+ Intent(AndroidSettings.ACTION_ACCESSIBILITY_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt
index 3c90a17..a17005f 100644
--- a/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt
+++ b/android/app/src/main/kotlin/tech/kadence/android/ui/components/Primitives.kt
@@ -28,6 +28,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -318,6 +319,40 @@ fun Banner(
}
}
+/**
+ * A label, an explanatory line and a switch on the right.
+ *
+ * Every settings-shaped row in the app is this: the caption is not optional, because a
+ * bare switch labelled "kill_blocked_apps" is a riddle, and the design system has no
+ * other place to explain itself.
+ */
+@Composable
+fun ToggleRow(
+ label: String,
+ detail: String,
+ checked: Boolean,
+ onChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+) {
+ val colors = KadenceTheme.colors
+ Row(
+ modifier.fillMaxWidth().padding(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = label,
+ style = KadenceTheme.type.body,
+ color = if (enabled) colors.ink else colors.inkFaint,
+ )
+ Text(text = detail, style = KadenceTheme.type.caption, color = colors.inkMuted)
+ }
+ Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
+ }
+}
+
/**
* Today's pomodoros as a row of dots — the web app's `TimerPage.tsx` encoding:
* earned dots filled in the work colour, the rest a hairline ring.
diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt
index 5b58b43..64cd769 100644
--- a/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt
+++ b/android/app/src/main/kotlin/tech/kadence/android/ui/settings/SettingsScreen.kt
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -45,6 +44,7 @@ import tech.kadence.android.ui.components.HorizontalRule
import tech.kadence.android.ui.components.PageHeader
import tech.kadence.android.ui.components.Panel
import tech.kadence.android.ui.components.SectionTitle
+import tech.kadence.android.ui.components.ToggleRow
import tech.kadence.android.ui.theme.KadenceTheme
@Composable
@@ -245,32 +245,6 @@ fun SettingsScreen(
}
}
-@Composable
-private fun ToggleRow(
- label: String,
- detail: String,
- checked: Boolean,
- onChange: (Boolean) -> Unit,
- enabled: Boolean = true,
-) {
- val colors = KadenceTheme.colors
- Row(
- Modifier.fillMaxWidth().padding(14.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Column(Modifier.weight(1f)) {
- Text(
- text = label,
- style = KadenceTheme.type.body,
- color = if (enabled) colors.ink else colors.inkFaint,
- )
- Text(text = detail, style = KadenceTheme.type.caption, color = colors.inkMuted)
- }
- Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
- }
-}
-
/** Deep-link straight to Kadence's notification page, not the global one. */
private fun openAppNotificationSettings(context: Context) {
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS)
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 28d767f..7ab4eb2 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -13,6 +13,15 @@
Rings when a focus interval or break reaches zero.
+
+ Blocked while focusing
+
+ Says what the focus guard turned away — a blocked site, or a blocked app
+ that was closed. Silent.
+
+
Pause
Resume
@@ -21,6 +30,15 @@
Start break
Start focus
+
+
+ Reads the address bar of open browser tabs so Kadence can turn away sites on
+ your block list while a focus interval runs, and takes you to the home screen
+ when a blocked app opens. Kadence never records what you type or read, and
+ nothing on it leaves the phone.
+
+
Kadence
diff --git a/android/app/src/main/res/xml/blocker_accessibility_config.xml b/android/app/src/main/res/xml/blocker_accessibility_config.xml
new file mode 100644
index 0000000..1ac5fa8
--- /dev/null
+++ b/android/app/src/main/res/xml/blocker_accessibility_config.xml
@@ -0,0 +1,21 @@
+
+
+
diff --git a/android/app/src/test/kotlin/tech/kadence/android/BlockerSettingsTest.kt b/android/app/src/test/kotlin/tech/kadence/android/BlockerSettingsTest.kt
new file mode 100644
index 0000000..4e70ddb
--- /dev/null
+++ b/android/app/src/test/kotlin/tech/kadence/android/BlockerSettingsTest.kt
@@ -0,0 +1,141 @@
+/**
+ * The blocker's settings subtree: wire keys in, [BlockerRules] out.
+ *
+ * The mapping lives at the top of `BlockerEngine.kt` and is exercised here rather than
+ * behind a screen, because it is the part of the focus guard that fails *silently* — a
+ * mistyped key decodes to a default, the phone reports itself enforcing, and nothing
+ * blocks. Offline enforcement (`evaluatePolicy`) runs at the end of the happy-path
+ * test for the same reason: it is the only consumer of what this function produces
+ * that cannot be observed while online.
+ */
+package tech.kadence.android
+
+import org.junit.Test
+import tech.kadence.android.blocker.toCore
+import tech.kadence.android.data.dto.BlockerPolicyDto
+import tech.kadence.android.data.dto.BlockerSettingsDto
+import tech.kadence.android.data.dto.SettingsEnvelopeDto
+import tech.kadence.android.data.net.KadenceJson
+import tech.kadence.core.BlockAction
+import tech.kadence.core.BlockDecision
+import tech.kadence.core.BlockerRules
+import tech.kadence.core.DomainMatcher
+import tech.kadence.core.evaluatePolicy
+import tech.kadence.core.invalidExpansions
+import tech.kadence.core.unexpandableMasks
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class BlockerSettingsTest {
+
+ private fun fixture(name: String): String =
+ checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) {
+ "missing fixture: $name"
+ }.bufferedReader().readText()
+
+ @Test
+ fun `the settings envelope carries the blocker subtree`() {
+ val envelope = KadenceJson.decodeFromString(fixture("settings.json"))
+ val blocker = envelope.settings.blocker
+
+ assertTrue(blocker.enabled)
+ assertEquals("scheduled", blocker.mode)
+ assertTrue(blocker.blockDuringBreaks)
+ assertEquals(listOf("youtube.com", "*.reddit.com", "*gram*"), blocker.blockedDomains)
+ assertEquals(listOf("music.youtube.com"), blocker.allowedDomains)
+ 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)
+ assertEquals("Europe/Kyiv", envelope.settings.timezone)
+ }
+
+ @Test
+ fun `the DTO maps onto BlockerRules and enforces when online rules cannot`() {
+ val envelope = KadenceJson.decodeFromString(fixture("settings.json"))
+ val rules = envelope.settings.blocker.toCore()
+
+ // The keys, spelled out — this is what the assertions are actually about.
+ assertEquals(listOf("www", "m", "web", "*"), rules.subdomainExpansions)
+ assertEquals(listOf("youtube.com", "*.reddit.com", "*gram*"), rules.blockedDomains)
+ assertFalse(rules.killBlockedApps)
+ assertEquals(1, rules.schedule.size)
+ assertEquals("09:00:00", rules.schedule[0].start)
+
+ // `*` is the wildcard flag rather than a label; only genuine junk is refused.
+ // The wildcard itself surfaces as an extra "unexpandable" pattern alongside
+ // `*gram*`, which is the matcher's to enforce on top of the hosts file.
+ assertEquals(emptyList(), invalidExpansions(rules))
+ assertEquals(listOf("*.reddit.com", "*gram*"), unexpandableMasks(rules))
+
+ // Monday 10:00 Kyiv sits inside the window, so the offline verdict agrees with
+ // what the server would have said: "inside a blocking window".
+ val now = ZonedDateTime.of(2026, 10, 19, 10, 0, 0, 0, ZoneId.of("Europe/Kyiv"))
+ val verdict = evaluatePolicy(rules, current = null, now)
+ assertTrue(verdict.active)
+ assertEquals("inside a blocking window", verdict.reason)
+
+ // …and the matcher blocks a host on the list while letting an exception through.
+ // The target is the hostname actually visited, which is what the report groups
+ // by — `m.youtube.com`, not the `youtube.com` entry that covered it.
+ val matcher = DomainMatcher(rules)
+ val youtube = matcher.decide("https://m.youtube.com/watch?v=x")
+ assertTrue(youtube is BlockDecision.Block)
+ assertEquals(BlockAction.BLOCKED, youtube.action)
+ assertEquals("m.youtube.com", youtube.target)
+ assertEquals("youtube.com", youtube.matchedRule)
+ assertTrue(matcher.decide("music.youtube.com") is BlockDecision.Allow)
+ // The fixture lists `*` in `subdomain_expansions`, so `*.reddit.com` covers
+ // labels no expansion named — the wildcard doing its job end to end.
+ assertTrue(matcher.decide("forum.reddit.com") is BlockDecision.Block)
+ }
+
+ @Test
+ fun `an absent blocker subtree decodes to the defaults the server ships`() {
+ val blocker = KadenceJson.decodeFromString("{}")
+ assertFalse(blocker.enabled)
+ assertEquals("during_work", blocker.mode)
+ assertTrue(blocker.blockedDomains.isEmpty())
+ assertTrue(blocker.allowedDomains.isEmpty())
+ assertTrue(blocker.blockedApps.isEmpty())
+ assertTrue(blocker.killBlockedApps)
+ assertTrue(blocker.notifyOnBlock)
+ assertFalse(blocker.strict)
+ assertEquals(BlockerRules.DEFAULT_SUBDOMAINS, blocker.subdomainExpansions)
+ }
+
+ @Test
+ fun `nulls where a value is declared fall back to defaults`() {
+ // `coerceInputValues` is what saves us here; without it this throws.
+ val nulled = KadenceJson.decodeFromString(
+ """
+ {"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
+ }}}
+ """.trimIndent(),
+ )
+ val blocker = nulled.settings.blocker
+ assertFalse(blocker.enabled)
+ assertEquals("during_work", blocker.mode)
+ assertTrue(blocker.blockedDomains.isEmpty())
+ assertTrue(blocker.killBlockedApps)
+ assertEquals(BlockerRules.DEFAULT_SUBDOMAINS, blocker.subdomainExpansions)
+ }
+
+ @Test
+ fun `the policy reports invalid expansions and defaults to none`() {
+ val active = KadenceJson.decodeFromString(fixture("blocker_policy_active.json"))
+ assertEquals(listOf("not a label"), active.invalidExpansions)
+
+ val inactive = KadenceJson.decodeFromString(fixture("blocker_policy_inactive.json"))
+ assertTrue(inactive.invalidExpansions.isEmpty())
+ }
+}
diff --git a/android/app/src/test/kotlin/tech/kadence/android/DtoToleranceTest.kt b/android/app/src/test/kotlin/tech/kadence/android/DtoToleranceTest.kt
index a4a6c61..3fa683a 100644
--- a/android/app/src/test/kotlin/tech/kadence/android/DtoToleranceTest.kt
+++ b/android/app/src/test/kotlin/tech/kadence/android/DtoToleranceTest.kt
@@ -247,6 +247,7 @@ class DtoToleranceTest {
"timer_state_running.json", "timer_state_idle.json", "timer_complete_reply.json",
"blocker_policy_active.json", "blocker_policy_inactive.json", "tasks.json",
"daily_report.json", "stats_summary.json", "block_events.json", "auth_me.json",
+ "settings.json",
)
for (name in names) {
val parsed = KadenceJson.parseToJsonElement(fixture(name))
diff --git a/android/app/src/test/resources/fixtures/blocker_policy_active.json b/android/app/src/test/resources/fixtures/blocker_policy_active.json
index 89f6a4f..35d4848 100644
--- a/android/app/src/test/resources/fixtures/blocker_policy_active.json
+++ b/android/app/src/test/resources/fixtures/blocker_policy_active.json
@@ -8,6 +8,7 @@
"domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
"apps": ["steam*", "*discord*"],
"unexpandable": ["*gram*"],
+ "invalid_expansions": ["not a label"],
"kill_apps": true,
"notify_on_block": true,
"poll_seconds": 5,
diff --git a/android/app/src/test/resources/fixtures/settings.json b/android/app/src/test/resources/fixtures/settings.json
new file mode 100644
index 0000000..20bf882
--- /dev/null
+++ b/android/app/src/test/resources/fixtures/settings.json
@@ -0,0 +1,41 @@
+{
+ "settings": {
+ "pomodoro": {
+ "work_minutes": 25,
+ "short_break_minutes": 5,
+ "long_break_minutes": 15,
+ "cycles_before_long_break": 4,
+ "daily_goal_pomodoros": 8,
+ "timer_enabled": true,
+ "strict_mode": false,
+ "allow_pause": true,
+ "auto_start_breaks": false,
+ "auto_start_work": false,
+ "presets": [
+ {"name": "deep", "work_minutes": 50, "short_break_minutes": 10, "long_break_minutes": 20, "cycles_before_long_break": 4}
+ ]
+ },
+ "blocker": {
+ "enabled": true,
+ "mode": "scheduled",
+ "block_during_breaks": true,
+ "blocked_domains": ["youtube.com", "*.reddit.com", "*gram*"],
+ "allowed_domains": ["music.youtube.com"],
+ "subdomain_expansions": ["www", "m", "web", "*"],
+ "blocked_apps": ["*discord*", "instagram"],
+ "kill_blocked_apps": false,
+ "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
+ },
+ "timezone": "Europe/Kyiv"
+ },
+ "defaults": {
+ "blocked_domains": ["*.youtube.com", "twitter.com", "x.com"],
+ "blocked_apps": ["steam*", "discord", "telegram-desktop", "spotify"]
+ }
+}
diff --git a/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt b/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt
index 7cf7f45..528b610 100644
--- a/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt
+++ b/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt
@@ -64,16 +64,20 @@ data class PolicyVerdict(val active: Boolean, val reason: String)
* `youtube.com` -> itself plus `www.` and `m.`; `*.youtube.com` -> the bare domain plus
* every configured subdomain; `*gram*` -> nothing expandable, returned as a leftover.
*
- * A port of `expand_entry()`. Returns `(hostnames, unexpandablePattern)`.
+ * A port of `expand_entry()`. Returns `(hostnames, unexpandablePattern)`; with
+ * `wildcard` set, `*.youtube.com` returns both at once — the concrete
+ * names block on their own, and the pattern says "every other subdomain too",
+ * which the URL-bar matcher (here) and the desktop's resolver stub take from there.
*/
-fun expandEntry(raw: String, subdomains: List): Pair, String?> {
+fun expandEntry(raw: String, labels: List, wildcard: Boolean = false): Pair, String?> {
val entry = raw.trim().lowercase().trimStart('.')
if (entry.isEmpty()) return emptyList() to null
if (entry.startsWith("*.")) {
val base = entry.removePrefix("*.")
if (base.isEmpty() || '*' in base) return emptyList() to entry
- return (listOf(base) + subdomains.map { "$it.$base" }) to null
+ val hosts = listOf(base) + labels.map { "$it.$base" }
+ return hosts to (entry.takeIf { wildcard })
}
if ('*' in entry) return emptyList() to entry
@@ -90,9 +94,50 @@ fun allowMasksOf(rules: BlockerRules): List = rules.allowedDomains
.filter { it.isNotBlank() }
.map { it.trim().lowercase().trimStart('.') }
-private fun subdomainsOf(rules: BlockerRules): List = rules.subdomainExpansions
- .filter { it.isNotBlank() }
- .map { it.trim().lowercase() }
+/** One label of a hostname: what may legally sit in front of `.example.com`. */
+private val LABEL_RE = Regex("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
+
+/** What `subdomainExpansions` asked for, split three ways. See [parseExpansions]. */
+data class Expansion(
+ val labels: List = emptyList(),
+ val wildcard: Boolean = false,
+ val rejected: List = emptyList(),
+)
+
+/**
+ * `subdomainExpansions` split into labels, the `*` flag, and rejects — the port of
+ * `parse_expansions()`.
+ *
+ * A bare `*` is the user saying *every* subdomain, and it is supported rather than
+ * refused: the labels still expand into hostnames (which block without help), while
+ * [Expansion.wildcard] tells the matcher to cover the rest by mask. Before that, `*`
+ * was treated as a label, so `*.telegram.org` expanded to the literal string
+ * `*.telegram.org` — not a hostname, which the desktop app dropped without a word,
+ * `unexpandable` stayed empty, and `web.telegram.org` was never blocked. Anything
+ * that is neither a label nor `*` is still refused, and reported as a reject.
+ */
+fun parseExpansions(raw: List): Expansion {
+ val labels = LinkedHashSet()
+ val rejected = LinkedHashSet()
+ var wildcard = false
+ for (entry in raw) {
+ val label = entry.trim().lowercase()
+ if (label.isEmpty()) continue
+ when {
+ label == "*" -> wildcard = true
+ LABEL_RE.matches(label) -> labels.add(label)
+ else -> rejected.add(label)
+ }
+ }
+ return Expansion(labels.toList(), wildcard, rejected.toList())
+}
+
+/** Expansion entries neither a label nor `*`, so the UI can flag them. */
+fun invalidExpansions(rules: BlockerRules): List =
+ parseExpansions(rules.subdomainExpansions).rejected
+
+private fun expansionOf(rules: BlockerRules): Expansion =
+ parseExpansions(rules.subdomainExpansions)
/**
* Every hostname a hosts file would be pointed at the redirect IP, allow list already
@@ -113,11 +158,11 @@ fun expandDomains(rules: BlockerRules): List = expandDomainsWithOrigin(r
*/
fun expandDomainsWithOrigin(rules: BlockerRules): Map {
val allowMasks = allowMasksOf(rules)
- val subdomains = subdomainsOf(rules)
+ val expansion = expansionOf(rules)
val origin = LinkedHashMap()
for (raw in rules.blockedDomains) {
val entry = raw.trim().lowercase().trimStart('.')
- val (expanded, _) = expandEntry(raw, subdomains)
+ val (expanded, _) = expandEntry(raw, expansion.labels, expansion.wildcard)
for (host in expanded) {
if (!isAllowed(host, allowMasks)) origin.putIfAbsent(host, entry)
}
@@ -125,12 +170,16 @@ fun expandDomainsWithOrigin(rules: BlockerRules): Map {
return origin
}
-/** Block-list entries a hosts file cannot express, so the UI can flag them. */
+/**
+ * Block-list entries a hosts file cannot express — the patterns this matcher takes
+ * over in the URL bar, and that the desktop app answers at DNS level when it can
+ * route the zone. The UI shows them as "enforced beyond the hosts file".
+ */
fun unexpandableMasks(rules: BlockerRules): List {
- val subdomains = subdomainsOf(rules)
+ val expansion = expansionOf(rules)
val leftovers = LinkedHashSet()
for (raw in rules.blockedDomains) {
- val (_, pattern) = expandEntry(raw, subdomains)
+ val (_, pattern) = expandEntry(raw, expansion.labels, expansion.wildcard)
if (pattern != null) leftovers.add(pattern)
}
return leftovers.toList()
diff --git a/android/core/src/main/kotlin/tech/kadence/core/Matchers.kt b/android/core/src/main/kotlin/tech/kadence/core/Matchers.kt
index 6227bdd..6bf9e4b 100644
--- a/android/core/src/main/kotlin/tech/kadence/core/Matchers.kt
+++ b/android/core/src/main/kotlin/tech/kadence/core/Matchers.kt
@@ -6,11 +6,12 @@
* the package name and the app's visible label — standing in for the Linux process
* name and its command line.
*
- * `DomainMatcher` goes one step further than the desktop *can* go. A hosts file has no
- * wildcards, so the backend has to expand every mask into concrete hostnames and
- * reports whatever it cannot express as `unexpandable`. A URL-bar matcher has no such
- * limit, so patterns like `*gram*` — which the desktop apologises for — are enforced
- * here. That is the one place the phone is strictly stronger than the laptop.
+ * `DomainMatcher` goes one step further than a hosts file can. A hosts file has no
+ * wildcards, so the backend expands every mask into concrete hostnames and reports
+ * whatever it cannot express as `unexpandable`. A URL-bar matcher has no such limit:
+ * `*gram*` is matched here, and `*.youtube.com` covers every subdomain as soon as
+ * `*` is in `subdomain_expansions` — the same two things the desktop app now does
+ * through its local resolver stub.
*/
package tech.kadence.core
@@ -113,6 +114,7 @@ class DomainMatcher(
) {
private val exact: Set = expandedHosts.map { it.trim().lowercase() }.toSet()
private val allowMasks: List = allowMasksOf(rules)
+ private val expansion: Expansion = parseExpansions(rules.subdomainExpansions)
private val rawEntries: List = rules.blockedDomains
.filter { it.isNotBlank() }
.map { it.trim().lowercase().trimStart('.') }
@@ -137,7 +139,8 @@ class DomainMatcher(
return BlockDecision.Block(host, rule, BlockAction.BLOCKED)
}
- // 3. The raw entries, as patterns. This is the step the desktop cannot do.
+ // 3. The raw entries, as patterns. This is the step a hosts file cannot do —
+ // a `*gram*` mask, or every subdomain once `*` says so.
for (entry in rawEntries) {
if (entryCovers(entry, host)) {
return BlockDecision.Block(host, entry, BlockAction.BLOCKED)
@@ -148,13 +151,30 @@ class DomainMatcher(
}
/**
- * A mask is glob-matched; a plain entry covers itself and any subdomain of it, so
- * `youtube.com` catches `music.youtube.com` even though no expansion listed it.
+ * How much of a hostname one block-list entry covers.
+ *
+ * * a plain entry covers itself and any subdomain of it, so `youtube.com` catches
+ * `music.youtube.com` even though no expansion listed it;
+ * * `*.youtube.com` is a wildcard only when `*` is in `subdomain_expansions` —
+ * otherwise it means exactly the configured labels, which is what the desktop
+ * app expands into a hosts file, and what the settings screens say;
+ * * any other mask (`*gram*`, `*gram.com`) is glob-matched, as it always was —
+ * the one place the phone stays stronger than a hosts file.
*/
- private fun entryCovers(entry: String, host: String): Boolean = when {
- entry.isEmpty() -> false
- isMask(entry) -> globMatches(entry, host)
- else -> host == entry || host.endsWith(".$entry")
+ private fun entryCovers(entry: String, host: String): Boolean {
+ if (entry.isEmpty()) return false
+ if (entry.startsWith("*.")) {
+ val base = entry.removePrefix("*.")
+ if (base.isNotEmpty() && '*' !in base) {
+ if (expansion.wildcard) return globMatches(entry, host)
+ return host == base || expansion.labels.any { host == "$it.$base" }
+ }
+ }
+ return if (isMask(entry)) {
+ globMatches(entry, host)
+ } else {
+ host == entry || host.endsWith(".$entry")
+ }
}
/** The masks the server had to give up on, which this client does enforce. */
diff --git a/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt b/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt
index caa3125..716f9ab 100644
--- a/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt
+++ b/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt
@@ -233,6 +233,41 @@ class BlockerTest {
assertFalse(evaluatePolicy(rules, null, at("2026-10-19", "12:00")).active)
}
+ @Test
+ fun `a wildcard is the user asking for every subdomain`() {
+ // `*` used to be treated as a subdomain label, so `*.telegram.org` expanded to
+ // the literal string `*.telegram.org` — which every client then dropped without
+ // a word. It is the wildcard now: the labels still expand into real hostnames,
+ // and the pattern comes back for the matcher to take the rest.
+ val rules = BlockerRules(
+ blockedDomains = listOf("*.telegram.org"),
+ subdomainExpansions = listOf("*", "web", "", "not a label"),
+ )
+ assertEquals(listOf("web"), parseExpansions(rules.subdomainExpansions).labels)
+ assertTrue(parseExpansions(rules.subdomainExpansions).wildcard)
+ assertEquals(listOf("not a label"), invalidExpansions(rules))
+ assertEquals(listOf("telegram.org", "web.telegram.org"), expandDomains(rules))
+ assertTrue(expandDomains(rules).none { '*' in it })
+ assertEquals(listOf("*.telegram.org"), unexpandableMasks(rules))
+ }
+
+ @Test
+ fun `without a wildcard the mask stays inside the expansion list`() {
+ // The documented meaning of `*.google.com` before `*` existed: the bare domain
+ // plus the labels the user configured, nothing more, and nothing for a hosts
+ // file to refuse.
+ val rules = BlockerRules(
+ blockedDomains = listOf("*.google.com"),
+ subdomainExpansions = listOf("www", "mail"),
+ )
+ assertFalse(parseExpansions(rules.subdomainExpansions).wildcard)
+ assertEquals(emptyList(), unexpandableMasks(rules))
+ assertEquals(
+ listOf("google.com", "www.google.com", "mail.google.com"),
+ expandDomains(rules),
+ )
+ }
+
@Test
fun `zone parsing falls back instead of throwing`() {
assertEquals(ZoneId.of("Europe/Moscow"), zoneOrDefault("Europe/Moscow"))
diff --git a/android/core/src/test/kotlin/tech/kadence/core/MatchersTest.kt b/android/core/src/test/kotlin/tech/kadence/core/MatchersTest.kt
index 0df56aa..db19631 100644
--- a/android/core/src/test/kotlin/tech/kadence/core/MatchersTest.kt
+++ b/android/core/src/test/kotlin/tech/kadence/core/MatchersTest.kt
@@ -153,10 +153,12 @@ class DomainMatcherTest {
@Test
fun `a subdomain mask blocks the base domain too, via expansion`() {
- val m = matcher(blocked = listOf("*.reddit.com"))
+ val m = matcher(blocked = listOf("*.reddit.com"), subs = listOf("www", "old"))
assertBlocked(m.decide("reddit.com"), "reddit.com", "*.reddit.com")
assertBlocked(m.decide("www.reddit.com"), "www.reddit.com", "*.reddit.com")
assertBlocked(m.decide("old.reddit.com"), "old.reddit.com", "*.reddit.com")
+ // …and only those: without `*` in the expansion list, an unlisted label is out.
+ assertEquals(BlockDecision.Allow, m.decide("cdn.reddit.com"))
}
@Test
@@ -205,6 +207,32 @@ class DomainMatcherTest {
assertEquals("youtube.com", block.matchedRule)
}
+ @Test
+ fun `without a wildcard a subdomain mask covers only the configured labels`() {
+ // What the settings screens promise for `*.google.com`: the bare domain plus
+ // the labels listed in `subdomain_expansions`, and nothing else.
+ val m = matcher(blocked = listOf("*.google.com"), subs = listOf("www", "mail"))
+ assertBlocked(m.decide("google.com"), "google.com", "*.google.com")
+ assertBlocked(m.decide("mail.google.com"), "mail.google.com", "*.google.com")
+ assertEquals(BlockDecision.Allow, m.decide("cdn.google.com"))
+ }
+
+ @Test
+ fun `a wildcard in the expansion list makes the mask cover every label`() {
+ val m = matcher(blocked = listOf("*.google.com"), subs = listOf("*", "www"))
+ assertBlocked(m.decide("cdn.google.com"), "cdn.google.com", "*.google.com")
+ assertBlocked(m.decide("a.b.google.com"), "a.b.google.com", "*.google.com")
+ // The bare domain still belongs to the mask, via its expansion.
+ assertBlocked(m.decide("google.com"), "google.com", "*.google.com")
+ // And the allow list still wins over the wildcard.
+ val allowed = matcher(
+ blocked = listOf("*.google.com"),
+ allowed = listOf("docs.google.com"),
+ subs = listOf("*"),
+ )
+ assertEquals(BlockDecision.Allow, allowed.decide("docs.google.com"))
+ }
+
@Test
fun `case and whitespace in the list do not matter`() {
val m = matcher(blocked = listOf(" YouTube.COM "))