Commit Graph

2 Commits

Author SHA1 Message Date
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
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