# 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: 219 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 ``` **219 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): | 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 | | `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` (33 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 | | `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 | ## 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** — it has to be downloaded from [Nothing's developer kit](https://github.com/Nothing-Developer-Programme/GlyphMatrix-Developer-Kit) and dropped into `glyph/libs/`. It is git-ignored, and the Glyph code sits behind an interface with a no-op implementation, so a missing `.aar` disables the rear display and breaks nothing else. The renderer itself is in `core` and needs none of this to be tested. ## 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. ## Verifying on the device See the plan at `~/.claude/plans/make-an-app-for-logical-russell.md` for the full manual script. The short version, once the SDK is installed: ```bash make up # backend + web UI on :8000 ip -4 addr show | grep inet # find the LAN address for the phone make android-install # ./gradlew :app:installDebug adb logcat -s Kadence:* ``` Note that an always-on VPN can swallow a LAN address; `adb shell curl -s http://:8000/api/health` is the first thing to check if onboarding cannot reach the backend.