The app module was a design gallery: tokens, type and a dial, with no backend, no notification and nothing driving the rear matrix. This is the rest of it. **One engine, three views.** `TimerEngine` lives on the Application, not in a ViewModel, because the Compose screen, the foreground service's notification and the Glyph must show the *same* second. Two engines polling the same backend drift apart within seconds and the user sees a notification disagreeing with the dial. It polls, ticks from a monotonic anchor, and puts every reply through `:core`'s `reconcile`. `pomodoro.state()` does not finish an interval whose time is up — it reports `remaining_seconds = 0` and waits for a client to call `complete`. The tick loop does, behind the same re-entry guard `desktop/controller.py` uses, since without one a 1 Hz tick at zero fires `complete` every second until the reply lands. **Notifications.** Two channels, because the running interval must be silent and its *end* must not; one channel cannot be both, and a user who silences a buzzing countdown has silenced the only part that mattered. The ongoing one is an Android 16 Live Update: promotion needs `setOngoing` + `setRequestPromotedOngoing` + a `ProgressStyle`, and missing any one renders it as an ordinary notification with no error and no log. Its segments come from `:core`'s `dialTickCount`, so the status bar, the dial and the Glyph ring agree on what one tick is worth. **Glyph.** `GlyphMatrixManager.init()` binds `com.nothing.thirdparty/.GlyphService` and *discards* the boolean `bindService` returns — so on Android 11+ a missing `<queries>` entry makes the package invisible, the bind silently fail, and the matrix stay dark with no exception, no log and `onServiceConnected` never arriving. That was the whole of "the glyph does not work". The manifest now declares it and `MatrixGlyphDisplay` checks visibility itself rather than leaving the next person with the same dead end. The SDK's licence forbids redistribution (clause 2.1(c)), so the .aar stays git-ignored and `fetch-glyph-sdk.sh` downloads it. Without it a second source set compiles a no-op display: the app builds, installs and runs with an inert rear panel, which is also the correct behaviour on any phone that is not a Nothing. Verified on the device (Phone (3), Android 16), debug and release: onboarding against kadence.timka.su, the Live Update with `shortCriticalText` and a 600s progress track, the matrix accepting 625-dot frames, tasks opening in the Jira app, and R8 leaving the DTOs decodable. 225 tests, none needing a device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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:
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:
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
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 —
1is 1024 units against0at 1461 — and the font ships notnumfeature, so the web app's globalfont-variant-numeric: tabular-numsdoes nothing for the serif dial clock. Here the clock goes throughTabularFigures, which lays every glyph in a box as wide as the widest digit;ThemeTestmeasures it. Inter does shiptnum, 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 pollsUsageStatsManager. 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:
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://<lan-ip>:8000/api/health is the first thing to check if onboarding cannot reach
the backend.