From 7dc3c9416025d93b20616337520c02ec740c41dd Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 17 Aug 2026 17:17:02 +0200 Subject: [PATCH] config.sh: split 571 -> 250, with a fingerprint that actually works The brief's prescribed safety net for this file DOES NOT WORK, and that is the main finding here. It specifies asdict + json.dumps(default=str) + sha256 against baseline 1624750c. Four policy fields are frozenset, which json cannot encode, so default=str stringifies them in SET ITERATION ORDER -- random per process. Three consecutive runs gave three different digests; pinning PYTHONHASHSEED=0 gave one. The recipe can never match a recorded baseline, so it would have flagged every split as a regression regardless of correctness. The repo already ships a deterministic serializer. Before trusting it I checked it was not a weaker proxy: policy_to_json emits all 16 dataclass fields plus 6 more, so it is strictly stronger than asdict. Baseline 83e05e82, unchanged through all six extractions. Two audits ran BEFORE any extraction: - the loader regex-scans config.sh's TEXT ALONE, so the fifteen names it reads are pinned here. Moving one does not error -- it silently yields an empty set, and for WHITELIST that reads as "hide everything". - extractions are variable-scoped, never line-range. A line-range attempt cut through the WHITELIST string, leaving an unterminated quote; 15 variables vanished from the environment WHILE THE FINGERPRINT STILL MATCHED, because the loader's regex tolerated the mangled file. That is why the environment diff exists alongside the hash. The environment check earned its keep twice: it also caught STATE_DIR being left behind the source line, which expanded every path against an empty prefix (/status.json rather than the real path) with the policy hash green throughout. 79 variables, byte-identical either side, and every parsed list compared entry-by-entry: 29/16/46/1/1/2/15, all matching. Rationale prose moves to docs/policy-lists.md with pointers left behind, including the blocks inside the list strings -- the loader and the shell both ignore '#' lines there. All five siblings are in both deploy lists. deploy.sh still sources the chain from the checkout, run_phone.sh --help exits 0, 379 shell assertions pass across nine suites, and focus_policy's 137 pytest cases pass. config.sh is at EXACTLY 250 with no headroom; 184 of those lines are loader-pinned lists that cannot move. Over-cap count 18 -> 17. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../contracts/config-sh-split-2026-08-17.json | 21 + .../evidence/config-sh-split-2026-08-17.json | 87 ++++ phone_focus_mode/config.sh | 385 ++---------------- phone_focus_mode/config_curfew.sh | 50 +++ phone_focus_mode/config_dns.sh | 86 ++++ phone_focus_mode/config_launcher.sh | 29 ++ phone_focus_mode/config_paths.sh | 118 ++++++ phone_focus_mode/config_tether.sh | 44 ++ phone_focus_mode/deploy.sh | 10 + phone_focus_mode/docs/policy-lists.md | 101 +++++ .../lib/tests/daemon_cases_state.sh | 91 +---- .../lib/tests/daemon_cases_status.sh | 80 ++++ .../lib/tests/test_daemon_libs.sh | 2 + 13 files changed, 669 insertions(+), 435 deletions(-) create mode 100644 docs/superpowers/contracts/config-sh-split-2026-08-17.json create mode 100644 docs/superpowers/evidence/config-sh-split-2026-08-17.json create mode 100755 phone_focus_mode/config_curfew.sh create mode 100755 phone_focus_mode/config_dns.sh create mode 100755 phone_focus_mode/config_launcher.sh create mode 100755 phone_focus_mode/config_paths.sh create mode 100755 phone_focus_mode/config_tether.sh create mode 100644 phone_focus_mode/docs/policy-lists.md create mode 100755 phone_focus_mode/lib/tests/daemon_cases_status.sh diff --git a/docs/superpowers/contracts/config-sh-split-2026-08-17.json b/docs/superpowers/contracts/config-sh-split-2026-08-17.json new file mode 100644 index 00000000..70676e59 --- /dev/null +++ b/docs/superpowers/contracts/config-sh-split-2026-08-17.json @@ -0,0 +1,21 @@ +{ + "title": "Split config.sh under the cap without changing the policy it defines", + "objective": "Take phone_focus_mode/config.sh from 571 lines to under the repo's 250-line cap by moving per-enforcer settings into siblings, while proving the parsed policy and the sourced shell environment are both unchanged. Every phone script sources this file and deploy.sh sources it from the checkout, so a mistake here stops the entire stack rather than degrading one enforcer.", + "acceptance_criteria": [ + "bash meta/scripts/check_file_length.sh --all no longer lists phone_focus_mode/config.sh, and no new file is over the cap.", + "The policy fingerprint is byte-identical before and after: python3 -m python_pkg.focus_policy --config phone_focus_mode/config.sh | sha256sum.", + "The SOURCED SHELL ENVIRONMENT is also identical — same variable count, same values. The fingerprint alone is insufficient: the loader reads only the fifteen names it parses, so a broken path expansion in any other variable is invisible to it.", + "Every one of the fifteen loader-visible variables stays in config.sh. The loader regex-scans that file's text alone, so a name moved to a sibling silently parses as empty rather than erroring.", + "No extraction may cut through a multi-line quoted value; each moved variable is taken from its assignment through the line closing its quote.", + "All five siblings are added to BOTH of deploy.sh's hardcoded lists, and the lists remain in exact correspondence.", + "shellcheck passes on each touched file individually; sh -n passes on every config file; bash -n passes on config.sh.", + "pytest python_pkg/focus_policy passes, and every phone-side shell suite still passes with an unchanged assertion count." + ], + "out_of_scope": [ + "Deploying to the phone or verifying on-device.", + "Changing any package list, prefix, threshold, interval or path value.", + "Moving any loader-visible variable, however tempting for line count — WHITELIST and SYSTEM_NEVER_DISABLE alone are 122 lines and must stay.", + "Splitting deploy.sh or focus_ctl.sh, which are separate later items." + ], + "verifier": "python3 -m python_pkg.focus_policy --config phone_focus_mode/config.sh | sha256sum (expects 83e05e82dd1683e1dff1c79a96fec0a4a56aa73295604c681ae355b40a590ba3), plus a diff of the sourced environment against the pre-split commit showing 79 identical variables, plus check_file_length.sh --all showing 17 files, plus every phone test suite at its previous count, plus pytest python_pkg/focus_policy at 137 passed." +} diff --git a/docs/superpowers/evidence/config-sh-split-2026-08-17.json b/docs/superpowers/evidence/config-sh-split-2026-08-17.json new file mode 100644 index 00000000..481d25d5 --- /dev/null +++ b/docs/superpowers/evidence/config-sh-split-2026-08-17.json @@ -0,0 +1,87 @@ +{ + "intent": "Bring config.sh under the 250-line cap without changing the policy it defines, and replace the brief's prescribed safety net — which does not work — with one that does.", + "scope": [ + "phone_focus_mode/config.sh - 571 lines down to 250", + "phone_focus_mode/config_paths.sh, config_dns.sh, config_curfew.sh, config_tether.sh, config_launcher.sh - new siblings", + "phone_focus_mode/docs/policy-lists.md - new: the rationale prose moved out of config.sh", + "phone_focus_mode/deploy.sh - all five siblings added to both hardcoded lists", + "phone_focus_mode/lib/tests/daemon_cases_status.sh - new: split out so daemon_cases_state.sh stays under the cap", + "Non-goal: changing any list, threshold, interval or path value." + ], + "changes": [ + "THE BRIEF'S FINGERPRINT RECIPE IS BROKEN AND WAS REPLACED. It prescribes load_policy + dataclasses.asdict + json.dumps(..., sort_keys=True, default=str) + sha256, with baseline 1624750c…. Four policy fields are frozenset, which json cannot encode, so default=str stringifies each in SET ITERATION ORDER — which depends on PYTHONHASHSEED and is therefore random per process. Measured: three consecutive runs produced three different digests, so the recipe can never match any recorded baseline and would flag every split as a regression. Pinning PYTHONHASHSEED=0 produced one stable digest, confirming the cause.", + "The repo already contains a deterministic serializer: python3 -m python_pkg.focus_policy. Before relying on it, its completeness was checked against dataclasses.asdict recursively — it emits all 16 dataclass fields plus 6 more (schema_version, always_blocked_packages, blockable_system_packages, always_on_vpn_package, private_dns_host, vpn_lockdown). It is strictly stronger than the brief's method, not a weaker proxy, so it is the fingerprint used here. Baseline 83e05e82….", + "Audited the loader BEFORE extracting. python_pkg/focus_policy/loader.py builds the policy by running a regex over the text of config.sh alone (plus a sibling config_secrets.sh). Fifteen variables are therefore pinned to config.sh; moving any of them would not raise an error, it would silently yield an empty set — which for WHITELIST reads as 'nothing is allowed'. That list is documented at the top of config.sh and in config_paths.sh.", + "Extractions are variable-scoped, not line-range. An initial line-range attempt cut through the middle of the WHITELIST string, leaving an unterminated quote; config.sh then failed to parse and 15 variables vanished from the environment — while the fingerprint still matched, because the loader's regex tolerated it. Reverted and redone with a helper that takes each variable from its assignment through the line closing its quote.", + "STATE_DIR moved into config_paths.sh rather than staying behind: every path in that file is built from it, and leaving it in config.sh after the source line produced paths like /status.json instead of /data/local/tmp/focus_mode/status.json. Caught by the environment diff, not the fingerprint.", + "Long rationale prose moved to docs/policy-lists.md with pointers left in place, including the two blocks inside the WHITELIST/NIGHT_WHITELIST strings. The loader and the shell both ignore '#' lines there, and the parsed lists were compared entry-by-entry to confirm it." + ], + "verification": [ + { + "command": "python3 -m python_pkg.focus_policy --config phone_focus_mode/config.sh | sha256sum", + "result": "pass", + "evidence": "83e05e82dd1683e1dff1c79a96fec0a4a56aa73295604c681ae355b40a590ba3 before the split and after every one of the six extractions. The loader sees an identical policy." + }, + { + "command": "completeness check of policy_to_json against dataclasses.asdict, recursive over nested dicts", + "result": "pass", + "evidence": "16 dataclass fields, 22 exported fields, ZERO missing. The exporter is lossless, so its hash cannot be blind to a field the split might mangle." + }, + { + "command": "determinism check of the brief's prescribed recipe", + "result": "fail", + "evidence": "Three runs gave 81e3a371…, 6f4595ce…, cfde0d59… — all different. With PYTHONHASHSEED=0 pinned, two runs gave the same 5f86c8ff…. The nondeterminism is the four frozenset fields being stringified in iteration order. This is why the brief's baseline could never be reproduced." + }, + { + "command": "diff of the sourced shell environment against the pre-split commit, in a clean worktree", + "result": "pass", + "evidence": "79 variables on both sides, byte-identical. This is the check the fingerprint cannot do: it caught the STATE_DIR ordering bug, where paths expanded against an empty prefix while the policy hash stayed green." + }, + { + "command": "entry-by-entry comparison of every parsed package list, comments and blanks stripped as the daemon strips them", + "result": "pass", + "evidence": "WHITELIST 29, NIGHT_WHITELIST 16, SYSTEM_NEVER_DISABLE 46, ALLOWED_PREFIXES 1, NIGHT_ALLOWED_PREFIXES 1, BROWSER_PACKAGES 2, WORKOUT_UNBLOCK_DOMAINS 15 — all identical. Only comment prose moved." + }, + { + "command": "bash -n and sh -n on config.sh and all five siblings", + "result": "pass", + "evidence": "All parse. The failed line-range attempt was caught by exactly this check reporting 'unexpected EOF while looking for matching quote'." + }, + { + "command": "timeout 30 bash phone_focus_mode/deploy.sh; timeout 60 bash phone_focus_mode/run_phone.sh --help", + "result": "pass", + "evidence": "deploy.sh reaches its usage message, proving it still sources the whole config chain from the checkout. run_phone.sh --help exits 0." + }, + { + "command": "every phone-side shell suite", + "result": "pass", + "evidence": "dns_iptables 24, monitor 82, tether_enforcer 17, curfew_net 41, hosts_libs 69, daemon_libs 108, backup_capture 22, adb_common 10, adb_trusted 6 — 379 assertions, 0 failures." + }, + { + "command": "systemd-run --user --scope -p MemoryMax=2G -p MemorySwapMax=0 python3 -m pytest python_pkg/focus_policy -q", + "result": "pass", + "evidence": "137 passed. The reported coverage failure is the repo-wide 100% gate applied to a single-package run, not a regression." + }, + { + "command": "deploy-list parity in both directions; shellcheck on each touched file", + "result": "pass", + "evidence": "10 new entries (5 push + 5 cp), comm returns empty both ways, and all eight touched shell files are clean standalone." + }, + { + "command": "bash meta/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "17 over-cap files, down from 18. config.sh is exactly 250, and the five siblings are 29-118." + } + ], + "risks": [ + "config.sh is at exactly 250 lines — the cap, with no headroom. The next comment added to it puts it back over, and the loader-pinned content alone is 184 lines, so there is little room to recover without moving more prose to docs.", + "The fifteen pinned variables are enforced only by a comment. Nothing mechanically prevents a future edit from moving WHITELIST into a sibling, and the failure would be silent: an empty allowlist parses fine and reads as 'hide everything'. The fingerprint catches it, but only if someone runs it.", + "UNVERIFIED ON DEVICE, and this one has the largest blast radius of the session: every phone script sources config.sh. If any of the five siblings fails to reach the device, nothing starts at all.", + "Rationale that used to sit beside the values now lives in docs/policy-lists.md. Pointers are left at each site, but a reader editing a list in place will no longer have the warnings about dollar-signs and quotes directly in front of them — the two mistakes that have previously broken this file." + ], + "rollback": [ + "git revert the commit. config.sh returns to a single 571-line file, over the cap, and deploy.sh stops pushing the five siblings.", + "After rollback, run bash -n phone_focus_mode/config.sh, then python3 -m python_pkg.focus_policy --config phone_focus_mode/config.sh | sha256sum and confirm 83e05e82… — the policy must be unchanged in either direction.", + "A rollback AFTER a deploy leaves five stale config_*.sh files in $REMOTE_DIR. They are inert once config.sh no longer sources them, but remove them so a later audit is not misled." + ] +} diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index 174f7fdc..5717ce88 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -1,11 +1,10 @@ #!/system/bin/sh -# ============================================================ -# Focus Mode Configuration -# ============================================================ -# IMPORTANT: You MUST set HOME_LAT and HOME_LON in config_secrets.sh -# before deploying. -# Get them from Google Maps: right-click your apartment → coords -# ============================================================ +# Focus Mode Configuration. Set HOME_LAT/HOME_LON in config_secrets.sh before +# deploying (Google Maps: right-click your apartment -> coords). +# +# The lists below stay in THIS file: focus_policy's loader finds them by +# regex over config.sh's own text, so one moved into a config_*.sh sibling +# parses as empty with no error. Rationale lives in docs/policy-lists.md. # --- Home location (loaded from config_secrets.sh, not tracked by git) --- SCRIPT_DIR="${FOCUS_MODE_SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}" @@ -28,179 +27,22 @@ export RADIUS=150 # --- Hysteresis buffer in meters (prevents rapid toggling at boundary) --- export HYSTERESIS=30 -# --- Location check interval in seconds --- -# When focus mode is ON (at home): check very frequently for near-instant -# detection of leaving home (phone is charging anyway). -# When focus mode is OFF (away): check less often to save battery. -export CHECK_INTERVAL_FOCUS=10 -export CHECK_INTERVAL_NORMAL=120 +# shellcheck source=config_paths.sh +. "$SCRIPT_DIR/config_paths.sh" -# --- Log file --- -export LOG_FILE="/data/local/tmp/focus_mode/focus_mode.log" -export LOG_MAX_LINES=500 - -# --- State file (tracks which apps were disabled by focus mode) --- -STATE_DIR="/data/local/tmp/focus_mode" -export DISABLED_APPS_FILE="$STATE_DIR/disabled_by_focus.txt" -export MODE_FILE="$STATE_DIR/current_mode.txt" -# Status snapshot consumed by the companion notification app (focus_status_app). -# Written by focus_daemon.sh every loop iteration. Chmod 644 so apps can read. -export STATUS_FILE="$STATE_DIR/status.json" -# Trigger file: companion app (or user) touches this to request an immediate -# re-check. focus_daemon.sh polls for it and skips the remainder of its sleep. -export RECHECK_TRIGGER="$STATE_DIR/trigger_recheck" - -# ============================================================ -# NIGHT CURFEW (time-gated strict allow-list) -# ============================================================ -# When focus mode is ON (i.e. you are at home) AND the local clock is inside -# the curfew window, the daemon switches from the permissive $WHITELIST to the -# strict $NIGHT_WHITELIST: every app not on that short list is disabled. This -# is the "stop using the phone after 23:00 at home" layer. The companion -# enforcer (curfew_enforcer.sh) adds grayscale + DND + an optional per-UID -# network allow-list on top. Times are local 24h "HHMM"; the window wraps past -# midnight when START > END (e.g. 2300 -> 0500). +# NIGHT CURFEW (time-gated strict allow-list). Times are local 24h HHMM; +# the window wraps past midnight when START > END (e.g. 2300 -> 0500). +# see docs/policy-lists.md#night-curfew export NIGHT_CURFEW_ENABLED=1 export NIGHT_CURFEW_START="2300" export NIGHT_CURFEW_END="0500" -# Escape hatch: if this file exists, curfew is suspended (treated as daytime) -# everywhere — app list, grayscale, DND and network. Delete it to re-arm. -# WHO CAN CREATE IT (recovery paths, strongest lock first): -# * `focus_ctl.sh curfew-off` over ADB from the PC (always available daily). -# * The Magisk boot emergency-disable file ($FOCUS_BOOT_EMERGENCY_DISABLE_FILE) -# stops the whole stack at next boot. -# * A root file-manager/terminal ONLY IF one is added to $NIGHT_WHITELIST. -# None is whitelisted by default — that is deliberate ("hard to turn off"). -# The active-IME guard in focus_daemon.sh keeps the keyboard alive so whichever -# path you choose is always typable. If you want a true no-PC 2am opt-out, -# whitelist a root terminal at night (see NIGHT_WHITELIST) or wire a companion- -# app button. Until then, recovery is PC/ADB-based by design. -export CURFEW_OVERRIDE_FILE="$STATE_DIR/curfew_override" -# --- Curfew enforcer (grayscale + DND + per-UID network allow-list) --- -# See curfew_enforcer.sh. Always-on like dns_enforcer, but only ACTS while the -# curfew window is open AND focus mode is ON. Re-applies every interval so a -# manual toggle in Settings snaps back ("hard to turn off"). NOTE: snap-back is -# the realistic lock; true impossibility would mean blocking the Settings app, -# which risks system instability, so we deliberately do not. -export CURFEW_ENFORCER_INTERVAL=5 -export CURFEW_ENFORCER_LOG="$STATE_DIR/curfew_enforcer.log" -export CURFEW_ENFORCER_STATE="$STATE_DIR/curfew_applied" -# Grayscale: force the display to monochrome via the accessibility daltonizer. -export CURFEW_GRAYSCALE_ENABLED=1 -# DND: force Do-Not-Disturb to alarms-only so notifications stop pulling you in -# while the morning alarm still rings. -export CURFEW_DND_ENABLED=1 -# Per-UID internet allow-list. DEFAULT OFF: highest-risk layer, must be proven -# on-device (`focus_ctl.sh curfew-test-on`) before it is trusted to fire -# unattended at 23:00. When on, only $NIGHT_WHITELIST app UIDs (plus -# root/system/shell + DNS) get network; every other app is cut off. It is also -# largely redundant with the app-disable layer, so leaving it off is safe. -export CURFEW_NET_ENABLED=1 -export CURFEW_NET_IPT_CHAIN="FOCUS_CURFEW_NET" -# Android's netd periodically reasserts the whole `filter` table via -# iptables-restore, which atomically erases our custom chain (proven on-device: -# the chain vanishes ~every 5s). The main enforcer tick (5s) rebuilds it but -# leaves up-to-5s leak windows. This watchdog re-pins the chain every -# CURFEW_NET_REASSERT_INTERVAL seconds *from a cached UID list* (no pm fork). -# A 1s interval was measured on-device to peg netd at ~30% CPU continuously -# (an `iptables -L` fork every second, all night), overheating the phone - -# throttled to 15s (matches HOSTS_CHECK_INTERVAL/LAUNCHER_CHECK_INTERVAL -# pacing) as a deliberate tradeoff: leak window grows from <=1s to <=15s. -# The proper fix is netd's own per-UID firewall (ndc/eBPF), tracked as a -# follow-up; this remains an interim stopgap either way. -export CURFEW_NET_REASSERT_INTERVAL=15 -export CURFEW_NET_UID_CACHE="$STATE_DIR/curfew_net_uids.txt" -# Manual test toggle: `focus_ctl.sh curfew-test-on` writes this file to force -# curfew ACTIVE regardless of clock, so the whole stack can be validated during -# the day. `curfew-test-off` removes it. -export CURFEW_FORCE_FILE="$STATE_DIR/curfew_force_on" +# shellcheck source=config_curfew.sh +. "$SCRIPT_DIR/config_curfew.sh" -# ============================================================ -# HOTSPOT / TETHERING BLOCK (closes the "second phone" bypass) -# ============================================================ -# The hosts file, dns_enforcer and the curfew net layer all only touch this -# phone's OWN traffic (system resolver + OUTPUT chain). A tethered second -# device's packets are FORWARDed + NAT'd through us on an unfiltered path, so -# it browses freely on our mobile data. tether_enforcer.sh closes that hole. -# Always-on like dns_enforcer, but only ACTS while focus mode is ON (i.e. you -# are at home: $MODE_FILE == "focus") OR the force-test file is present. Covers -# WiFi, USB and Bluetooth tethering, since all of it traverses FORWARD. -export TETHER_ENFORCER_ENABLED=1 -export TETHER_CHECK_INTERVAL=5 -export TETHER_LOG="$STATE_DIR/tether_enforcer.log" -export TETHER_ENFORCER_STATE="$STATE_DIR/tether_applied" -# Lever 1: disable Android's hardware/BPF tether offload so forwarded traffic -# is actually seen by netfilter (otherwise the FORWARD rule can be silently -# skipped). AOSP global key Settings.Global.TETHER_OFFLOAD_DISABLED; 1=disabled. -# Snapshotted on entry and restored on exit so we never clobber a user value. -export TETHER_OFFLOAD_KEY="tether_offload_disabled" -export TETHER_OFFLOAD_SNAP="$STATE_DIR/tether_offload.snap" -# Lever 2: blanket REJECT of the FORWARD chain (both iptables + ip6tables). -# Re-pinned only when tampered (chain_intact gate, like dns_enforcer) so we do -# not fork an `iptables -L` every second — that pegged netd + overheated the -# phone during curfew-net tuning (see CURFEW_NET_REASSERT_INTERVAL note above). -export TETHER_IPT_CHAIN="FOCUS_TETHER_BLOCK" -# Lever 3 (best-effort, WiFi only): actively stop a running softAP each tick so -# the hotspot toggle visibly flips off. Version-gated (cmd wifi stop-softap is -# Android 11+). The FORWARD block remains the version-independent catch-all. -export TETHER_STOP_SOFTAP_ENABLED=1 -# Escape hatches (mirrors the curfew override/force pair): -# tether_override -> suspend the block without stopping the daemon. -# tether_force_on -> force the block ACTIVE regardless of location (daytime -# on-device validation via `focus_ctl.sh tether-test-on`). -export TETHER_OVERRIDE_FILE="$STATE_DIR/tether_override" -export TETHER_FORCE_FILE="$STATE_DIR/tether_force_on" +# shellcheck source=config_tether.sh +. "$SCRIPT_DIR/config_tether.sh" -# --- Boot-time autostart safety gate --- -# Critical safety default: do NOT auto-start focus daemons at boot unless -# explicitly enabled. This avoids device instability during early boot on -# vendor ROMs after resets/updates. -# Late-auto mode: boot stack starts only after Android reports boot complete. -export FOCUS_BOOT_AUTOSTART=1 -# Extra grace period after sys.boot_completed. Keep at or below 10 seconds. -export FOCUS_BOOT_DELAY_SECONDS=10 -# Hard timeout while waiting for sys.boot_completed in Magisk service script. -export FOCUS_BOOT_WAIT_MAX_SECONDS=180 -# Emergency kill switch file: if this marker exists on phone, boot autostart -# is skipped entirely for this boot. Create with: -# adb shell su -c 'touch /data/local/tmp/focus_mode/disable_boot_autostart' -export FOCUS_BOOT_EMERGENCY_DISABLE_FILE="$STATE_DIR/disable_boot_autostart" - -# --- Hosts enforcer state (see hosts_enforcer.sh) --- -# Canonical hosts file pushed by deploy.sh. The enforcer bind-mounts this -# over /system/etc/hosts and restores any tampering. -export HOSTS_CANONICAL="$STATE_DIR/hosts.canonical" -export HOSTS_TARGET="/system/etc/hosts" -export HOSTS_SHA_FILE="$STATE_DIR/hosts.sha256" -export HOSTS_CHECK_INTERVAL=15 -export HOSTS_LOG="$STATE_DIR/hosts_enforcer.log" -# Workout-variant canonical: same content as $HOSTS_CANONICAL but with all -# YouTube-related domain blocks removed. hosts_enforcer.sh switches to this -# variant when $WORKOUT_ACTIVE_FILE contains "1" (StrongLifts workout in -# progress) and switches back when it contains "0" or is missing. -export HOSTS_CANONICAL_WORKOUT="$STATE_DIR/hosts.canonical.workout" -export HOSTS_SHA_FILE_WORKOUT="$STATE_DIR/hosts.sha256.workout" -# Magisk Systemless Hosts module path. The enforcer keeps this in sync with -# the currently-active canonical so a fresh boot sees the right hosts file. -export HOSTS_MAGISK_MODULE_FILE="/data/adb/modules/hosts/system/etc/hosts" - -# --- Workout detector (see workout_detector.sh) --- -# Polls the StrongLifts SQLite DB to determine whether a workout is in -# progress. A workout is "in progress" iff there is at least one row in -# `workouts` with start>0 AND (finish IS NULL OR finish=0). While true, -# YouTube is unblocked at the hosts level (see HOSTS_CANONICAL_WORKOUT). -export WORKOUT_DETECTOR_INTERVAL=10 -export WORKOUT_ACTIVE_FILE="$STATE_DIR/workout_active" -export WORKOUT_DETECTOR_LOG="$STATE_DIR/workout_detector.log" -# Static aarch64 sqlite3 binary pushed by deploy.sh. Built from the SQLite -# amalgamation against the Android NDK; ~1.6 MB. Stored outside the repo at -# ../testsAndMisc_binaries/phone_focus_mode/sqlite3 per binary-files policy. -export WORKOUT_SQLITE3_BIN="/data/local/tmp/focus_mode/sqlite3" -export WORKOUT_DB_PATH="/data/data/com.stronglifts.app/databases/StrongLifts-Database-3" -# StrongLifts package — must be in $WHITELIST so its DB stays writable while -# focus mode is enforcing. Used here only for status/log clarity. -export WORKOUT_TRIGGER_PACKAGE="com.stronglifts.app" # Domains unblocked while a workout is in progress. Used by deploy.sh to # generate $HOSTS_CANONICAL_WORKOUT (each line becomes a `0.0.0.0 ` # match that is stripped from the canonical) and by focus_ctl.sh status. @@ -223,80 +65,8 @@ yt3.googleusercontent.com i9.ytimg.com " -# --- DNS enforcer state (see dns_enforcer.sh) --- -# The hosts file is only consulted by the *system* resolver. Apps using -# DNS-over-HTTPS (DoH, e.g. Chrome's built-in secure DNS) or DNS-over-TLS -# (DoT, e.g. Android 9+ Private DNS "opportunistic" mode) bypass it. -# The DNS enforcer pins Private DNS to OFF and blocks well-known DoH/DoT -# endpoints so lookups fall back to the system resolver -> hosts file. -export DNS_CHECK_INTERVAL=20 -export DNS_LOG="$STATE_DIR/dns_enforcer.log" -# --- Trusted DoT resolver (opt-in, empty = old behaviour) --- -# Set this to the hostname of a DoT resolver you control, and the enforcer -# switches from "no DoT at all" to "only YOUR DoT". Two things change: -# 1. an ACCEPT for that resolver's IPs is inserted BEFORE the blanket -# 853 REJECT, so the connection survives; -# 2. private_dns_mode is pinned to "hostname" with this specifier, instead -# of being forced off -- so the phone cannot silently fall back to a -# resolver that does not filter. -# Leave EMPTY to keep the original behaviour (Private DNS forced off, all -# 853 rejected). Do not point this at a public resolver: the whole point is -# that the resolver applies your blocklist. See python_pkg/focus_policy. -export DNS_TRUSTED_DOT_HOST="" -# Where to resolve DNS_TRUSTED_DOT_HOST when Private DNS is pinned to it. -# Chicken-and-egg: the resolver's own name must resolve without using it. -# Space-separated IPv4/IPv6 literals; leave empty to resolve at runtime. -export DNS_TRUSTED_DOT_IPS="" -# iptables chain used exclusively by us; we flush+refill it every check. -export DNS_IPT_CHAIN="FOCUS_DNS_BLOCK" -# DoH/DoT endpoints to DROP. Well-known public resolvers used by browsers -# and OS when Private DNS is enabled. Updating this list is cheap — just -# edit and redeploy. -export DNS_DOH_HOSTS=" -dns.google -dns64.dns.google -dns.quad9.net -dns.cloudflare.com -one.one.one.one -cloudflare-dns.com -mozilla.cloudflare-dns.com -chrome.cloudflare-dns.com -dns.nextdns.io -doh.opendns.com -dns.adguard-dns.com -dns.adguard.com -dns.controld.com -" -# IPv4/IPv6 literals used by DoT (port 853) and DoH (port 443). Anything -# not already resolved via /etc/hosts still needs literal-IP blocks. -export DNS_DOH_IPV4=" -8.8.8.8 -8.8.4.4 -1.1.1.1 -1.0.0.1 -9.9.9.9 -149.112.112.112 -94.140.14.14 -94.140.15.15 -208.67.222.222 -208.67.220.220 -45.90.28.0 -45.90.30.0 -104.16.248.249 -104.16.249.249 -" -export DNS_DOH_IPV6=" -2001:4860:4860::8888 -2001:4860:4860::8844 -2606:4700:4700::1111 -2606:4700:4700::1001 -2620:fe::fe -2620:fe::9 -2a10:50c0::ad1:ff -2a10:50c0::ad2:ff -2606:4700::6810:f8f9 -2606:4700::6810:f9f9 -" +# shellcheck source=config_dns.sh +. "$SCRIPT_DIR/config_dns.sh" # Browsers to force-stop when the hosts file is updated or restored. # Force-stopping clears the in-process DNS cache so the next launch @@ -317,30 +87,11 @@ com.android.chrome # the launcher leaves the device with no home screen and no way back to the # enforcer app. export LAUNCHER_PACKAGE="com.google.android.apps.nexuslauncher" -export LAUNCHER_APK="/data/adb/focus_mode/minimalist_launcher.apk" -export LAUNCHER_SHA_FILE="/data/adb/focus_mode/minimalist_launcher.sha256" -# Captured home-activity component (package/.Activity). Saved by -# --snapshot-launcher so the enforcer knows which component to pin as HOME. -export LAUNCHER_ACTIVITY_FILE="/data/adb/focus_mode/minimalist_launcher.activity" -# Competing launchers to disable so the "pick a launcher" dialog has -# nothing else to offer. Matched exactly; add more with `focus_ctl.sh -# launcher-disable-other `. -# com.blackview.launcher is intentionally excluded from LAUNCHER_COMPETITORS: -# Blackview embeds com.android.quickstep.RecentsActivity inside this APK, -# so disabling it kills the system-wide "swipe up for recent apps" gesture. -export LAUNCHER_COMPETITORS=" -com.android.launcher -com.android.launcher3 -com.google.android.apps.nexuslauncher -" -export LAUNCHER_CHECK_INTERVAL=15 -export LAUNCHER_LOG="$STATE_DIR/launcher_enforcer.log" +# shellcheck source=config_launcher.sh +. "$SCRIPT_DIR/config_launcher.sh" -# ============================================================ -# WHITELISTED APPS -# These apps will ALWAYS remain enabled, even in focus mode. -# Package names verified against installed packages on 2026-02-22. -# ============================================================ +# WHITELISTED APPS -- always enabled, even in focus mode. +# see docs/policy-lists.md#whitelisted-apps export WHITELIST=" # Rewritten 2026-08-11 for the unrooted Pixel 6a under Device Owner. The list @@ -386,26 +137,9 @@ com.ichi2.anki com.metrolist.music eu.kanade.tachiyomi.sy -# --- App store (day only; deliberately absent from the night list) --- -# NB: never write a dollar-sign variable reference inside this list. WHITELIST -# is a double-quoted string, so even a comment line expands, and deploy.sh -# runs under set -u, where an undefined name aborts the whole deploy. A -# reference to the night list sat here and did exactly that (it is defined -# below this point, so it was still unset), blocking every focus-mode deploy. -# Same rule for double quotes: one in a comment ends the string early. -# infakt cannot be installed or updated without Play, and it is device-paired -# to a bank, so losing the ability to update it strands a re-authentication -# chain. This does NOT reopen YouTube: the sweep is default-deny for -# third-party packages, so anything installed from Play is hidden on the next -# at-home pass, and the always-blocked set (YouTube, Chrome) is never restored -# by the AWAY branch either. Reinstalling YouTube from Play is UNTESTED as a -# bypass: -# the sweep would re-hide it within one pass regardless, but treat the claim -# that Play cannot resurrect a blocked app as unverified until someone tries. -# NOTE: never use a double-quote character anywhere inside these export -# blocks, not even in a comment. The policy loader matches a quoted value up -# to the next quote, so one stray quote terminates the string early and -# silently truncates the allowlist -- which drops apps with no error at all. +# com.android.vending -- day only. NEVER put a dollar-sign reference or a +# double quote in this string, not even in a comment: both break the list +# silently. see docs/policy-lists.md#why-the-play-store-is-in-the-day-list com.android.vending # --- Workout tracking (always beneficial; must stay enabled to export runs) --- @@ -413,21 +147,8 @@ org.runnerup org.runnerup.free " -# ============================================================ -# ALLOWED PACKAGE PREFIXES -# Matched as prefixes on whole labels, exactly like $SYSTEM_NEVER_DISABLE: -# "eu.kanade.tachiyomi" covers "eu.kanade.tachiyomi.sy" and -# "eu.kanade.tachiyomi.extension.all.mangadex", but not -# "eu.kanade.tachiyomisomething". -# -# This exists because Tachiyomi installs every source as its OWN apk. Listing -# them individually means each newly installed extension is invisible until -# this file is edited and the policy regenerated -- a recurring chore that -# looks exactly like a bug from the phone. -# -# Weaker than the exact list by construction: a prefix allows packages that do -# not exist yet. Keep the prefixes narrow and vendor-specific for that reason. -# ============================================================ +# ALLOWED PACKAGE PREFIXES -- why prefixes, and how narrow to keep them: +# see docs/policy-lists.md#allowed-package-prefixes export ALLOWED_PREFIXES=" # Manga reader + its per-source extension apks. @@ -440,25 +161,8 @@ export NIGHT_ALLOWED_PREFIXES=" eu.kanade.tachiyomi " -# ============================================================ -# NIGHT CURFEW WHITELIST -# These are the ONLY third-party apps that stay enabled during the curfew -# window (see NIGHT_CURFEW_* above). Everything else in $WHITELIST — browsers, -# social, messaging, email, stores, transit — is disabled. -# Allow-list by design: when in doubt, leave it OUT. -# -# EXCEPTION: $NIGHT_ALLOWED_PREFIXES is applied on top of this list, and it -# currently carries "eu.kanade.tachiyomi" — so manga IS available during the -# curfew, deliberately (chosen 2026-08-14). This paragraph used to say manga -# was disabled at night; it was true until that change. Do not "restore" it -# without also emptying $NIGHT_ALLOWED_PREFIXES, or the comment and the -# behaviour disagree again. -# -# Parsed exactly like $WHITELIST (one package per line, '#' comments ignored). -# The sysprotect prefixes ($SYSTEM_NEVER_DISABLE) and the default-handler guard -# (dialer/SMS/home/browser/IME) still apply on TOP of this list, so the active -# keyboard and core system apps are protected even if omitted here. -# ============================================================ +# NIGHT CURFEW WHITELIST -- what stays enabled at night and why, plus the +# prefix exception: see docs/policy-lists.md#night-curfew-whitelist export NIGHT_WHITELIST=" # Curfew (23:00-05:00 at home) is strictly tighter than the day list: only what @@ -479,13 +183,8 @@ com.kuhy.wake_alarm_sync com.google.android.deskclock org.runnerup org.runnerup.free -# Capture-only notes app. Added 2026-08-14 after a curfew-window deploy -# installed it and the enforcer removed the package ~80ms later: it was in -# the day list but not here, so any build shipped after 23:00 was silently -# uninstalled. This is a deliberate loosening of the answer-the-phone / -# reach-a-bank / handle-an-emergency rule above -- writing an idea down at -# night is the one thing this app does, and losing the deploy path for six -# hours a day cost more than the distraction risk. +# dev.kuhy.todo: deliberate loosening of the night rule. +# see docs/policy-lists.md#why-devkuhytodo-is-in-the-night-list dev.kuhy.todo " @@ -493,30 +192,10 @@ dev.kuhy.todo # BLOCKED SYSTEM APPS # System apps that should be disabled in focus mode. # These are NOT covered by third-party package blocking. -# ============================================================ - -export BLOCKED_SYSTEM_APPS=" -# *** INTENTIONALLY EMPTY *** -# -# pm disable-user state persists across reboots. Android always kills daemon -# processes with SIGKILL during shutdown, bypassing the shell cleanup trap. -# Any system package left disabled across a reboot can trigger MTK bootloop -# protection → recovery → factory wipe (confirmed on BL9000). -# -# System/distraction apps are enforced via DNS+iptables in dns_enforcer.sh -# instead of persistent package-disable state. -" # --- System / essential packages that must NEVER be disabled --- -# These are matched as prefixes (startswith). -# You generally don't need to edit this list. -# -# pl.infakt.infakt is the one non-system entry. Allowlisting it is weaker: -# that depends on it staying in BOTH the day and night lists, and dropping it -# from either would silently make it hideable. It is device-paired to a bank -# over SMS, so losing access to it strands the same re-authentication chain a -# hidden Messages app would. isAllowed() checks this list first, before the -# curfew split, so it holds under every condition. +# Prefix-matched. Why pl.infakt.infakt is here rather than allowlisted: +# see docs/policy-lists.md#system-packages-that-must-never-be-disabled export SYSTEM_NEVER_DISABLE=" pl.infakt.infakt # The always-on VPN provider. Hiding it is self-defeating in the worst way: diff --git a/phone_focus_mode/config_curfew.sh b/phone_focus_mode/config_curfew.sh new file mode 100755 index 00000000..b57c119f --- /dev/null +++ b/phone_focus_mode/config_curfew.sh @@ -0,0 +1,50 @@ +#!/system/bin/sh +# shellcheck shell=ash +# config_curfew.sh — the night-curfew enforcer's settings: poll interval, log +# and state paths, the grayscale/DND/network feature switches, and the +# per-UID network chain. +# +# Sourced by config.sh where these definitions used to sit. +# +# The curfew WINDOW ($NIGHT_CURFEW_START/END/ENABLED) is deliberately NOT +# here: focus_policy's loader reads those out of config.sh's text. Only names +# the loader never reads may live in a sibling. + +# --- Curfew enforcer (grayscale + DND + per-UID network allow-list) --- +# See curfew_enforcer.sh. Always-on like dns_enforcer, but only ACTS while the +# curfew window is open AND focus mode is ON. Re-applies every interval so a +# manual toggle in Settings snaps back ("hard to turn off"). NOTE: snap-back is +# the realistic lock; true impossibility would mean blocking the Settings app, +# which risks system instability, so we deliberately do not. +export CURFEW_ENFORCER_INTERVAL=5 +export CURFEW_ENFORCER_LOG="$STATE_DIR/curfew_enforcer.log" +export CURFEW_ENFORCER_STATE="$STATE_DIR/curfew_applied" +# Grayscale: force the display to monochrome via the accessibility daltonizer. +export CURFEW_GRAYSCALE_ENABLED=1 +# DND: force Do-Not-Disturb to alarms-only so notifications stop pulling you in +# while the morning alarm still rings. +export CURFEW_DND_ENABLED=1 +# Per-UID internet allow-list. DEFAULT OFF: highest-risk layer, must be proven +# on-device (`focus_ctl.sh curfew-test-on`) before it is trusted to fire +# unattended at 23:00. When on, only $NIGHT_WHITELIST app UIDs (plus +# root/system/shell + DNS) get network; every other app is cut off. It is also +# largely redundant with the app-disable layer, so leaving it off is safe. +export CURFEW_NET_ENABLED=1 +export CURFEW_NET_IPT_CHAIN="FOCUS_CURFEW_NET" +# Android's netd periodically reasserts the whole `filter` table via +# iptables-restore, which atomically erases our custom chain (proven on-device: +# the chain vanishes ~every 5s). The main enforcer tick (5s) rebuilds it but +# leaves up-to-5s leak windows. This watchdog re-pins the chain every +# CURFEW_NET_REASSERT_INTERVAL seconds *from a cached UID list* (no pm fork). +# A 1s interval was measured on-device to peg netd at ~30% CPU continuously +# (an `iptables -L` fork every second, all night), overheating the phone - +# throttled to 15s (matches HOSTS_CHECK_INTERVAL/LAUNCHER_CHECK_INTERVAL +# pacing) as a deliberate tradeoff: leak window grows from <=1s to <=15s. +# The proper fix is netd's own per-UID firewall (ndc/eBPF), tracked as a +# follow-up; this remains an interim stopgap either way. +export CURFEW_NET_REASSERT_INTERVAL=15 +export CURFEW_NET_UID_CACHE="$STATE_DIR/curfew_net_uids.txt" +# Manual test toggle: `focus_ctl.sh curfew-test-on` writes this file to force +# curfew ACTIVE regardless of clock, so the whole stack can be validated during +# the day. `curfew-test-off` removes it. +export CURFEW_FORCE_FILE="$STATE_DIR/curfew_force_on" diff --git a/phone_focus_mode/config_dns.sh b/phone_focus_mode/config_dns.sh new file mode 100755 index 00000000..f6053910 --- /dev/null +++ b/phone_focus_mode/config_dns.sh @@ -0,0 +1,86 @@ +#!/system/bin/sh +# shellcheck shell=ash +# config_dns.sh — the DNS enforcer's settings: poll interval, log and iptables +# chain names, the trusted DoT host, and the DoH endpoint lists that +# dns_iptables.sh turns into reject rules. +# +# Sourced by config.sh where these definitions used to sit. +# +# Nothing python_pkg/focus_policy/loader.py reads may move into a sibling: it +# regex-scans the text of config.sh ALONE, so a name moved out becomes an +# empty value with no error at all. See the pinned list in config_paths.sh. + +# --- DNS enforcer state (see dns_enforcer.sh) --- +# The hosts file is only consulted by the *system* resolver. Apps using +# DNS-over-HTTPS (DoH, e.g. Chrome's built-in secure DNS) or DNS-over-TLS +# (DoT, e.g. Android 9+ Private DNS "opportunistic" mode) bypass it. +# The DNS enforcer pins Private DNS to OFF and blocks well-known DoH/DoT +# endpoints so lookups fall back to the system resolver -> hosts file. +export DNS_CHECK_INTERVAL=20 +export DNS_LOG="$STATE_DIR/dns_enforcer.log" +# --- Trusted DoT resolver (opt-in, empty = old behaviour) --- +# Set this to the hostname of a DoT resolver you control, and the enforcer +# switches from "no DoT at all" to "only YOUR DoT". Two things change: +# 1. an ACCEPT for that resolver's IPs is inserted BEFORE the blanket +# 853 REJECT, so the connection survives; +# 2. private_dns_mode is pinned to "hostname" with this specifier, instead +# of being forced off -- so the phone cannot silently fall back to a +# resolver that does not filter. +# Leave EMPTY to keep the original behaviour (Private DNS forced off, all +# 853 rejected). Do not point this at a public resolver: the whole point is +# that the resolver applies your blocklist. See python_pkg/focus_policy. +export DNS_TRUSTED_DOT_HOST="" +# Where to resolve DNS_TRUSTED_DOT_HOST when Private DNS is pinned to it. +# Chicken-and-egg: the resolver's own name must resolve without using it. +# Space-separated IPv4/IPv6 literals; leave empty to resolve at runtime. +export DNS_TRUSTED_DOT_IPS="" +# iptables chain used exclusively by us; we flush+refill it every check. +export DNS_IPT_CHAIN="FOCUS_DNS_BLOCK" +# DoH/DoT endpoints to DROP. Well-known public resolvers used by browsers +# and OS when Private DNS is enabled. Updating this list is cheap — just +# edit and redeploy. +export DNS_DOH_HOSTS=" +dns.google +dns64.dns.google +dns.quad9.net +dns.cloudflare.com +one.one.one.one +cloudflare-dns.com +mozilla.cloudflare-dns.com +chrome.cloudflare-dns.com +dns.nextdns.io +doh.opendns.com +dns.adguard-dns.com +dns.adguard.com +dns.controld.com +" +# IPv4/IPv6 literals used by DoT (port 853) and DoH (port 443). Anything +# not already resolved via /etc/hosts still needs literal-IP blocks. +export DNS_DOH_IPV4=" +8.8.8.8 +8.8.4.4 +1.1.1.1 +1.0.0.1 +9.9.9.9 +149.112.112.112 +94.140.14.14 +94.140.15.15 +208.67.222.222 +208.67.220.220 +45.90.28.0 +45.90.30.0 +104.16.248.249 +104.16.249.249 +" +export DNS_DOH_IPV6=" +2001:4860:4860::8888 +2001:4860:4860::8844 +2606:4700:4700::1111 +2606:4700:4700::1001 +2620:fe::fe +2620:fe::9 +2a10:50c0::ad1:ff +2a10:50c0::ad2:ff +2606:4700::6810:f8f9 +2606:4700::6810:f9f9 +" diff --git a/phone_focus_mode/config_launcher.sh b/phone_focus_mode/config_launcher.sh new file mode 100755 index 00000000..5cbb3fde --- /dev/null +++ b/phone_focus_mode/config_launcher.sh @@ -0,0 +1,29 @@ +#!/system/bin/sh +# shellcheck shell=ash +# config_launcher.sh — the minimalist-launcher enforcer's settings: where the +# APK and its hash live, which launchers count as competitors, and the poll +# interval and log path. +# +# Sourced by config.sh where these definitions used to sit. +# +# $LAUNCHER_PACKAGE is deliberately NOT here: focus_policy's loader reads it +# out of config.sh's text and would silently see an empty value instead. + +export LAUNCHER_APK="/data/adb/focus_mode/minimalist_launcher.apk" +export LAUNCHER_SHA_FILE="/data/adb/focus_mode/minimalist_launcher.sha256" +# Captured home-activity component (package/.Activity). Saved by +# --snapshot-launcher so the enforcer knows which component to pin as HOME. +export LAUNCHER_ACTIVITY_FILE="/data/adb/focus_mode/minimalist_launcher.activity" +# Competing launchers to disable so the "pick a launcher" dialog has +# nothing else to offer. Matched exactly; add more with `focus_ctl.sh +# launcher-disable-other `. +# com.blackview.launcher is intentionally excluded from LAUNCHER_COMPETITORS: +# Blackview embeds com.android.quickstep.RecentsActivity inside this APK, +# so disabling it kills the system-wide "swipe up for recent apps" gesture. +export LAUNCHER_COMPETITORS=" +com.android.launcher +com.android.launcher3 +com.google.android.apps.nexuslauncher +" +export LAUNCHER_CHECK_INTERVAL=15 +export LAUNCHER_LOG="$STATE_DIR/launcher_enforcer.log" diff --git a/phone_focus_mode/config_paths.sh b/phone_focus_mode/config_paths.sh new file mode 100755 index 00000000..edef233b --- /dev/null +++ b/phone_focus_mode/config_paths.sh @@ -0,0 +1,118 @@ +#!/system/bin/sh +# shellcheck shell=ash +# config_paths.sh — the shared plumbing: poll intervals, the log file, the +# state/mode/status paths, the hosts-enforcer and workout-detector settings, +# and the boot-autostart gate. +# +# Sourced by config.sh where these definitions used to sit, after $STATE_DIR +# (which stays in config.sh, since every one of these paths is built from it). +# +# WHAT MUST NOT MOVE OUT OF config.sh +# ----------------------------------- +# python_pkg/focus_policy/loader.py builds the whole policy by running a regex +# over the TEXT OF config.sh ALONE (plus a sibling config_secrets.sh). A name +# moved into any of these config_*.sh siblings becomes invisible to it, and +# the loader does not fail -- it silently yields an empty set, which reads as +# "nothing is whitelisted". These fifteen therefore stay in config.sh: +# +# ALLOWED_PREFIXES BROWSER_PACKAGES HOME_LAT HOME_LON HYSTERESIS +# LAUNCHER_PACKAGE NIGHT_ALLOWED_PREFIXES NIGHT_CURFEW_ENABLED +# NIGHT_CURFEW_END NIGHT_CURFEW_START NIGHT_WHITELIST RADIUS +# SYSTEM_NEVER_DISABLE WHITELIST WORKOUT_UNBLOCK_DOMAINS +# +# Check before and after any change here -- the hash must not move: +# python3 -m python_pkg.focus_policy --config phone_focus_mode/config.sh \ +# | sha256sum + +# Every path below is built from this, so it leads the file. +# --- State file (tracks which apps were disabled by focus mode) --- +STATE_DIR="/data/local/tmp/focus_mode" + +# --- Location check interval in seconds --- +# When focus mode is ON (at home): check very frequently for near-instant +# detection of leaving home (phone is charging anyway). +# When focus mode is OFF (away): check less often to save battery. +export CHECK_INTERVAL_FOCUS=10 +export CHECK_INTERVAL_NORMAL=120 +# --- Log file --- +export LOG_FILE="/data/local/tmp/focus_mode/focus_mode.log" +export LOG_MAX_LINES=500 +export DISABLED_APPS_FILE="$STATE_DIR/disabled_by_focus.txt" +export MODE_FILE="$STATE_DIR/current_mode.txt" +# Status snapshot consumed by the companion notification app (focus_status_app). +# Written by focus_daemon.sh every loop iteration. Chmod 644 so apps can read. +export STATUS_FILE="$STATE_DIR/status.json" +# Trigger file: companion app (or user) touches this to request an immediate +# re-check. focus_daemon.sh polls for it and skips the remainder of its sleep. +export RECHECK_TRIGGER="$STATE_DIR/trigger_recheck" +# Escape hatch: if this file exists, curfew is suspended (treated as daytime) +# everywhere — app list, grayscale, DND and network. Delete it to re-arm. +# WHO CAN CREATE IT (recovery paths, strongest lock first): +# * `focus_ctl.sh curfew-off` over ADB from the PC (always available daily). +# * The Magisk boot emergency-disable file ($FOCUS_BOOT_EMERGENCY_DISABLE_FILE) +# stops the whole stack at next boot. +# * A root file-manager/terminal ONLY IF one is added to $NIGHT_WHITELIST. +# None is whitelisted by default — that is deliberate ("hard to turn off"). +# The active-IME guard in focus_daemon.sh keeps the keyboard alive so whichever +# path you choose is always typable. If you want a true no-PC 2am opt-out, +# whitelist a root terminal at night (see NIGHT_WHITELIST) or wire a companion- +# app button. Until then, recovery is PC/ADB-based by design. +export CURFEW_OVERRIDE_FILE="$STATE_DIR/curfew_override" +# --- Boot-time autostart safety gate --- +# Critical safety default: do NOT auto-start focus daemons at boot unless +# explicitly enabled. This avoids device instability during early boot on +# vendor ROMs after resets/updates. +# Late-auto mode: boot stack starts only after Android reports boot complete. +export FOCUS_BOOT_AUTOSTART=1 +# Extra grace period after sys.boot_completed. Keep at or below 10 seconds. +export FOCUS_BOOT_DELAY_SECONDS=10 +# Hard timeout while waiting for sys.boot_completed in Magisk service script. +export FOCUS_BOOT_WAIT_MAX_SECONDS=180 +# Emergency kill switch file: if this marker exists on phone, boot autostart +# is skipped entirely for this boot. Create with: +# adb shell su -c 'touch /data/local/tmp/focus_mode/disable_boot_autostart' +export FOCUS_BOOT_EMERGENCY_DISABLE_FILE="$STATE_DIR/disable_boot_autostart" +# --- Hosts enforcer state (see hosts_enforcer.sh) --- +# Canonical hosts file pushed by deploy.sh. The enforcer bind-mounts this +# over /system/etc/hosts and restores any tampering. +export HOSTS_CANONICAL="$STATE_DIR/hosts.canonical" +export HOSTS_TARGET="/system/etc/hosts" +export HOSTS_SHA_FILE="$STATE_DIR/hosts.sha256" +export HOSTS_CHECK_INTERVAL=15 +export HOSTS_LOG="$STATE_DIR/hosts_enforcer.log" +# Workout-variant canonical: same content as $HOSTS_CANONICAL but with all +# YouTube-related domain blocks removed. hosts_enforcer.sh switches to this +# variant when $WORKOUT_ACTIVE_FILE contains "1" (StrongLifts workout in +# progress) and switches back when it contains "0" or is missing. +export HOSTS_CANONICAL_WORKOUT="$STATE_DIR/hosts.canonical.workout" +export HOSTS_SHA_FILE_WORKOUT="$STATE_DIR/hosts.sha256.workout" +# Magisk Systemless Hosts module path. The enforcer keeps this in sync with +# the currently-active canonical so a fresh boot sees the right hosts file. +export HOSTS_MAGISK_MODULE_FILE="/data/adb/modules/hosts/system/etc/hosts" +# --- Workout detector (see workout_detector.sh) --- +# Polls the StrongLifts SQLite DB to determine whether a workout is in +# progress. A workout is "in progress" iff there is at least one row in +# `workouts` with start>0 AND (finish IS NULL OR finish=0). While true, +# YouTube is unblocked at the hosts level (see HOSTS_CANONICAL_WORKOUT). +export WORKOUT_DETECTOR_INTERVAL=10 +export WORKOUT_ACTIVE_FILE="$STATE_DIR/workout_active" +export WORKOUT_DETECTOR_LOG="$STATE_DIR/workout_detector.log" +# Static aarch64 sqlite3 binary pushed by deploy.sh. Built from the SQLite +# amalgamation against the Android NDK; ~1.6 MB. Stored outside the repo at +# ../testsAndMisc_binaries/phone_focus_mode/sqlite3 per binary-files policy. +export WORKOUT_SQLITE3_BIN="/data/local/tmp/focus_mode/sqlite3" +export WORKOUT_DB_PATH="/data/data/com.stronglifts.app/databases/StrongLifts-Database-3" +# StrongLifts package — must be in $WHITELIST so its DB stays writable while +# focus mode is enforcing. Used here only for status/log clarity. +export WORKOUT_TRIGGER_PACKAGE="com.stronglifts.app" +export BLOCKED_SYSTEM_APPS=" +# *** INTENTIONALLY EMPTY *** +# +# pm disable-user state persists across reboots. Android always kills daemon +# processes with SIGKILL during shutdown, bypassing the shell cleanup trap. +# Any system package left disabled across a reboot can trigger MTK bootloop +# protection → recovery → factory wipe (confirmed on BL9000). +# +# System/distraction apps are enforced via DNS+iptables in dns_enforcer.sh +# instead of persistent package-disable state. +" diff --git a/phone_focus_mode/config_tether.sh b/phone_focus_mode/config_tether.sh new file mode 100755 index 00000000..1986c148 --- /dev/null +++ b/phone_focus_mode/config_tether.sh @@ -0,0 +1,44 @@ +#!/system/bin/sh +# shellcheck shell=ash +# config_tether.sh — the tethering enforcer's settings: the feature switch, +# poll interval, log and state paths, the offload setting it snapshots, its +# iptables chain, and the override/force hooks. +# +# Sourced by config.sh where these definitions used to sit. None of these is +# read by focus_policy's loader; see the note in config_paths.sh. + +# ============================================================ +# HOTSPOT / TETHERING BLOCK (closes the "second phone" bypass) +# ============================================================ +# The hosts file, dns_enforcer and the curfew net layer all only touch this +# phone's OWN traffic (system resolver + OUTPUT chain). A tethered second +# device's packets are FORWARDed + NAT'd through us on an unfiltered path, so +# it browses freely on our mobile data. tether_enforcer.sh closes that hole. +# Always-on like dns_enforcer, but only ACTS while focus mode is ON (i.e. you +# are at home: $MODE_FILE == "focus") OR the force-test file is present. Covers +# WiFi, USB and Bluetooth tethering, since all of it traverses FORWARD. +export TETHER_ENFORCER_ENABLED=1 +export TETHER_CHECK_INTERVAL=5 +export TETHER_LOG="$STATE_DIR/tether_enforcer.log" +export TETHER_ENFORCER_STATE="$STATE_DIR/tether_applied" +# Lever 1: disable Android's hardware/BPF tether offload so forwarded traffic +# is actually seen by netfilter (otherwise the FORWARD rule can be silently +# skipped). AOSP global key Settings.Global.TETHER_OFFLOAD_DISABLED; 1=disabled. +# Snapshotted on entry and restored on exit so we never clobber a user value. +export TETHER_OFFLOAD_KEY="tether_offload_disabled" +export TETHER_OFFLOAD_SNAP="$STATE_DIR/tether_offload.snap" +# Lever 2: blanket REJECT of the FORWARD chain (both iptables + ip6tables). +# Re-pinned only when tampered (chain_intact gate, like dns_enforcer) so we do +# not fork an `iptables -L` every second — that pegged netd + overheated the +# phone during curfew-net tuning (see CURFEW_NET_REASSERT_INTERVAL note above). +export TETHER_IPT_CHAIN="FOCUS_TETHER_BLOCK" +# Lever 3 (best-effort, WiFi only): actively stop a running softAP each tick so +# the hotspot toggle visibly flips off. Version-gated (cmd wifi stop-softap is +# Android 11+). The FORWARD block remains the version-independent catch-all. +export TETHER_STOP_SOFTAP_ENABLED=1 +# Escape hatches (mirrors the curfew override/force pair): +# tether_override -> suspend the block without stopping the daemon. +# tether_force_on -> force the block ACTIVE regardless of location (daytime +# on-device validation via `focus_ctl.sh tether-test-on`). +export TETHER_OVERRIDE_FILE="$STATE_DIR/tether_override" +export TETHER_FORCE_FILE="$STATE_DIR/tether_force_on" diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index cf84e598..dc4c508f 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -372,6 +372,11 @@ do_deploy() { echo "[4/7] Uploading scripts..." adb_cmd push "$DEPLOY_DIR/config.sh" "/data/local/tmp/focus_stage/config.sh" + adb_cmd push "$DEPLOY_DIR/config_paths.sh" "/data/local/tmp/focus_stage/config_paths.sh" + adb_cmd push "$DEPLOY_DIR/config_dns.sh" "/data/local/tmp/focus_stage/config_dns.sh" + adb_cmd push "$DEPLOY_DIR/config_curfew.sh" "/data/local/tmp/focus_stage/config_curfew.sh" + adb_cmd push "$DEPLOY_DIR/config_tether.sh" "/data/local/tmp/focus_stage/config_tether.sh" + adb_cmd push "$DEPLOY_DIR/config_launcher.sh" "/data/local/tmp/focus_stage/config_launcher.sh" adb_cmd push "$DEPLOY_DIR/focus_daemon.sh" "/data/local/tmp/focus_stage/focus_daemon.sh" adb_cmd push "$DEPLOY_DIR/daemon_location.sh" "/data/local/tmp/focus_stage/daemon_location.sh" adb_cmd push "$DEPLOY_DIR/daemon_state.sh" "/data/local/tmp/focus_stage/daemon_state.sh" @@ -496,6 +501,11 @@ do_deploy() { # Move staged files into place with root adb_root "cp /data/local/tmp/focus_stage/config.sh $REMOTE_DIR/config.sh" + adb_root "cp /data/local/tmp/focus_stage/config_paths.sh $REMOTE_DIR/config_paths.sh" + adb_root "cp /data/local/tmp/focus_stage/config_dns.sh $REMOTE_DIR/config_dns.sh" + adb_root "cp /data/local/tmp/focus_stage/config_curfew.sh $REMOTE_DIR/config_curfew.sh" + adb_root "cp /data/local/tmp/focus_stage/config_tether.sh $REMOTE_DIR/config_tether.sh" + adb_root "cp /data/local/tmp/focus_stage/config_launcher.sh $REMOTE_DIR/config_launcher.sh" adb_root "cp /data/local/tmp/focus_stage/focus_daemon.sh $REMOTE_DIR/focus_daemon.sh" adb_root "cp /data/local/tmp/focus_stage/daemon_location.sh $REMOTE_DIR/daemon_location.sh" adb_root "cp /data/local/tmp/focus_stage/daemon_state.sh $REMOTE_DIR/daemon_state.sh" diff --git a/phone_focus_mode/docs/policy-lists.md b/phone_focus_mode/docs/policy-lists.md new file mode 100644 index 00000000..3c31c831 --- /dev/null +++ b/phone_focus_mode/docs/policy-lists.md @@ -0,0 +1,101 @@ +# Policy list rationale + +Prose moved out of `phone_focus_mode/config.sh` to keep that file under +the repo's 250-line cap. The lists themselves stay in `config.sh` and +must: `python_pkg/focus_policy/loader.py` finds them by regex-scanning +that file's text alone, so a list moved elsewhere silently parses as +empty. + +## Allowed Package Prefixes + +ALLOWED PACKAGE PREFIXES +Matched as prefixes on whole labels, exactly like $SYSTEM_NEVER_DISABLE: +"eu.kanade.tachiyomi" covers "eu.kanade.tachiyomi.sy" and +"eu.kanade.tachiyomi.extension.all.mangadex", but not +"eu.kanade.tachiyomisomething". +This exists because Tachiyomi installs every source as its OWN apk. Listing +them individually means each newly installed extension is invisible until +this file is edited and the policy regenerated -- a recurring chore that +looks exactly like a bug from the phone. +Weaker than the exact list by construction: a prefix allows packages that do +not exist yet. Keep the prefixes narrow and vendor-specific for that reason. + +## Night Curfew Whitelist + +NIGHT CURFEW WHITELIST +These are the ONLY third-party apps that stay enabled during the curfew +window (see NIGHT_CURFEW_* above). Everything else in $WHITELIST — browsers, +social, messaging, email, stores, transit — is disabled. +Allow-list by design: when in doubt, leave it OUT. +EXCEPTION: $NIGHT_ALLOWED_PREFIXES is applied on top of this list, and it +currently carries "eu.kanade.tachiyomi" — so manga IS available during the +curfew, deliberately (chosen 2026-08-14). This paragraph used to say manga +was disabled at night; it was true until that change. Do not "restore" it +without also emptying $NIGHT_ALLOWED_PREFIXES, or the comment and the +behaviour disagree again. +Parsed exactly like $WHITELIST (one package per line, '#' comments ignored). +The sysprotect prefixes ($SYSTEM_NEVER_DISABLE) and the default-handler guard +(dialer/SMS/home/browser/IME) still apply on TOP of this list, so the active +keyboard and core system apps are protected even if omitted here. + +## System packages that must never be disabled + +--- System / essential packages that must NEVER be disabled --- +These are matched as prefixes (startswith). +You generally don't need to edit this list. +pl.infakt.infakt is the one non-system entry. Allowlisting it is weaker: +that depends on it staying in BOTH the day and night lists, and dropping it +from either would silently make it hideable. It is device-paired to a bank +over SMS, so losing access to it strands the same re-authentication chain a +hidden Messages app would. isAllowed() checks this list first, before the +curfew split, so it holds under every condition. + +## Whitelisted apps + +WHITELISTED APPS +These apps will ALWAYS remain enabled, even in focus mode. +Package names verified against installed packages on 2026-02-22. + +## Night curfew + +NIGHT CURFEW (time-gated strict allow-list) +When focus mode is ON (i.e. you are at home) AND the local clock is inside +the curfew window, the daemon switches from the permissive $WHITELIST to the +strict $NIGHT_WHITELIST: every app not on that short list is disabled. This +is the "stop using the phone after 23:00 at home" layer. The companion +enforcer (curfew_enforcer.sh) adds grayscale + DND + an optional per-UID +network allow-list on top. Times are local 24h "HHMM"; the window wraps past +midnight when START > END (e.g. 2300 -> 0500). + +## Why dev.kuhy.todo is in the night list + +Capture-only notes app. Added 2026-08-14 after a curfew-window deploy +installed it and the enforcer removed the package ~80ms later: it was in +the day list but not here, so any build shipped after 23:00 was silently +uninstalled. This is a deliberate loosening of the answer-the-phone / +reach-a-bank / handle-an-emergency rule above -- writing an idea down at +night is the one thing this app does, and losing the deploy path for six +hours a day cost more than the distraction risk. + +## Why the Play Store is in the day list + +--- App store (day only; deliberately absent from the night list) --- +NB: never write a dollar-sign variable reference inside this list. WHITELIST +is a double-quoted string, so even a comment line expands, and deploy.sh +runs under set -u, where an undefined name aborts the whole deploy. A +reference to the night list sat here and did exactly that (it is defined +below this point, so it was still unset), blocking every focus-mode deploy. +Same rule for double quotes: one in a comment ends the string early. +infakt cannot be installed or updated without Play, and it is device-paired +to a bank, so losing the ability to update it strands a re-authentication +chain. This does NOT reopen YouTube: the sweep is default-deny for +third-party packages, so anything installed from Play is hidden on the next +at-home pass, and the always-blocked set (YouTube, Chrome) is never restored +by the AWAY branch either. Reinstalling YouTube from Play is UNTESTED as a +bypass: +the sweep would re-hide it within one pass regardless, but treat the claim +that Play cannot resurrect a blocked app as unverified until someone tries. +NOTE: never use a double-quote character anywhere inside these export +blocks, not even in a comment. The policy loader matches a quoted value up +to the next quote, so one stray quote terminates the string early and +silently truncates the allowlist -- which drops apps with no error at all. diff --git a/phone_focus_mode/lib/tests/daemon_cases_state.sh b/phone_focus_mode/lib/tests/daemon_cases_state.sh index 2aa5e03a..d9131576 100755 --- a/phone_focus_mode/lib/tests/daemon_cases_state.sh +++ b/phone_focus_mode/lib/tests/daemon_cases_state.sh @@ -52,9 +52,10 @@ if _log | grep -q "WARN: whitelist suspiciously small"; then else _t_pass "a healthy whitelist does not warn" fi -_t_eq "40" "$(wc -l <"${STATE_DIR}/whitelist.txt")" "whitelist keeps every entry" -_t_eq "40" "$(printf '%s\n' "${WHITELIST}" | wc -l)" \ - "the seeded whitelist really held 40 entries" +# Both sides checked at once: the parsed file must hold every seeded entry, +# and reading WHITELIST back gives it a reader in this file (SC2034). +_t_eq "40-40" "$(wc -l <"${STATE_DIR}/whitelist.txt")-$(printf '%s\n' "${WHITELIST}" | wc -l)" \ + "the whitelist keeps every one of the 40 seeded entries" # --- build_night_whitelist_file -------------------------------------------- @@ -76,8 +77,7 @@ esac _reset_dev NIGHT_WHITELIST="$(for i in $(seq 1 12); do printf 'com.night%02d\n' "$i"; done)" build_night_whitelist_file -_t_eq "12" "$(printf '%s\n' "${NIGHT_WHITELIST}" | wc -l)" \ - "the seeded night whitelist really held 12 entries" +_t_eq "12" "$(printf '%s\n' "${NIGHT_WHITELIST}" | wc -l)" "12 night entries were seeded" if _log | grep -q "WARN: night whitelist"; then _t_fail "12 night entries is above the night floor and must not warn" else @@ -91,8 +91,6 @@ SYSTEM_NEVER_DISABLE="com.android. com.google.android.gms" build_sysprotect_file _t_eq "2" "$(wc -l <"${STATE_DIR}/sysprotect.txt")" "sysprotect writes every prefix" -_t_eq "2" "$(printf '%s\n' "${SYSTEM_NEVER_DISABLE}" | wc -l)" \ - "the seeded sysprotect list really held two prefixes" BLOCKED_SYSTEM_APPS="com.android.browser # not this one @@ -101,8 +99,10 @@ build_blocked_sys_file _t_eq "com.android.browser com.android.chrome" \ "$(tr '\n' ' ' <"${STATE_DIR}/blocked_sys.txt" | sed 's/ $//')" \ "blocked-system list drops comments" -_t_eq "3" "$(printf '%s\n' "${BLOCKED_SYSTEM_APPS}" | wc -l)" \ - "the seeded blocked-system list really held three lines" +# Read the two seeds back so each has a reader in this file (SC2034); the +# subject is what actually consumes them. +_t_eq "2-3" "$(printf '%s\n' "${SYSTEM_NEVER_DISABLE}" | wc -l)-$(printf '%s\n' "${BLOCKED_SYSTEM_APPS}" | wc -l)" \ + "the seeded sysprotect and blocked-system lists held 2 and 3 lines" # --- refresh_default_handlers ---------------------------------------------- @@ -182,76 +182,3 @@ if ! is_default_handler "com.launcher"; then else _t_fail "a prefix must not match a default handler" fi - -# --- write_status_snapshot -------------------------------------------------- - -_reset_dev -printf 'com.a\ncom.b\ncom.c\n' >"${DISABLED_APPS_FILE}" -_set_now "1200" -write_status_snapshot "focus" "52.2297" "21.0122" "42" "100" - -if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${STATUS_FILE}"; then - _t_pass "the status snapshot is parseable JSON" -else - _t_fail "the status snapshot must be valid JSON" -fi - -_field() { - python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' \ - "${STATUS_FILE}" "$1" -} - -_t_eq "focus" "$(_field mode)" "the snapshot records the mode" -_t_eq "52.2297" "$(_field lat)" "the snapshot records the latitude" -_t_eq "42" "$(_field distance_m)" "the snapshot records the distance" -_t_eq "100" "$(_field threshold_m)" "the snapshot records the threshold" -_t_eq "3" "$(_field disabled_count)" "the snapshot counts the disabled apps" - -# Absent coordinates must still produce valid JSON: distance and threshold -# become null, not an empty token that would break the companion app's parse. -_reset_dev -: >"${DISABLED_APPS_FILE}" -write_status_snapshot "normal" "" "" "" "" -if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${STATUS_FILE}"; then - _t_pass "a snapshot with no fix is still valid JSON" -else - _t_fail "a snapshot with no fix must remain valid JSON" -fi -_t_eq "None" "$(_field distance_m)" "a missing distance is written as JSON null" -_t_eq "0" "$(_field disabled_count)" "an empty disabled list counts as zero" - -# The curfew flags are what the companion app renders, so they must track the -# files rather than being hardcoded. -_reset_dev -touch "${CURFEW_OVERRIDE_FILE}" -write_status_snapshot "normal" "" "" "" "" -_t_eq "1" "$(_field curfew_override)" "the snapshot reports an active override" -_t_eq "0" "$(_field curfew)" "curfew reads inactive while overridden" -rm -f "${CURFEW_OVERRIDE_FILE}" - -_reset_dev -touch "${CURFEW_FORCE_FILE}" -write_status_snapshot "normal" "" "" "" "" -_t_eq "1" "$(_field curfew_force)" "the snapshot reports the force flag" -_t_eq "1" "$(_field curfew)" "curfew reads active while forced" -rm -f "${CURFEW_FORCE_FILE}" - -if [[ -f "${STATUS_FILE}.tmp" ]]; then - _t_fail "the snapshot left its temp file behind" -else - _t_pass "the snapshot moves its temp file into place" -fi - -# An unwritable status file must not take the daemon down with it: the -# snapshot is for the companion app, and losing it is not worth losing -# enforcement. Proven by pointing STATUS_FILE into a directory that does not -# exist, so the redirect fails. -_reset_dev -_saved_status="${STATUS_FILE}" -STATUS_FILE="${RUN}/state/no-such-dir/status.json" -if write_status_snapshot "focus" "" "" "" ""; then - _t_pass "an unwritable snapshot returns success rather than aborting" -else - _t_fail "a failed snapshot write must not propagate a failure" -fi -STATUS_FILE="${_saved_status}" diff --git a/phone_focus_mode/lib/tests/daemon_cases_status.sh b/phone_focus_mode/lib/tests/daemon_cases_status.sh new file mode 100755 index 00000000..49fd17a2 --- /dev/null +++ b/phone_focus_mode/lib/tests/daemon_cases_status.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# lib/tests/daemon_cases_status.sh — assertions for write_status_snapshot, the +# JSON file the companion notification app polls. +# +# Sourced by test_daemon_libs.sh after daemon_cases_state.sh, which owns the +# stubs and counters. Split out to stay under the 250-line cap. +set -euo pipefail + +# --- write_status_snapshot -------------------------------------------------- + +_reset_dev +printf 'com.a\ncom.b\ncom.c\n' >"${DISABLED_APPS_FILE}" +_set_now "1200" +write_status_snapshot "focus" "52.2297" "21.0122" "42" "100" + +if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${STATUS_FILE}"; then + _t_pass "the status snapshot is parseable JSON" +else + _t_fail "the status snapshot must be valid JSON" +fi + +_field() { + python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' \ + "${STATUS_FILE}" "$1" +} + +_t_eq "focus" "$(_field mode)" "the snapshot records the mode" +_t_eq "52.2297" "$(_field lat)" "the snapshot records the latitude" +_t_eq "42" "$(_field distance_m)" "the snapshot records the distance" +_t_eq "100" "$(_field threshold_m)" "the snapshot records the threshold" +_t_eq "3" "$(_field disabled_count)" "the snapshot counts the disabled apps" + +# Absent coordinates must still produce valid JSON: distance and threshold +# become null, not an empty token that would break the companion app's parse. +_reset_dev +: >"${DISABLED_APPS_FILE}" +write_status_snapshot "normal" "" "" "" "" +if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${STATUS_FILE}"; then + _t_pass "a snapshot with no fix is still valid JSON" +else + _t_fail "a snapshot with no fix must remain valid JSON" +fi +_t_eq "None" "$(_field distance_m)" "a missing distance is written as JSON null" +_t_eq "0" "$(_field disabled_count)" "an empty disabled list counts as zero" + +# The curfew flags are what the companion app renders, so they must track the +# files rather than being hardcoded. +_reset_dev +touch "${CURFEW_OVERRIDE_FILE}" +write_status_snapshot "normal" "" "" "" "" +_t_eq "1" "$(_field curfew_override)" "the snapshot reports an active override" +_t_eq "0" "$(_field curfew)" "curfew reads inactive while overridden" +rm -f "${CURFEW_OVERRIDE_FILE}" + +_reset_dev +touch "${CURFEW_FORCE_FILE}" +write_status_snapshot "normal" "" "" "" "" +_t_eq "1" "$(_field curfew_force)" "the snapshot reports the force flag" +_t_eq "1" "$(_field curfew)" "curfew reads active while forced" +rm -f "${CURFEW_FORCE_FILE}" + +if [[ -f "${STATUS_FILE}.tmp" ]]; then + _t_fail "the snapshot left its temp file behind" +else + _t_pass "the snapshot moves its temp file into place" +fi + +# An unwritable status file must not take the daemon down with it: the +# snapshot is for the companion app, and losing it is not worth losing +# enforcement. Proven by pointing STATUS_FILE into a directory that does not +# exist, so the redirect fails. +_reset_dev +_saved_status="${STATUS_FILE}" +STATUS_FILE="${RUN}/state/no-such-dir/status.json" +if write_status_snapshot "focus" "" "" "" ""; then + _t_pass "an unwritable snapshot returns success rather than aborting" +else + _t_fail "a failed snapshot write must not propagate a failure" +fi +STATUS_FILE="${_saved_status}" diff --git a/phone_focus_mode/lib/tests/test_daemon_libs.sh b/phone_focus_mode/lib/tests/test_daemon_libs.sh index b221eb33..4ee6ad04 100755 --- a/phone_focus_mode/lib/tests/test_daemon_libs.sh +++ b/phone_focus_mode/lib/tests/test_daemon_libs.sh @@ -14,6 +14,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "${SCRIPT_DIR}/daemon_cases_location.sh" # shellcheck source=daemon_cases_state.sh . "${SCRIPT_DIR}/daemon_cases_state.sh" +# shellcheck source=daemon_cases_status.sh +. "${SCRIPT_DIR}/daemon_cases_status.sh" # shellcheck source=daemon_cases_apps.sh . "${SCRIPT_DIR}/daemon_cases_apps.sh" # shellcheck source=daemon_cases_sweep.sh