mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:47:03 +01:00
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
171 lines
6.2 KiB
Python
171 lines
6.2 KiB
Python
"""Turn a hosts-format blocklist into a filter-agnostic domain list.
|
|
|
|
The rooted enforcer ships a hosts file to the phone and bind-mounts it over
|
|
``/system/etc/hosts``. An unrooted phone cannot do that, so the same blocklist
|
|
has to be delivered as *domains* — to a DoT resolver, or to a local VpnService
|
|
that answers DNS itself. This module performs that conversion.
|
|
|
|
Two properties of the generated hosts file make a naive parser wrong, and both
|
|
are handled here:
|
|
|
|
1. ``generate_hosts_file.sh`` **comments out** deliberate unblocks rather than
|
|
deleting them (``#0.0.0.0 facebook.com``). Stripping ``#`` and taking field
|
|
two would silently re-block every site the user chose to allow.
|
|
2. The upstream StevenBlack header carries loopback and IPv6 housekeeping lines
|
|
(``127.0.0.1 localhost``, ``::1``, ``ff02::1``). Treating those as blocked
|
|
domains would blackhole ``localhost``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import re
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable, Iterator
|
|
|
|
# Only entries pointing at the null route are blocks. Anything else in a hosts
|
|
# file is either loopback housekeeping or a real mapping, never a block.
|
|
_IPV4_SINK = ".".join(["0"] * 4)
|
|
_IPV6_SINK = "::"
|
|
_BLOCK_SINKS = frozenset({_IPV4_SINK, _IPV6_SINK})
|
|
|
|
_MIN_HOSTS_FIELDS = 2
|
|
_MAX_DOMAIN_LEN = 253
|
|
|
|
# A hostname label: 1-63 chars, alphanumeric or hyphen, no leading/trailing
|
|
# hyphen. A domain is two or more labels joined by dots.
|
|
_VALID_DOMAIN = re.compile(
|
|
r"^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$",
|
|
)
|
|
|
|
|
|
def is_valid_domain(candidate: str) -> bool:
|
|
"""Return whether ``candidate`` is a syntactically usable domain name.
|
|
|
|
The upstream StevenBlack list carries provenance annotations such as
|
|
``#[adware.zeno]`` and stray tokens like ``"spam``. Stripping the comment
|
|
marker turns those into plausible-looking names, and feeding them onward
|
|
produces a config the resolver rejects outright — dnsmasq fails to start on
|
|
``address=/"spam/``, which would take DNS down rather than block anything.
|
|
They also must not be honoured as unblocks, since ``#[cams.com]`` is an
|
|
annotation, not the user's decision to allow ``cams.com``.
|
|
"""
|
|
return len(candidate) <= _MAX_DOMAIN_LEN and bool(_VALID_DOMAIN.match(candidate))
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BlocklistStats:
|
|
"""Counts describing one parse, for logging and sanity checks."""
|
|
|
|
total_lines: int
|
|
blocked_domains: int
|
|
commented_unblocks: int
|
|
skipped_non_sink: int
|
|
rejected_invalid: int
|
|
|
|
|
|
def _iter_entries(lines: Iterable[str]) -> Iterator[tuple[str, list[str], bool]]:
|
|
"""Yield ``(sink, names, was_commented)`` for every hosts-style entry.
|
|
|
|
Commented entries are yielded too, flagged, because a commented block is an
|
|
explicit *unblock* that must override a block of the same name.
|
|
"""
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
commented = stripped.startswith("#")
|
|
body = stripped.lstrip("#").strip() if commented else stripped
|
|
if not body:
|
|
continue
|
|
parts = body.split()
|
|
if len(parts) < _MIN_HOSTS_FIELDS:
|
|
continue
|
|
yield parts[0], parts[1:], commented
|
|
|
|
|
|
def parse_hosts_blocklist(text: str) -> tuple[frozenset[str], BlocklistStats]:
|
|
"""Return the domains blocked by ``text``, plus statistics about the parse.
|
|
|
|
A domain that appears commented out anywhere is treated as unblocked, even
|
|
if an uncommented block for it also exists: the commented form is the
|
|
user's deliberate exception and must win.
|
|
"""
|
|
blocked: set[str] = set()
|
|
unblocked: set[str] = set()
|
|
total = skipped = rejected = 0
|
|
|
|
for sink, names, commented in _iter_entries(text.splitlines()):
|
|
total += 1
|
|
if sink not in _BLOCK_SINKS:
|
|
skipped += 1
|
|
continue
|
|
target = unblocked if commented else blocked
|
|
for name in names:
|
|
domain = name.lower().rstrip(".")
|
|
if not is_valid_domain(domain):
|
|
rejected += 1
|
|
continue
|
|
target.add(domain)
|
|
|
|
effective = frozenset(blocked - unblocked)
|
|
return effective, BlocklistStats(
|
|
total_lines=total,
|
|
blocked_domains=len(effective),
|
|
commented_unblocks=len(unblocked),
|
|
skipped_non_sink=skipped,
|
|
rejected_invalid=rejected,
|
|
)
|
|
|
|
|
|
def domain_matches(domain: str, patterns: Iterable[str]) -> bool:
|
|
"""Return whether ``domain`` equals or is a subdomain of any pattern.
|
|
|
|
Suffix matching is what makes a workout exception actually work. YouTube
|
|
serves video from dynamic CDN names like ``r1---sn-4g5e6nls.googlevideo.com``;
|
|
an exact-equality check against ``googlevideo.com`` never matches them, so
|
|
playback stays blocked. Comparing suffixes covers the whole domain tree.
|
|
"""
|
|
candidate = domain.lower().rstrip(".")
|
|
for pattern in patterns:
|
|
base = pattern.lower().rstrip(".")
|
|
if candidate == base or candidate.endswith(f".{base}"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def apply_exceptions(
|
|
blocked: frozenset[str],
|
|
exceptions: Iterable[str],
|
|
) -> frozenset[str]:
|
|
"""Remove every domain covered by ``exceptions`` from ``blocked``.
|
|
|
|
Used for the workout unblock: while a workout is in progress the YouTube
|
|
domain tree is released. Unlike the shell implementation's exact-match
|
|
filter, this releases subdomains too.
|
|
"""
|
|
patterns = tuple(exceptions)
|
|
if not patterns:
|
|
return blocked
|
|
return frozenset(
|
|
domain for domain in blocked if not domain_matches(domain, patterns)
|
|
)
|
|
|
|
|
|
def to_dnsmasq_conf(domains: Iterable[str]) -> str:
|
|
"""Render domains as dnsmasq ``address=`` directives.
|
|
|
|
Suitable for the self-hosted DoT path, where dnsmasq is already the
|
|
resolver behind the TLS terminator. ``address=/domain/`` returns NXDOMAIN
|
|
for the domain and every subdomain, so the CDN-subdomain problem above
|
|
cannot recur at the resolver.
|
|
"""
|
|
return "".join(f"address=/{domain}/\n" for domain in sorted(domains))
|
|
|
|
|
|
def to_domain_list(domains: Iterable[str]) -> str:
|
|
"""Render domains one per line, for the VpnService fallback to load."""
|
|
return "".join(f"{domain}\n" for domain in sorted(domains))
|