Files
kadence/android
timka123andClaude Opus 5 81032aca51 android: onboarding is not a wall, and server-owned edits say no offline
A never-connected install could not reach a single screen. Onboarding swapped the
whole shell out until `onboarded`, which only became true after `/api/health`
*and* `/api/timer/state` both answered, and there was no way past. The comment
explaining that argued an app which shows nothing is worse than one that insists
— which was true when nothing survived a process, and is not true now: the timer
ticks from the kept anchor, the guard enforces the kept rules, taps go to the
outbox and the lists show what they last held.

So once an attempt has actually failed *for want of a network* — not on a wrong
address, where stopping here is still right because this is where the message can
explain it — the screen offers "Use it offline for now" and says exactly what
that does and does not do.

The other direction, for honesty: the blocker's rules are the server's settings
document, and there is no outbox for it. A settings PUT is a deep merge of a
shared object, so queueing one would replay a stale version over whatever the web
UI did meanwhile. The "Focus guard on" toggle and "Save lists" are therefore
disabled offline instead of appearing to work and silently failing, which is the
pattern the Settings screen already followed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:47:30 +03:00
..
2026-10-19 14:52:20 +03:00

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: 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:

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:

./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.

  • <queries> is mandatory. GlyphMatrixManager.init() binds com.nothing.thirdparty/.GlyphService, and since Android 11 a package not named in the manifest's <queries> 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

./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://<lan-ip>:8000/api/health is the first thing to check.

Three things worth confirming by hand, because no test can:

# 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:

adb shell settings get secure enabled_accessibility_services
adb logcat -s Kadence | grep -i blocker