diff --git a/README.md b/README.md index 7d8bb62..f9e76e6 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ and you can fill everything in from **Settings**. * `backend/` — FastAPI app. `app/services/` holds the thinking (timer, tasks, stats, report, blocker policy, scheduler, bot); `app/sources/` holds one module - per integration; `app/api/` is a thin HTTP layer. 71 tests under `backend/tests`. + per integration; `app/api/` is a thin HTTP layer. 73 tests under `backend/tests`. * `frontend/` — React + TypeScript + MUI, built by Vite into the backend image and served from the same origin (so there is no CORS to configure). * **The look** — warm paper, ink type, hairline rules instead of boxes and @@ -228,7 +228,7 @@ and you can fill everything in from **Settings**. enforcement. There is deliberately no window with pages in it — lists, rules and reports are the web app's job. See [`desktop/README.md`](desktop/README.md). * `android/` — the phone client. Everything with interesting behaviour lives in a - plain-JVM `:core` module, so 234 tests run on a laptop with nothing but a JDK. See + plain-JVM `:core` module, so 274 tests run on a laptop with nothing but a JDK. See [`android/README.md`](android/README.md). --- @@ -270,10 +270,10 @@ Gitea Actions, in `.gitea/workflows/`: | Workflow | When | What | | --- | --- | --- | -| `test.yml` | push to master, every PR | backend (73), desktop (47), `:core` (196), and the web app's typecheck + build — four parallel jobs, none of which may need a running backend | +| `test.yml` | push to master, every PR | backend (73), desktop (47), `:core` (193), and the web app's typecheck + build — four parallel jobs, none of which may need a running backend | | `lint.yml` | push to master, every PR | `ruff check` over `backend/` and `desktop/`, with the rule set named in `backend/pyproject.toml` | | `docker.yml` | push to master, `v*` tags | builds the image, starts it against SQLite and waits for `/api/health`, then pushes `git.timka.su/timka123/kadence:latest` and `:` | -| `android-release.yml` | push to master, `v*` tags | `:app`'s Robolectric suite (44), then the release APK — signed from secrets, with `apksigner verify` printing the certificate it actually used | +| `android-release.yml` | push to master, `v*` tags | `:app`'s suite (81, on the JVM), then the release APK — signed from secrets, with `apksigner verify` printing the certificate it actually used | **Nothing runs until a runner is registered.** `docker-compose.ci.yml` is the whole of that, and its header is the setup checklist: a registration token from diff --git a/android/README.md b/android/README.md index 1da74b7..bfc43b4 100644 --- a/android/README.md +++ b/android/README.md @@ -10,7 +10,7 @@ the rear Glyph Matrix. | 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 | +| **`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, @@ -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: 234 tests, no device, no backend +make android-test # both modules: 274 tests, no device, no backend make android-test-core # just the pure-logic suite: needs only a JDK ``` @@ -31,12 +31,12 @@ command works on a machine that has never seen the Android tooling. ./gradlew :core:test :app:testDebugUnitTest ``` -**234 tests, none of which need a device or a running backend.** The backend lives only +**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` (190 tests): +`:core` (193 tests): | Suite | What it pins down | | --- | --- | @@ -51,7 +51,7 @@ 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` (44 tests, on the JVM via Robolectric): +`:app` (81 tests, on the JVM via Robolectric): | Suite | What it pins down | | --- | --- | @@ -60,6 +60,12 @@ live server. | `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 @@ -131,9 +137,17 @@ user's always-on VPN: 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.** The `blocker` half of - `/api/settings` is kept and re-evaluated with `:core`'s `evaluatePolicy` — the same - function and the same reason strings as `backend/app/services/blocker_service.py`. +* **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: @@ -162,8 +176,11 @@ Two honest limitations follow: 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. + 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