a8c84c65da567c7eb390a8918a345f1f03bde5c3
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a8c84c65da
|
android: offline writes go to the outbox that was already written
`:core/Outbox.kt` is 245 lines and 29 tests of careful thinking — what coalesces with what, which post-condition proves an ambiguous send landed, which intervals cannot be filed honestly at all — and nothing in `app/` had ever called it. Three comments pointed at it as though it were wired up. A tap with no signal produced one banner and was gone, after a ten-second connect timeout taken *under the command mutex*, so a handful of taps was most of a minute of frozen UI and then a stack of identical banners. Now every command has a queued form. `Reachability` (ConnectivityManager, which the manifest has had permission for since the start and never used) is asked before the network is, so an offline tap is answered instantly; a request that fails as NetworkError anyway lands in the same place one timeout later. The optimistic state stays on screen, the local anchor keeps ticking, and the snapshot is cached — so an interval started with no signal survives the process being killed. `NET_CAPABILITY_VALIDATED` is deliberately not required: this backend is routinely a LAN address, and demanding validation would call the one setup the README recommends unreachable. Replay runs on the next successful poll and the moment a network appears. In order, stopping at the first thing that cannot go; at most once, with the state re-read and probed rather than resent, because none of these endpoints is idempotent; a 4xx dropped rather than retried eight times into a stuck queue. An interval that both began and ended offline is quarantined and *said out loud* — replaying it would stamp `started_at` as the reconnect moment and record a pomodoro of no duration, quietly corrupting the statistics the app exists to produce. The poll finally declares the queue as `PendingOps`, which is what that type was written for: a poll racing an unsent intent now keeps the dial still instead of bouncing it. The offline banner stops saying only that the countdown keeps running — the sentence that made the dead buttons look intentional. 7 tests: an offline pause that never touches the network, the queue going out on reconnect, two `+5`s coalescing into ten minutes, pause-then-resume annihilating, the unfilable interval being reported, and an offline interval surviving a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
120c553537
|
android: keep what the server said, on disk
Everything the backend owns lived in memory, which made every claim about working offline true for exactly as long as the process. Android kills processes constantly, and starts them again for the accessibility service with no Activity to have fetched anything — so the common case was the unhandled one: * the focus guard woke with empty matchers, enforced nothing, and reported "disabled", which is indistinguishable from the user switching it off; * the dial read 00:00 until a reply landed, and with no signal, forever; * Tasks and Today showed an error banner where yesterday's answer would do. `LocalStore` is one small JSON file per subject under `filesDir` — not Room (no queries, no relations, no migrations worth the dependency) and not SharedPreferences (those are for what this *machine* owns; these are documents). Every read degrades to "no cache" rather than throwing, and a file that fails to parse is deleted on the way past, because a background service must not be crash-looped by its own cache. The timer state is stamped with the wall-clock moment it was received, which is what lets a cold start subtract the time that passed while nothing was running — wall clock deliberately, since `nanoTime` does not survive a reboot and the question is how much of the *day* went by. A paused interval is left where it was. None of it is marked Online: a cache is not a reply. The guard keeps the rules and the last policy. The verdict is not adopted from disk — `active` is a claim about this moment — but the expanded host list and the masks do not go stale, so the guard can enforce before the first poll. Read-only screens take a cache slot, so `Loader` paints the last good answer before it asks. Changing the backend URL clears the lot: one server's report under another's name is worse than an empty screen. 10 tests, including the two cold-start cases that failed before: a dial that knows what is running, and a guard that still blocks with the server gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
71a0389029
|
android: a command's reply is the answer, not a conflict
Half of "the buttons do nothing" happens with the network working perfectly. `command()` passed `PendingOps(hasAny = true, …)` into `applyServer`, and `PendingOps` is the *outbox's* vocabulary — it means "something of ours is queued and has not landed". For a direct reply that is simply untrue, and because the reply is for the interval already running, `reconcile` answered `KeepOptimistic` and `applyServer` returned early, discarding the server's status, remaining and planned seconds. Pause, resume, +5 min, Interrupted and attach-task therefore changed nothing until the next poll, up to ten seconds later. `toggle()` read the stale state, so the second tap fired another pause. Two changes. `KeepOptimistic` now short-circuits only for *polls* — a poll racing a queued intent is a real conflict and still keeps the dial still. And the commands whose effect is knowable without asking apply it on the tap, then let the server's reply overwrite it: paused, resumed, extendedBy, one more interruption. Three small extension functions, deliberately a preview of the server's answer rather than a second implementation of the timer. `toggle()` returns its Job now, so the Glyph toy's long press, the Live Update's buttons and the tests can all wait for the reply. 5 tests, on MockWebServer and the committed fixtures. The first of them fails on the old code in the obvious way: the reply says paused, the UI says running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b9c0762f23
|
android: the website guard sees what it promised to see
Four defects in the capture-and-match path, each able to look like "the blocker works incorrectly" on its own. Masks were unenforced exactly while online. The online matcher was built with `blockedDomains = dto.allDomains`, and `all_domains` is concrete hostnames only — a mask is precisely what the backend could *not* expand, and it comes back in `unexpandable`. Overwriting the raw entries emptied `DomainMatcher.rawEntries`, so step 3 of `decide()` — the step a hosts file cannot do — was dead code with the network up. `*gram*` blocked only offline, while the Guard screen and the README both said it worked on the phone. The rules now go in unchanged, with `all_domains` as `expandedHosts`, and `unexpandable` + a newly-read `allowed` folded in so masks and exceptions survive a process that has never read the settings document. The event-text path never matched anything. `event.text` is a list and was joined with a space before being handed over; `normalizeHost` rejects anything containing a space, which is correct of it and left the as-you-type capture dead for every event carrying more than one item. Each item is offered on its own now. Any editable node counted as an address bar — including every text field the *page* renders. Typing a blocked hostname into a search box, a form or a chat ejected you to the launcher, which is worse than a miss: it throws you off a page you are allowed to be on. An unlabelled editable node is now believed only near the top of the tree, a node whose view id names a URL field is believed anywhere and ends the walk, and web-content containers are not descended into at all — which also stops a content-heavy tab from spending the 250-node budget before the toolbar is reached. The guard armed up to 45 seconds late. `active` only changed on the blocker's own poll, which is 45s apart while idle — the exact gap between tapping "Start focus" and opening the thing you blocked. The application now subscribes the guard to the timer, and `onTimerChanged()` applies `:core`'s local verdict at once while the server is asked in the background. The rules are also read once per `start()` rather than only from MainActivity and the Guard screen. 9 new tests. The per-node judgement moved out of the traversal to be reachable from them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b7b50bc8e
|
blockers: the report's cooldown was deciding whether to block
Both clients made the same mistake, in the same shape: the throttle that keeps the daily report from filling with one row per poll was placed in front of the enforcement call. So blocking worked exactly once per target, and the second launch of a banned app — or the second visit to a banned site — simply went through. Android, `BlockerEngine`: `claim()` now gates only `report()`/`announce()`. Enforcement runs on every decision behind a 1.2s debounce whose only job is to stop the accessibility event and the usage-stats poll both pressing home for one visit. An app still in front on the next pass is pressed again — one GLOBAL_ACTION_HOME is a request, not a result — and a visit is no longer consumed when no accessibility service is attached to consume it with, which turned a missing grant into a silently unblocked app. `lastForeground` and `lastBlockedApp` are @Volatile like every other field here; they are written from the service's main thread and the poll coroutine both. The usage-stats cadence now follows the server's `app_poll_seconds`, and the first look happens at once rather than four seconds in. Linux, `BlockerManager._enforce_apps`: the pid went into the dedupe set *before* the kill was attempted, so a process whose signal was refused was exempt for the rest of the interval, and a pid is reused — a relaunch landing on a recycled number inherited the exemption. Processes are now identified by (pid, start time) from /proc/<pid>/stat, the kill is attempted on every pass, and only the report is throttled. The books are pruned of dead processes so an all-day interval cannot leak. `processes.terminate()` no longer busy-waits two seconds for the process to die before escalating — on the desktop that wait ran on the thread drawing the countdown. SIGTERM now, SIGKILL on a later pass once the grace has gone by, which is also gentler than the old fixed two seconds. With nothing blocking, the scan gets its own timer at the cadence the server asks for instead of riding the five-second policy poll. Neither enforcement path had any test: the desktop's blocker suite hardcoded an empty app list and nothing anywhere touched BlockerEngine. 16 new tests, on fakes and the committed fixtures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
50e9c68b2f
|
android: the focus guard on the phone itself
The Guard screen shows the shared policy; two Android facilities enforce it. An accessibility service reads the browser's URL bar (no /etc/hosts exists, and the VPN slot belongs to the user's always-on VPN), sends blocked pages home and records the attempt; UsageStatsManager spots blocked apps in front and the service presses home — 'kill' cannot mean force-stop, and the KDoc says so. Masks mean what they mean on every client: a bare '*' in the expansion list makes '*.site' cover every subdomain, and masks no hosts file can express are matched as ordinary globs against every URL, so they work here even where the desktop cannot route them. Offline, the kept blocker settings are re-evaluated with :core's evaluatePolicy, so losing signal does not lose the guard. Two limits are documented rather than papered over: in-app WebViews are invisible to the URL-bar reader, and blocking degrades to reporting without the service. Core keeps the policy/matcher semantics testable on a plain JVM: 190 core and 44 app tests. The screen's copy says junk labels are ignored, a '*' is kept, and unroutable masks are enforced on this phone only. |
||
|
|
535830bacc
|
android: two reasons the Glyph stayed dark
Both silent, and each alone was enough to leave the matrix blank while everything else in the app worked. **1. `setAppMatrixFrame` does nothing on this firmware.** The developer kit is explicit that app-driven control must use the `App` variants, so that is what the first cut used. On Phone (3) build 2608141733 the call is accepted without an exception and then dropped: it forwards to `IGlyphService.setAppMatrixColors` and the system service never reaches the hardware. Measured over 20 seconds of 1 Hz pushes: `setAppMatrixFrame` produced 0 writes to the matrix light, `setMatrixFrame` produced 232. Both paths now use the plain variant. The kit's warning that it can fight a selected Glyph Toy is real, but a rear display that never lights is the worse failure. **2. `getDeviceMatrixLength()` returns the side, not the dot count.** The name invites the opposite reading, and the SDK settles it: `DEVICE_23112_MATRIX_LENGTH` is 25 and `DEVICE_23112_SIZE` is that squared. Taking a square root of 25 gave a 5x5 frame, so the renderer drew a few stray dots in one corner and `GlyphFrame.set` silently discarded everything outside the grid — 7 lit dots instead of 78. Also: `clear()` blanks by drawing an empty frame rather than calling `closeAppMatrix()`, which was closing a channel we no longer open and so left the last frame lit. Verified on the device: 78 of 625 dots for a running interval, 234 hardware writes in 20s, the frame rendering as minutes over seconds inside a draining ring. 225 tests still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a7148b69c6
|
android: the real client — backend, notifications, Live Update, Glyph
The app module was a design gallery: tokens, type and a dial, with no backend, no notification and nothing driving the rear matrix. This is the rest of it. **One engine, three views.** `TimerEngine` lives on the Application, not in a ViewModel, because the Compose screen, the foreground service's notification and the Glyph must show the *same* second. Two engines polling the same backend drift apart within seconds and the user sees a notification disagreeing with the dial. It polls, ticks from a monotonic anchor, and puts every reply through `:core`'s `reconcile`. `pomodoro.state()` does not finish an interval whose time is up — it reports `remaining_seconds = 0` and waits for a client to call `complete`. The tick loop does, behind the same re-entry guard `desktop/controller.py` uses, since without one a 1 Hz tick at zero fires `complete` every second until the reply lands. **Notifications.** Two channels, because the running interval must be silent and its *end* must not; one channel cannot be both, and a user who silences a buzzing countdown has silenced the only part that mattered. The ongoing one is an Android 16 Live Update: promotion needs `setOngoing` + `setRequestPromotedOngoing` + a `ProgressStyle`, and missing any one renders it as an ordinary notification with no error and no log. Its segments come from `:core`'s `dialTickCount`, so the status bar, the dial and the Glyph ring agree on what one tick is worth. **Glyph.** `GlyphMatrixManager.init()` binds `com.nothing.thirdparty/.GlyphService` and *discards* the boolean `bindService` returns — so on Android 11+ a missing `<queries>` entry makes the package invisible, the bind silently fail, and the matrix stay dark with no exception, no log and `onServiceConnected` never arriving. That was the whole of "the glyph does not work". The manifest now declares it and `MatrixGlyphDisplay` checks visibility itself rather than leaving the next person with the same dead end. The SDK's licence forbids redistribution (clause 2.1(c)), so the .aar stays git-ignored and `fetch-glyph-sdk.sh` downloads it. Without it a second source set compiles a no-op display: the app builds, installs and runs with an inert rear panel, which is also the correct behaviour on any phone that is not a Nothing. Verified on the device (Phone (3), Android 16), debug and release: onboarding against kadence.timka.su, the Live Update with `shortCriticalText` and a 600s progress track, the matrix accepting 625-dot frames, tasks opening in the Jira app, and R8 leaving the DTOs decodable. 225 tests, none needing a device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1e55144291
|
some backend fixes and add android app |