android: the focus guard on the phone itself
The Guard screen shows the shared policy; two Android facilities enforce it. An accessibility service reads the browser's URL bar (no /etc/hosts exists, and the VPN slot belongs to the user's always-on VPN), sends blocked pages home and records the attempt; UsageStatsManager spots blocked apps in front and the service presses home — 'kill' cannot mean force-stop, and the KDoc says so. Masks mean what they mean on every client: a bare '*' in the expansion list makes '*.site' cover every subdomain, and masks no hosts file can express are matched as ordinary globs against every URL, so they work here even where the desktop cannot route them. Offline, the kept blocker settings are re-evaluated with :core's evaluatePolicy, so losing signal does not lose the guard. Two limits are documented rather than papered over: in-app WebViews are invisible to the URL-bar reader, and blocking degrades to reporting without the service. Core keeps the policy/matcher semantics testable on a plain JVM: 190 core and 44 app tests. The screen's copy says junk labels are ignored, a '*' is kept, and unroutable masks are enforced on this phone only.
This commit is contained in:
24 files changed
+1996
-65
No files matched your search
@@ -16,6 +16,21 @@
|
||||
permission since Android 13; the timer screen asks for it. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- The focus guard's two grants. Neither is a runtime permission, so nothing
|
||||
"requests" them — the Blocker screen sends the user to the matching Settings
|
||||
page and offers the same banners until they come back.
|
||||
|
||||
Usage access is what app blocking polls. It is not an install-time permission
|
||||
at all but an AppOps grant; declaring it here is what puts "usage access" in
|
||||
this app's entry under Settings → Special access. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!-- Accessibility, granted to the service below from Android's Accessibility
|
||||
menu. This is what reads a browser's URL bar and presses Home. -->
|
||||
<uses-permission android:name="android.permission.BIND_ACCESSIBILITY_SERVICE" />
|
||||
|
||||
<!-- What keeps the process alive while the app is off screen. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
@@ -36,6 +51,24 @@
|
||||
-->
|
||||
<queries>
|
||||
<package android:name="com.nothing.thirdparty" />
|
||||
|
||||
<!-- App labels. `BlockerEngine` names apps by their visible name in reports
|
||||
("Discord (com.discord)"), and since Android 11 `getApplicationLabel` on a
|
||||
package not named here throws NameNotFoundException — every listed app
|
||||
would fall back to its package id. The MAIN/LAUNCHER intent is what
|
||||
covers "everything the user could actually open". -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
|
||||
<!-- The default browser, which is the one app whose URL bar the focus guard
|
||||
reads. Resolved with `ACTION_VIEW` + `http://`, so a browser this app has
|
||||
never heard of still counts as a browser. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="http" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
@@ -107,5 +140,29 @@
|
||||
android:name="com.nothing.glyph.toy.longpress"
|
||||
android:value="1" />
|
||||
</service>
|
||||
|
||||
<!--
|
||||
The focus guard's eyes and hands. Declared with BIND_ACCESSIBILITY_SERVICE,
|
||||
configured by res/xml/blocker_accessibility_config.xml, and enabled by the
|
||||
user from Settings → Accessibility → Kadence — Android will not grant it any
|
||||
other way.
|
||||
|
||||
`android:exported="false"` is deliberate and correct here: the permission is
|
||||
the system's binding contract, and a false export stops a third party from
|
||||
sending us events pretending to be the system.
|
||||
-->
|
||||
<service
|
||||
android:name=".blocker.BlockerAccessibilityService"
|
||||
android:exported="false"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/blocker_accessibility_config" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+196
@@ -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<String>()
|
||||
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<String> {
|
||||
root ?: return emptyList()
|
||||
val found = mutableListOf<String>()
|
||||
val queue = ArrayDeque<Pair<AccessibilityNodeInfo, Int>>()
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -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<String> = emptyList(),
|
||||
/** `subdomain_expansions` entries the server refused as "not a DNS label". */
|
||||
val invalidExpansions: List<String> = 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<BlockerUi> = _state.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<BlockerEvent>(extraBufferCapacity = 8)
|
||||
val events: SharedFlow<BlockerEvent> = _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<String, Long>()
|
||||
|
||||
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<DomainMatcher, AppMatcher> = 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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<BlockEventsDto> = api.get("/api/blocker/events")
|
||||
|
||||
/** Starter lists the web app seeds a fresh install with. */
|
||||
suspend fun blockerDefaults(): ApiResult<BlockDefaultsDto> = 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<String, Any>): ApiResult<SettingsEnvelopeDto> {
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ data class BlockerPolicyDto(
|
||||
/** Likewise empty when inactive. */
|
||||
val apps: List<String> = emptyList(),
|
||||
val unexpandable: List<String> = emptyList(),
|
||||
/** Subdomain expansions the server refused — a bare `*`, say, which is not a label. */
|
||||
@SerialName("invalid_expansions") val invalidExpansions: List<String> = 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<PomodoroPresetDto> = 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<Int> = 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<String> = emptyList(),
|
||||
@SerialName("allowed_domains") val allowedDomains: List<String> = emptyList(),
|
||||
@SerialName("subdomain_expansions")
|
||||
val subdomainExpansions: List<String> = tech.kadence.core.BlockerRules.DEFAULT_SUBDOMAINS,
|
||||
@SerialName("blocked_apps") val blockedApps: List<String> = emptyList(),
|
||||
@SerialName("kill_blocked_apps") val killBlockedApps: Boolean = true,
|
||||
val schedule: List<BlockWindowDto> = 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<String> = emptyList(),
|
||||
val apps: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AppSettingsDto(
|
||||
val pomodoro: PomodoroSettingsDto = PomodoroSettingsDto(),
|
||||
val blocker: BlockerSettingsDto = BlockerSettingsDto(),
|
||||
val timezone: String = "",
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<String?>(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<String, Any>, 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<String>()
|
||||
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<String> =
|
||||
value.split("\n").map { it.trim() }.filter { it.isNotBlank() }
|
||||
|
||||
private fun commaList(value: String): List<String> =
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
Rings when a focus interval or break reaches zero.
|
||||
</string>
|
||||
|
||||
<!-- The focus guard. A block is worth a quiet line in the shade, not a buzz:
|
||||
the guard acting is already the interruption, and buzzing on top of it
|
||||
punishes the user for the app's own defence. -->
|
||||
<string name="channel_blocker_name">Blocked while focusing</string>
|
||||
<string name="channel_blocker_description">
|
||||
Says what the focus guard turned away — a blocked site, or a blocked app
|
||||
that was closed. Silent.
|
||||
</string>
|
||||
|
||||
<!-- Notification actions. -->
|
||||
<string name="action_pause">Pause</string>
|
||||
<string name="action_resume">Resume</string>
|
||||
@@ -21,6 +30,15 @@
|
||||
<string name="action_start_break">Start break</string>
|
||||
<string name="action_start_focus">Start focus</string>
|
||||
|
||||
<!-- Shown by Android in Settings → Accessibility → Kadence, before the user
|
||||
turns the service on. -->
|
||||
<string name="blocker_accessibility_description">
|
||||
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.
|
||||
</string>
|
||||
|
||||
<!-- The Glyph Toy, as Nothing's Glyph Toys settings list shows it. -->
|
||||
<string name="toy_name">Kadence</string>
|
||||
<string name="toy_summary">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
What Kadence's accessibility service is allowed to see.
|
||||
|
||||
Only window-state and window-content events, because those are what a browser's URL
|
||||
bar changing produces; only `canRetrieveWindowContent`, because the URL is text in a
|
||||
view tree. No keystroke monitoring (`flagReportViewIds` is view ids, not input) and no
|
||||
global actions declared here — `GLOBAL_ACTION_HOME` needs no capability flag, only
|
||||
the binding this declaration creates.
|
||||
|
||||
Keep the event set narrow. Broadening it to "everything" would read more windows for
|
||||
no benefit: the service only ever acts on text that looks like a hostname in a
|
||||
browser's address bar (see BlockerAccessibilityService).
|
||||
-->
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagReportViewIds"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:description="@string/blocker_accessibility_description"
|
||||
android:notificationTimeout="250" />
|
||||
@@ -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<SettingsEnvelopeDto>(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<SettingsEnvelopeDto>(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<String>(), 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<BlockerSettingsDto>("{}")
|
||||
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<SettingsEnvelopeDto>(
|
||||
"""
|
||||
{"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<BlockerPolicyDto>(fixture("blocker_policy_active.json"))
|
||||
assertEquals(listOf("not a label"), active.invalidExpansions)
|
||||
|
||||
val inactive = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_inactive.json"))
|
||||
assertTrue(inactive.invalidExpansions.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user