# Kadence for Android The fourth client, for the Nothing Phone (3). Same backend, same editorial design, plus the three things only a native phone client can do: enforce the focus guard on the phone itself, surface the running interval as an Android 16 Live Update, and draw the timer on the rear Glyph Matrix. ## Two modules, and why | Module | What it is | Needs the Android SDK? | | --- | --- | --- | | **`core`** | Plain Kotlin on the JVM: the matchers, the policy clock, the timer maths, the outbox reconciliation, the Glyph frame renderer, the design tokens | **No** | | **`app`** | The Android client: Compose UI, services, Room, the HTTP client, the Glyph toy | Yes | Everything with interesting behaviour lives in `core`, deliberately. The result is that the hard parts — offline reconciliation, glob and mask matching, the blocking policy, even the 25×25 rear display — are **testable on a laptop with nothing but a JDK**, with no emulator, no SDK and no phone in the loop: ```bash make android-test # both modules: 234 tests, no device, no backend make android-test-core # just the pure-logic suite: needs only a JDK ``` `settings.gradle.kts` only includes `:app` when an SDK is actually present, so that command works on a machine that has never seen the Android tooling. ## Running the tests ``` ./gradlew :core:test :app:testDebugUnitTest ``` **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` (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`, 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 | | `OutboxCoalesceTest` / `PostconditionTest` / `PartitionForReplayTest` | the offline queue: coalescing, the at-most-once probes, and quarantining intervals that cannot be filed honestly | | `TickMathTest` / `DialGeometryTest` | the countdown anchor and the dial's tick rule | | `Glyph*Test` | the rear matrix, asserted as ASCII art | | `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` (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` | ## Toolchain Gradle 9.6 (via the wrapper) on **JDK 21** — newer JDKs are ahead of what AGP supports: ```bash export JAVA_HOME=/usr/lib/jvm/java-21-openjdk ``` For the `app` module you also need an Android SDK. AGP 9.4 with Compose 1.12 wants `compileSdk 37`, which is published under Android's new minor-version scheme as `platforms;android-37.0` (plain `android-37` does not exist). `targetSdk` stays 36, because Android 16 is what the phone runs. Point the build at the SDK with `ANDROID_HOME`, or `android/local.properties`: ``` sdk.dir=/home/you/Android/Sdk ``` ## The Glyph Matrix SDK `glyph-matrix-sdk-2.0.aar` is **not on Maven** and its licence forbids redistribution (clause 2.1(c)), so it cannot be committed: ```bash ./fetch-glyph-sdk.sh # downloads it into app/libs/ ``` It is git-ignored, and `app/build.gradle.kts` picks one of two source sets by whether the file is there — `src/glyph` binds `GlyphMatrixManager`, `src/noglyph` compiles a no-op. So a fresh clone builds, installs and runs with an inert rear panel, which is also the correct behaviour on any phone that is not a Nothing. The renderer itself is in `core` and needs none of this to be tested. ### Two things that cost an afternoon Both fail **silently** — no exception, no log, nothing in logcat — and each alone is enough to leave the matrix dark while the rest of the app works perfectly. * **`` is mandatory.** `GlyphMatrixManager.init()` binds `com.nothing.thirdparty/.GlyphService`, and since Android 11 a package not named in the manifest's `` is invisible: `bindService` returns false, the SDK *discards* that return value, and `onServiceConnected` simply never arrives. * **`setAppMatrixFrame` does nothing** on Phone (3) build 2608141733, despite the developer kit insisting app-driven control must use the `App` variants. It is accepted and dropped — 0 hardware writes against 232 for `setMatrixFrame` over the same 20 seconds of 1 Hz pushes. Both paths therefore use the plain variant. And one that is merely a trap: **`getDeviceMatrixLength()` returns the side (25), not 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 against `0` at 1461 — and the font ships no `tnum` feature, so the web app's global `font-variant-numeric: tabular-nums` does nothing for the serif dial clock. Here the clock goes through `TabularFigures`, which lays every glyph in a box as wide as the widest digit; `ThemeTest` measures it. Inter *does* ship `tnum`, so the sans numbers are fixed with a font feature instead. * **The canvas is Kadence's, the accent is the phone's.** Warm paper, ink type, hairline rules and Fraunces figures are fixed; the single accent comes from Android's dynamic palette when available, falling back to the brand ember. `onAccentArgb()` picks the label colour by WCAG contrast, because a wallpaper-derived accent can be any lightness — that guard is what keeps "use the system colour" from producing unreadable buttons. * **Chart colours never follow the accent.** They come from the validated, colour-blind safe palette in `viz.ts`. A chart has to stay readable; that is a property of the palette, not of the wallpaper. * **No `VpnService`, ever.** Android allows exactly one, and it belongs to the user's always-on VPN. Website blocking reads the browser URL bar through an accessibility service instead; app blocking polls `UsageStatsManager`. Neither touches the VPN slot. * **The server owns the clock.** The phone polls, ticks locally from a monotonic anchor, and re-anchors on every reply. Offline writes go to an outbox and the server's answer always wins the final word. ## What the app actually is | Piece | Where | | --- | --- | | **`TimerEngine`** | one per process, on the Application. The Compose screen, the foreground service's notification and the rear matrix all read it, so they cannot disagree about the second | | **`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 whose time is up — it reports `remaining_seconds = 0` and waits for a client to call `complete`, which the tick loop does behind the same re-entry guard `desktop/controller.py` uses. ## Verifying on the device ```bash ./fetch-glyph-sdk.sh # once, for the rear display make android-install # ./gradlew :app:installDebug adb logcat -s Kadence ``` Onboarding asks for the backend URL and the `API_TOKEN` from its `.env`. Against the deployed instance that is `kadence.timka.su`; against `make up` on a laptop it is the LAN address, and an always-on VPN can swallow that — `adb shell curl -s http://:8000/api/health` is the first thing to check. Three things worth confirming by hand, because no test can: ```bash # the Live Update really was promoted, not just posted adb shell dumpsys notification --noredact | grep -E 'requestPromotedOngoing|shortCriticalText' # frames are reaching the matrix (and how many dots are lit) 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 ```