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 "))