docs: the phone client, and the Glyph traps worth not re-learning

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>
This commit is contained in:
timka123andClaude Opus 5 committed 2026-10-19 16:17:25 +03:00
1 parent 535830bacc
commit 738734d485
3 files changed
+84 -19

No files matched your search

+6
View File
@@ -11,6 +11,7 @@ Three pieces, one backend:
| **Backend** | FastAPI + Postgres: the clock, the task mirror, the blocking policy, the reports, the Telegram bot | Docker |
| **Web app** | React + Material UI — the whole thing in a browser, on any device | Docker (same container) |
| **Desktop app** | A **native Qt/QML** app: tray, D-Bus, real notifications, and the part that actually blocks websites and apps | Your machine |
| **Android app** | A native Compose client for the Nothing Phone (3): the interval as an Android 16 Live Update, and the countdown on the rear Glyph Matrix | Your phone |
The server owns the clock. Start a pomodoro on your phone's browser, watch it
count down in the tray, finish it from the desktop — it is one interval.
@@ -212,6 +213,9 @@ and you can fill everything in from **Settings**.
come from a separate, validated data palette: the brand is warm, but a chart
has to stay readable for colour-blind readers in both themes.
* `desktop/` — the native app. See [`desktop/README.md`](desktop/README.md).
* `android/` — the phone client. Everything with interesting behaviour lives in a
plain-JVM `:core` module, so 225 tests run on a laptop with nothing but a JDK. See
[`android/README.md`](android/README.md).
---
@@ -225,6 +229,8 @@ make desktop # run the native app from the checkout
make desktop-install # menu entry + icon
make helper-install # the privileged blocking helper
make report # build and send today's report right now
make android-install # build and install the phone client
make deploy # push master and redeploy kadence.timka.su
```
The backend also has a CLI, handy from cron or a systemd timer:
+69 -18
View File
@@ -18,7 +18,7 @@ even the 25×25 rear display — are **testable on a laptop with nothing but a J
no emulator, no SDK and no phone in the loop:
```bash
make android-test # both modules: 219 tests, no device, no backend
make android-test # both modules: 225 tests, no device, no backend
make android-test-core # just the pure-logic suite: needs only a JDK
```
@@ -31,7 +31,7 @@ command works on a machine that has never seen the Android tooling.
./gradlew :core:test :app:testDebugUnitTest
```
**219 tests, none of which need a device or a running backend.** The backend lives only
**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.
@@ -51,13 +51,14 @@ live server.
| `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):
`: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
@@ -80,12 +81,37 @@ 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](https://github.com/Nothing-Developer-Programme/GlyphMatrix-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.
`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
@@ -110,18 +136,43 @@ tested.
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
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:
```bash
make up # backend + web UI on :8000
ip -4 addr show | grep inet # find the LAN address for the phone
./fetch-glyph-sdk.sh # once, for the rear display
make android-install # ./gradlew :app:installDebug
adb logcat -s Kadence:*
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.
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
```
+9 -1
View File
@@ -51,11 +51,19 @@ class Api:
return json.loads(body) if body else None
except urllib.error.HTTPError as exc:
detail = _detail(exc)
if exc.code == 401:
hint = (
"Set the API token in Settings → Backend."
if self.token
else "No API token configured — add it in Settings → Backend."
)
raise ApiError(f"{detail} {hint}", exc.code) from exc
raise ApiError(detail, exc.code) from exc
except (urllib.error.URLError, TimeoutError) as exc:
raise ApiError(
f"Cannot reach the backend at {self.base_url} ({exc}). "
"Is `docker compose up` running?"
"Check the server URL in Settings → Backend, or is "
"`docker compose up` running locally?"
) from exc
except json.JSONDecodeError as exc:
raise ApiError(f"The backend sent something that is not JSON: {exc}") from exc