Commit Graph
15 Commits
Author SHA1 Message Date
timka123 d280101bce disallow to disable guard during focus
docker / image (push) Failing after 24s
lint / ruff (push) Failing after 28s
tests / backend (push) Successful in 37s
tests / desktop (push) Successful in 22s
tests / android-core (push) Successful in 49s
tests / web (push) Successful in 1m20s
android / apk (push) Successful in 8m49s
2026-10-28 09:50:58 +03:00
timka123 157a113c39 fix timers
lint / ruff (push) Failing after 15s
tests / desktop (push) Successful in 21s
tests / web (push) Successful in 46s
docker / image (push) Failing after 1m31s
tests / android-core (push) Successful in 1m5s
tests / backend (push) Successful in 2m33s
android / apk (push) Successful in 4m34s
2026-10-27 18:52:54 +03:00
timka123 894f132e34 fix ots
docker / image (push) Canceled after 14s
lint / ruff (push) Successful in 14s
tests / backend (push) Canceled after 16s
tests / desktop (push) Canceled after 3s
tests / web (push) Canceled after 1s
tests / android-core (push) Canceled after 0s
android / apk (push) Successful in 4m16s
2026-10-22 21:58:57 +03:00
timka123 1bdd65cc76 implement ota updates
lint / ruff (push) Successful in 14s
tests / desktop (push) Successful in 22s
docker / image (push) Successful in 43s
tests / web (push) Successful in 39s
tests / android-core (push) Successful in 1m1s
tests / backend (push) Successful in 2m12s
android / apk (push) Successful in 6m50s
2026-10-22 21:11:04 +03:00
timka123andClaude Opus 5 81032aca51 android: onboarding is not a wall, and server-owned edits say no offline
A never-connected install could not reach a single screen. Onboarding swapped the
whole shell out until `onboarded`, which only became true after `/api/health`
*and* `/api/timer/state` both answered, and there was no way past. The comment
explaining that argued an app which shows nothing is worse than one that insists
— which was true when nothing survived a process, and is not true now: the timer
ticks from the kept anchor, the guard enforces the kept rules, taps go to the
outbox and the lists show what they last held.

So once an attempt has actually failed *for want of a network* — not on a wrong
address, where stopping here is still right because this is where the message can
explain it — the screen offers "Use it offline for now" and says exactly what
that does and does not do.

The other direction, for honesty: the blocker's rules are the server's settings
document, and there is no outbox for it. A settings PUT is a deep merge of a
shared object, so queueing one would replay a stale version over whatever the web
UI did meanwhile. The "Focus guard on" toggle and "Save lists" are therefore
disabled offline instead of appearing to work and silently failing, which is the
pattern the Settings screen already followed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:47:30 +03:00
timka123andClaude Opus 5 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>
2026-10-22 17:46:12 +03:00
timka123andClaude Opus 5 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>
2026-10-22 17:41:26 +03:00
timka123andClaude Opus 5 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>
2026-10-22 17:35:46 +03:00
timka123andClaude Opus 5 bc6ff3c489 ci: gitea actions for the suites, the image and the APK
Four workflows, none of which existed:

* `tests.yml` — backend, desktop, `:core` and the web app's typecheck+build, in
  parallel. `:core` needs no Android SDK at all, because settings.gradle.kts
  leaves `:app` out when there is none; `:app`'s Robolectric suite therefore
  lives in the APK workflow, where the SDK is installed anyway. Nothing needs a
  running backend — the suites feed on committed fixtures, which is the whole
  reason they were written that way.
* `lint.yml` — ruff, which the repo had been configured for and never run.
* `docker.yml` — builds the image, *starts* it against SQLite and waits for
  /api/health before pushing, then publishes `:latest` and `:<short-sha>` to
  git.timka.su. A tag nobody can run is worse than no tag.
* `android-release.yml` — the release APK on every push to master, signed from
  secrets, with `apksigner verify --print-certs` reporting which key was really
  used and failing if it was the debug one.

Three things the repo needed before any of that could work:

`versionCode` was pinned at 1, so every APK looked like the same version to the
phone and would not install over its predecessor. It now comes from the run
number, with `versionName` from a property, both via one `setting()` helper that
reads a Gradle property or an environment variable.

The release build fell back to the *debug* signing key whenever the keystore was
missing — silently. That is a trap: the APK installs, looks fine, and can never
be upgraded by a properly signed build. `KADENCE_REQUIRE_SIGNING=1` makes that
case fail the build instead, and the release job sets it whenever a keystore
secret is present.

The compose files built an image with no name, so there was nothing to pull.
`image:` now names the registry tag while `up --build` still builds locally.

Also: `android/ci/install-sdk.sh` (idempotent, cacheable, and the one place that
knows `platforms;android-37.0` is spelled with the minor version), the runner as
a compose service with its config and a setup checklist, six make targets, and a
README section. No runner is registered yet — `make ci-runner` is step two of
that checklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-22 17:33:27 +03:00
timka123andClaude Opus 5 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>
2026-10-22 17:22:12 +03:00
timka123andClaude Opus 5 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>
2026-10-22 17:16:56 +03:00
timka123 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.
2026-10-19 18:45:50 +03:00
timka123andClaude Opus 5 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>
2026-10-19 16:16:30 +03:00
timka123andClaude Opus 5 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>
2026-10-19 16:05:13 +03:00
timka123 1e55144291 some backend fixes and add android app 2026-10-19 14:52:20 +03:00