# 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: 225 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 ``` **225 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` (39 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 | | `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. ## 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` | | **`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 ```