android / apk (push) Failing after 59s
lint / ruff (push) Failing after 3s
tests / backend (push) Failing after 2s
tests / desktop (push) Failing after 2s
tests / web (push) Successful in 27s
tests / android-core (push) Failing after 3s
docker / image (push) Canceled after 8s
274 Android tests, not 234; 73 backend, not 71. The new suites named alongside the others, since that table is how anyone finds out what is already covered. Two sentences in the Android README described the intent rather than the code, and both now describe the code: "offline, the server's last answer keeps working" (it is on disk, so it survives the process — and the process Android starts for the accessibility service is the one that needed it most), and "offline writes go to an outbox" (they do now; it says which guarantees that buys, and what happens to an interval that cannot be filed honestly). The `:app` module never contained Room, either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
239 lines
14 KiB
Markdown
239 lines
14 KiB
Markdown
# 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, the HTTP client, the local store, 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: 274 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
|
||
```
|
||
|
||
**274 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` (193 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` (81 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` |
|
||
| `BlockerEngineTest` | the bug that let the second launch through: enforcement runs on every visit, the report is throttled, and a missing accessibility grant does not consume a visit. Also that a mask the backend could not expand is enforced *online* |
|
||
| `UrlBarTest` | which text in a browser's window is the address bar's — and which is the page's, because a false positive ejects you from a page you are allowed to be on |
|
||
| `TimerEngineTest` | a tap's effect appearing at once, and the server's reply being kept rather than discarded as a conflict |
|
||
| `OfflineWriteTest` | the outbox for real: a queued pause, coalescing, pause-then-resume annihilating, the unfilable interval being reported, and an offline interval surviving a restart |
|
||
| `ColdStartOfflineTest` | a process that has never reached the server: the dial shows the kept interval, the guard enforces the kept rules |
|
||
| `LocalStoreTest` | the cache round-tripping, and an unreadable file degrading to "no cache" rather than crash-looping a background service |
|
||
|
||
## 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.
|
||
|
||
* **`<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** — and it survives the process,
|
||
which is what makes that claim true rather than aspirational. The `blocker` half of
|
||
`/api/settings` is written to `filesDir` and re-evaluated with `:core`'s
|
||
`evaluatePolicy` — the same function and the same reason strings as
|
||
`backend/app/services/blocker_service.py`. Without that, a process Android started to
|
||
bind the accessibility service had no rules at all and enforced nothing while
|
||
reporting "disabled".
|
||
* **Enforcement is not the report's cooldown.** A blocked site or app is turned away
|
||
*every* time, debounced only against one visit's duplicate signals; the twenty-second
|
||
cooldown throttles what reaches the daily report. Conflating the two is what let the
|
||
second launch through.
|
||
|
||
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 `:core`'s outbox — coalesced at
|
||
enqueue, replayed in order, at most once, with a post-condition probe instead of a
|
||
blind retry, because none of these endpoints is idempotent. An interval that both
|
||
began *and* ended offline is quarantined and said out loud rather than filed at the
|
||
wrong hour. 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://<lan-ip>: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
|
||
```
|