diff --git a/README.md b/README.md index a759aef..a83e2cc 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/android/README.md b/android/README.md index 9d8f6c1..11e561f 100644 --- a/android/README.md +++ b/android/README.md @@ -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. + +* **`` is mandatory.** `GlyphMatrixManager.init()` binds + `com.nothing.thirdparty/.GlyphService`, and since Android 11 a package not named in + the manifest's `` 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://: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://: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 +``` diff --git a/desktop/kadence_desktop/api.py b/desktop/kadence_desktop/api.py index 4435e40..8dac8cb 100644 --- a/desktop/kadence_desktop/api.py +++ b/desktop/kadence_desktop/api.py @@ -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