Commit Graph

10 Commits

Author SHA1 Message Date
47e05d1dca focus_owner: split status_page_state.dart under the 250-line cap
Extracts the two AlertDialog builders and the build() body-selection
logic into a new status_page_dialogs.dart part file, following the
same part-of-main.dart split pattern status_body.dart already used.
Pure extraction, no behavior change -- verified with dart analyze and
the full flutter test suite (94/94 passing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-18 23:38:09 +02:00
d9982e53dd Split the focus_owner Dart files under the cap
main.dart 538 -> 130, policy.dart 272 -> 85, enforcement_status_card.dart
268 -> 145, plus both test files.

Every lib split uses part/part of rather than import. The first attempt
used import+export and the analyzer gave 30 errors like "The name
'_Card' isn't a class": Dart privates are visible to the whole LIBRARY,
and a separate file is a separate library unless it is a part.

The test splits separated groups from the testWidgets() calls that had
been initialising the binding implicitly, so four tests failed with
"Binding has not yet been initialized" - both halves now call
ensureInitialized() explicitly.

94 tests passing before and after; flutter analyze reports no issues.
focus_owner/analysis_options.yaml has an uncommitted change from an
earlier session and is deliberately left unstaged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 20:39:13 +02:00
01778d4f05 Put focus_owner on the shared palette, and stop curfew hiding todo
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
focus_owner had drifted entirely off the frozen palette -- a cool blue
#5B9DD9 accent on a #1B1D21 field -- while its own doc comment claimed
it matched the other com.kuhy.* apps. It did not: every other app is on
the gold #B8862E. lib/theme.dart now aliases design_system-v0.1.0, so
the ~80 call sites keep reading the same names but the values come from
one place and cannot drift again. The six constants duplicated verbatim
in main.dart are deleted -- the old comment said they had been "lifted
out" into theme.dart, but the originals were never removed, so there
were two copies of a wrong palette.

Separately: dev.kuhy.todo was in the day allowlist but not the night
one, so a release build installed at 23:07 was hidden by the enforcer
~80ms later. It is now in night_allowed_packages. That is a deliberate
loosening of the curfew's "answer the phone, reach a bank, handle an
emergency" rule -- writing an idea down at night is the one thing that
app does -- and it is recorded as such in both config files.

Deploying the shell mirror of that list surfaced two pre-existing latent
bugs in phone_focus_mode/config.sh: comment lines inside the
double-quoted WHITELIST string referenced the night list (defined ~70
lines below) and an ALWAYS_BLOCKED that is never defined anywhere, so
under set -u the entire deploy aborted with "unbound variable". Both are
now written plainly, with a note saying why a dollar sign or a double
quote must never appear inside that string.

Verified on the Pixel 6a: analyze clean, 94/94 tests, colour literals
15 -> 2 (both Colors.transparent sentinels), and an ad-hoc assertion
confirming kAccent is really ffb8862e rather than merely compiling. The
installed APK shows CURFEW and the action links in gold, "Allowed apps
29 (16 at night)" (was 15) and "restored 1"; todo then launched and
rendered at 23:19, inside the curfew window, with all 108 notes intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:21:06 +02:00
82e206116a Make the geofence tell the truth, and the phone explain itself
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The enforcer failed in both directions: nothing blocked at home, and
everything blocked at the office. The comparison was never inverted --
`metres <= threshold` was always right. Three separate causes:

Only ACCESS_COARSE_LOCATION was declared. Android 12+ fuzzes coarse to a
~1-2 km grid against a 150 m fence, so a fix taken at home routinely landed
outside it, read as AWAY, and restored every app the geofence exists to hide.
FINE is now declared and self-granted by the device owner, along with coarse
and background, re-asserted every pass so a reinstall cannot silently drop it.

There were three states but two behaviours. LOCATION_UNKNOWN applies the
identical sweep to AT_HOME -- correct fail-closed design, indistinguishable
from outside. And nothing ever requested a fix: the pass read whatever another
app happened to cache, so at the office with no navigation running the cache
was empty. Passes now acquire a fix inside a location-typed foreground
service, bounded at 20 s, and treat anything older than 30 min as no fix. That
window is deliberately longer than the 15-minute cadence; a shorter one marks
the newest cache stale on every pass of an idle phone and blocks everything.

None of it was diagnosable. `adb logcat -d -s FocusOwner:V` came back empty
while dumpsys showed 82 alarms had fired, and `run-as` is refused on the
release build device owner requires. So the pass now writes a ring-buffered
JSONL record read back in-app: reason, distance vs the fence, fix
age/provider/accuracy, curfew state, and every hidden app with the rule that
hid it. Coordinates are never recorded -- a distance is enough to debug a
geofence, and this is a home address.

Also fixed along the way:

- Hysteresis keyed off `isEnforcing`, which is true under AWAY too because
  YouTube stays hidden, so the threshold was permanently radius+hysteresis.
  It now keys off whether the previous pass was inside the fence.
- The scheduler's curfew-boundary alarms were documented but never scheduled;
  both callers invoked `scheduleNext()` bare, so 05:00 could land 15 min late.
- Installing over the app cancelled its pending alarms, so `adb install -r`
  silently stopped enforcement until someone opened the app. MY_PACKAGE_REPLACED
  now re-arms it, verified on device without launching the app.
- A wrong-typed log record threw instead of being skipped, and the exception
  escaped far enough to leave the page stuck busy -- which disables the
  release-device-owner button, the one control that must work when everything
  else has gone wrong.
- `lib/enforcement.dart` was a second decision layer with no always-blocked
  concept, cited by KDoc as pinned by a `PolicyParityTest` that never existed.
  It would have made the new screen report YouTube visible while it is hidden.
  Deleted; the UI renders the Kotlin record rather than re-deciding.
- Anchoring home accepted a stale or unknown-precision fix. A wrong home
  offsets the fence everywhere and forever, which is what produced a 766 m
  reading while standing at home.

Policy: Play Store is allowed by day (not during curfew) so infakt can be
installed and updated; the sweep is default-deny for third-party packages, so
anything installed from Play is hidden on the next at-home pass. Tachiyomi's
per-source extension apks are covered by a new prefix allowlist rather than an
exact list that goes stale whenever an extension is installed.

`export.py` stopped subtracting the allowlist from `blockable_system_packages`.
Sweepable is not hideable: that list decides which system packages are
eligible for a decision, and `isAllowed` is what protects them. Removing an
allowed system package from the sweep freezes it in its last state -- it would
have stranded Play hidden permanently the moment it was allowlisted.

Verified on the Pixel 6a: FINE granted POLICY_FIXED, `applied AT_HOME` with a
0 m distance from a +/-11 m GPS fix, YouTube/Chrome/Firefox absent from
`pm list packages -e`, Play Store, infakt, RunnerUp and all five Tachiyomi
packages present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjxL9kdAzZmGqQA5tMNrZ8
2026-08-14 13:43:05 +02:00
6836a32b43 Let the app set its own home, and lock VPN configuration
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Two gaps left from the device owner work.

The geofence had no way to be provisioned. push_home_location.sh stages
the file through `run-as`, which returns "package not debuggable" on the
release build device owner requires, and making the build debuggable to
fix that would hand anyone with adb the ability to edit this app's state
-- the bypass the whole design exists to close. So the app writes its
own filesDir instead, from a button.

Deliberately not an exported intent: anything that can set home from
outside the app can set it somewhere else, and the geofence would then
report AWAY forever with enforcement off. The coordinates are never
logged either, since that is a home address and logcat is readable by
anyone with adb.

DISALLOW_CONFIG_VPN is now applied from the app, after the filter was
confirmed working rather than before -- locking VPN configuration over a
broken setup would pin the broken state in place. The open question was
whether it also freezes RethinkDNS's own settings; it does not. Its
blocklist selection was changed under the restriction without trouble,
which is the answer that matters, because the alternative meant every
domain rule had to be final before locking.

The status page gained a Geofence row. "no home set - enforcing
everywhere" is a large behavioural difference from a working geofence
and was previously visible only by inspecting logcat.

The widget tests caught a real bug rather than a test-only one: the two
new buttons overflowed the column by 4px, and the control that must
never be clipped off the bottom of the screen is "Release device owner".
The body scrolls now.

Verified after a reboot: youtube hidden, all 14 youtube domains at
127.0.0.1, google/gmail/play resolving normally, no_config_vpn still
enforced, uninstall still refused. The geofence itself stays untested on
hardware until the phone has a GPS fix to record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
2026-08-10 22:17:31 +02:00
cc804dd311 Run enforcement from exact alarms, and re-arm it before first unlock
The decision layer needed a runtime. A polling loop was the obvious design and
the wrong one: Doze can skip a poll indefinitely, and skipping the 05:00
boundary leaves the curfew allowlist applied into the morning -- the user finds
their banking app missing with no obvious cause. The transitions are known
ahead of time, so they get exact alarms. The service wakes, applies, schedules
the next run and stops rather than sitting resident.

BootReceiver takes LOCKED_BOOT_COMPLETED as well as BOOT_COMPLETED, because the
latter does not arrive until first unlock. That is not theoretical: this phone
sat at RUNNING_LOCKED for minutes after an earlier reboot in this session, with
no network and no app able to start. Relying on BOOT_COMPLETED alone freezes
enforcement state across a reboot -- safe for hiding, not for unhiding, which is
the direction that strands you without a dialer. The schedule is re-armed
before unlock; the pass itself waits until storage is readable.

The first on-device run logged "exact alarms not permitted", and dumpsys showed
what that costs: window=+11m14s, maxWhenElapsed=+25m51s, so a 15-minute
fallback could slip to 26 minutes. SCHEDULE_EXACT_ALARM is user-revocable and
denied by default on Android 16; USE_EXACT_ALARM is the permission for apps
whose function is acting at specific times, and is granted at install. After
the change: window=0, exactAllowReason=policy_permission, whenElapsed equal to
maxWhenElapsed.

The service reports "not DO/PO - no enforcement applied" and does nothing when
unprovisioned, rather than failing quietly, and a failure inside a pass cannot
stop the next run being scheduled -- an enforcement bug should degrade one tick,
not end the chain.

Not yet wired: the service logs a tick rather than calling setApplicationHidden
with the decision. Both halves are tested; joining them is next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-09 21:50:58 +02:00
d0fcda3a47 Provision home coordinates out of git; layer the workout signal; drop the test hook
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
The geofence needs HOME_LAT/HOME_LON, but those say where I live, so the
committed policy asset is generated with --redact-home and the real values are
pushed to app-private storage instead. push_home_location.sh greps
config_secrets.sh rather than sourcing it -- that file holds other secrets and
this script has no business evaluating them -- and refuses the shipped
REDACTED_* placeholder with a message naming the file to fill in, rather than
writing whatever the parse happened to land on.

Private storage was chosen for its lifetime as much as its permissions: it does
not survive an uninstall, so reinstalling forces re-provisioning instead of
silently inheriting a stale home. The default umask left the file 666, so the
script chmods it 600; the shell user gets Permission denied, and the
/data/local/tmp staging copy is removed after the transfer.

Every read failure returns null rather than throwing. Null reaches the
enforcement layer as location-unknown, which already fails closed into
enforcing -- so a corrupt coordinates file cannot become an unlock, and cannot
crash the service either.

Workout detection replaces a root-only SQLite read of StrongLifts' private
database. The manual source is time-boxed rather than a plain toggle, because a
workout mode left on by accident is indistinguishable from having no exception
at all. The guarded wrapper catches broadly and times out: nothing about a
workout probe failing should be able to stop enforcement running. The combined
source can name which signal opened the exception, so the UI can explain an
unlock rather than presenting it as magic. Only the two sources verified to
exist on this device will be wired -- com.kuhy.workout_app exposes nothing
readable and org.runnerup is not installed.

HideTestReceiver is deleted. It proved setApplicationHidden persistence, which
is what it was for, but it applied policy from an exported receiver and that has
no place in a build that will later hold device owner. Confirmed absent from the
rebuilt and reinstalled APK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-09 21:42:20 +02:00
1b7958143d Prove DPM app-hiding survives a reboot; build the enforcement decision layer
The whole Device Owner design assumed setApplicationHidden persists across a
reboot, and that had never been tested -- while both shell primitives were
measured to be cleared by one. Settling it looked like it needed a factory
reset. It does not: a managed profile is removable and takes its profile-owner
grant with it, so the DPM API can be exercised and then erased.

  pm create-user --profileOf 0 --managed   -> user 10
  dpm set-profile-owner --user 10 ...
  ... hide, reboot, re-check ...
  pm remove-user 10

hidden=true before the reboot, hidden=true after, agreed by dumpsys and by the
DPM API itself. Afterwards: Device Owner Type -1, no restrictions, and Chrome
in user 0 untouched.

Scope limit, stated because it is easy to overclaim: this proves the API's
hidden state persists under PROFILE OWNER inside a work profile. It does not
prove a DEVICE OWNER can hide a given app in user 0, which is what the design
actually needs -- DO there additionally cannot touch the launcher, the default
dialer/SMS/IME or packages the system deems critical, and the call can return
false silently. That must be tested in the first minute after provisioning,
while clearDeviceOwnerApp() is still a cheap exit.

On top of that, EnforcementDecision turns policy plus conditions into a
hide/show set. It is a pure function, so the whole decision layer is tested
without a device, a clock or a location provider.

packagesToShow is deliberately the complete allowed set rather than a delta.
Failing to hide something is a mild disappointment; failing to UNHIDE strands
the user without a dialer or a bank at 09:00 because the phone rebooted during
curfew. Applying the full set on every evaluation makes a missed run
self-repairing, which matters because BOOT_COMPLETED does not fire until first
unlock -- observed directly, the phone sat at RUNNING_LOCKED for minutes after
a reboot with no network at all.

The workout exception is expressed as packages rather than domains. The rooted
system unblocked youtube.com in a hosts file; hiding an app cannot target a
domain, so the package set is derived from the configured domains and config.sh
stays the source of truth for intent.

Signal sources were checked on the device before being built: StrongLifts is
granted health.WRITE_EXERCISE and Health Connect is installed, so that path is
viable; com.kuhy.workout_app exposes only a MainActivity and Flutter
boilerplate; org.runnerup is not installed here at all. Only the two that can
actually produce a signal will be wired.

HideTestReceiver is temporary and must be deleted before any provisioned build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-09 21:36:26 +02:00
e3d5e1dd18 Serve the blocklist over DoT and hand the policy to the app as an asset
Two gaps stopped the unrooted path working end to end. The phone had no way to
read the focus policy, and the blocklist was only reachable as a hosts file or
over plain UDP/53 on the LAN -- while the rooted enforcer actively rejected
every DoT connection, so a self-hosted resolver could not have worked even if
one existed.

focus_owner now loads a generated policy.json instead of hardcoding anything,
so config.sh stays the single source of truth across Python, shell and Kotlin.
The committed asset is rendered with --redact-home and a test fails if home
coordinates ever appear in it. An unknown schema_version is rejected outright:
silently accepting a newer schema means enforcing a policy the app has misread,
and a misread allowlist is one that blocks the dialer.

dns_enforcer.sh rejected ALL port 853 traffic and pinned Private DNS off, every
20 seconds. That is right when DoT is a bypass channel and exactly wrong when
DoT is the enforcement channel, which is what an unrooted phone needs -- it has
no hosts file to fall back to. With DNS_TRUSTED_DOT_HOST set it now ACCEPTs the
trusted resolver before the blanket REJECT, and pins Private DNS to that
hostname so the user cannot pick an unfiltered one. Rule order is load-bearing:
iptables takes the first match, so an ACCEPT appended after the REJECT would
never be reached. expected_rule_count() counts the new rules, because a
mismatch there makes chain_intact() rebuild the chain every 20s -- a silent
fork storm that also drops the connection each time. With the variable empty
the behaviour is byte-identical, so the rooted phone is untouched until this is
deployed.

setup_dot_resolver.sh terminates DoT with stunnel rather than Caddy: DoT is a
raw TLS stream and stock caddy:2.8 has no layer4 module, so using Caddy would
have meant an xcaddy rebuild of a container serving five live sites. It reuses
the certificate Caddy already renews, so nothing competes for port 80. It binds
to the WireGuard address, not 0.0.0.0 -- a public DoT endpoint is an open
resolver, and over the existing tunnel it needs no port-forward, no firewall
rule and no rate limiting. Verified: example.com resolves, pornhub.com returns
0.0.0.0, TLS 1.3 against the LE cert, and both the LAN and public addresses
refuse.

The Device Owner checklist is planning only. Checking the device rather than
the config settled the one open risk: Google Wallet is not installed and no NFC
payment default is set, so the unconfirmable tap-to-pay question does not apply
here. The same check found that Aegis, MS Authenticator, Oracle IDM and PKO IKO
are absent -- they are in config.sh's whitelist, which describes the rooted
phone, not this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-05 09:36:11 +02:00
f636d2b21b Make focus policy and blocklist usable without root; fix workout CDN unblock
phone_focus_mode is built for a rooted phone: the hosts layer is a read-only
bind mount over /system/etc/hosts, chosen so that even root-in-a-terminal
cannot overwrite it. None of that ports to a stock locked device. This splits
out the parts that are device-independent so one policy can drive both.

python_pkg/focus_policy parses config.sh rather than copying it, so the shell
config stays the source of truth and the two cannot drift. It rejects the
shipped REDACTED_* coordinate placeholder instead of defaulting to (0,0),
which would move "home" to the Atlantic and quietly disable the location gate.

blocklist.py converts the hosts file to domains for a DoT resolver or a local
VpnService. Three properties of the real 194k-line file make the obvious parser
wrong, and all three are handled: generate_hosts_file.sh *comments out*
deliberate unblocks rather than deleting them, so stripping '#' re-blocks
facebook/messenger/linkedin/4chan; the StevenBlack header carries loopback and
IPv6 housekeeping that would blackhole localhost; and ~1060 upstream annotation
tokens ('#[adware.zeno]', '"spam') render as address=/"spam/, which makes
dnsmasq refuse to start -- taking DNS down rather than blocking anything.
Verified with dnsmasq --test over all 185681 generated domains.

strip_workout_hosts.py had the same class of bug on the live rooted phone:
exact set membership meant the allowlisted googlevideo.com never matched the
dynamic r*---sn-*.googlevideo.com hosts YouTube actually streams from, so the
workout unblock was only partly working. Now suffix-matched (17 lines stripped,
was 14). The three new tests were checked against the original code in a
throwaway worktree; two of them fail there, so they pin the fix rather than
passing vacuously.

focus_owner is a device-owner app that deliberately does nothing yet. It ships
clearDeviceOwnerApp() implemented and tested first, because dpm
remove-active-admin does not work on a device owner: an app that locks the
device down before proving it can let go leaves a soft-brick recoverable only
through fastboot on a locked bootloader. Built, installed and launched on the
Pixel 6a; dumpsys afterwards confirms Device Owner Type -1 and no user
restrictions, i.e. the phone is unchanged.

Measured while scoping this, and not yet reflected in any enforcement code:
on Android 16 both pm suspend and pm disable-user are cleared by a reboot, so
shell-level app blocking is per-session only. setApplicationHidden is assumed
to persist under Device Owner but is untested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-05 09:15:02 +02:00