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>