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:
3 files changed
+84
-19
No files matched your search
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user