testsAndMisc/phone_focus_mode/strip_workout_hosts.py
Krzysztof kuhy Rudnicki 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

71 lines
2.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Produce the workout variant of the canonical hosts file.
Copies ``argv[1]`` (the full hosts file) to ``argv[2]``, dropping every entry
whose host name (or any alias) matches a workout-allowlisted domain. The domain
set is read from the ``WORKOUT_UNBLOCK_DOMAINS`` environment variable (whitespace
separated) so the generator and the on-device runtime share one source of truth.
Kept as a standalone module (not inline ``python <<PY`` in ``deploy.sh``) so the
repository's Python tooling applies; see CLAUDE.md "Shell Style".
"""
from __future__ import annotations
import os
from pathlib import Path
import sys
# A hosts entry needs at least an IP and one name: "<ip> <name> [aliases...]".
_MIN_HOSTS_FIELDS = 2
def _matches_unblock(name: str, unblock: frozenset[str]) -> bool:
"""Return whether ``name`` is an unblocked domain or a subdomain of one.
Suffix matching, not equality. YouTube serves video from dynamically named
CDN hosts such as ``r1---sn-4g5e6nls.googlevideo.com``; testing those for
exact membership against ``googlevideo.com`` never matches, so playback
stayed blocked during workouts even though the domain was allowlisted.
"""
candidate = name.lower().rstrip(".")
return any(
candidate == domain or candidate.endswith(f".{domain}")
for domain in (entry.lower().rstrip(".") for entry in unblock)
)
def _strip(source: Path, dest: Path, unblock: frozenset[str]) -> None:
"""Write ``source`` to ``dest`` minus lines that map an unblocked domain."""
text = source.read_text(encoding="utf-8", errors="replace")
kept: list[str] = []
for line in text.splitlines(keepends=True):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
kept.append(line)
continue
parts = stripped.split()
names = parts[1:]
if len(parts) >= _MIN_HOSTS_FIELDS and any(
_matches_unblock(name, unblock) for name in names
):
continue
kept.append(line)
dest.write_text("".join(kept), encoding="utf-8")
def main() -> int:
"""Read the source/dest paths from argv and the domains from the env."""
args = sys.argv[1:]
expected_args = 2
if len(args) != expected_args:
sys.stderr.write("usage: strip_workout_hosts.py <src-hosts> <dst-hosts>\n")
return 2
unblock = frozenset(os.environ.get("WORKOUT_UNBLOCK_DOMAINS", "").split())
_strip(Path(args[0]), Path(args[1]), unblock)
return 0
if __name__ == "__main__":
sys.exit(main())