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
+48
-5
@@ -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
|
||||
```
|
||||
Reference in new issue
Block a user