The android README still described the app as unbuilt and pointed `glyph/libs/` at a path that does not exist. It now covers what the app is made of, how to get the SDK, and the three Glyph behaviours that cost an afternoon — two of which fail with no exception and no log at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
9.5 KiB
Markdown
179 lines
9.5 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, 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.
|
||
|
||
* **`<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.
|
||
|
||
## 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://<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
|
||
```
|