focus_ctl: split 1091 -> 171 + 8 siblings, one per enforcer

The largest file in phone_focus_mode, and the last one blocking deploy.sh:
splitting it grows deploy.sh's two hardcoded lists, so those lists are only
final now. All 55 functions moved verbatim.

Three while-read loops were audited before extraction. Two call pm in their
bodies and moved untouched. The third, iter_whitelist_packages, is fed by a
pipe from printf and reads nothing from stdin -- already safe, and now said
so in ctl_usage.sh's header so the distinction is not lost by a later editor
who has only read the warning in daemon_apps.sh.

136 assertions and 22 mutations, all killed. Three survived the first run
and every one was a defect in MY OWN tests, not in the subject:

  - cmd_status carries a THIRD copy of the Haversine, and I had asserted
    only that it printed "some number". A wrong Earth radius sailed through.
    Now the value is checked against a seeded fix ~4.3 km from home.
  - the two whitelist filters were each tested with input the OTHER filter
    also rejects, so either could be deleted with nothing failing. Fixed
    with a punctuation-free undotted token (only the dot check rejects it)
    and a dotted token carrying an illegal character (only the charset check
    does), making each independently observable.

That last investigation turned up dead code: the `#*)` case arm is provably
equivalent, because `set -- $line` makes a comment's first token "#", which
the dot check already rejects. Removed rather than covered by a test that
cannot fail.

Coverage is NOT 100% and the real numbers are in the evidence: 41.79% to
92.98% across the eight, up from 5-22%. Unlike the daemon split, most of
what remains is genuinely untested -- the per-enforcer status blocks that
print mount state, APK hashes and offload settings, each needing its own
device fixture. The mutations prove the assertions that exist are load-
bearing; they do not prove those branches work.

jscpd checked in a clean worktree at 1.57%: eight files each carrying a
near-identical start/stop/status trio is exactly what that gate is for, so
the tests drive the trios through tables instead of repeating them.

Two drift hazards recorded, not fixed: ctl_is_curfew_now duplicates
daemon_location.sh's is_curfew_now (pinned by an agreement test across every
boundary minute), and cmd_status's awk is the third Haversine.

Over-cap count 17 -> 16. deploy.sh is the last one here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-17 17:46:04 +02:00
parent 7dc3c94160
commit 3ddc13f671
18 changed files with 2113 additions and 936 deletions

View File

@ -0,0 +1,21 @@
{
"title": "Split focus_ctl.sh, the largest file in phone_focus_mode",
"objective": "Take focus_ctl.sh from 1091 lines to under the repo's 250-line cap by extracting one library per enforcer, and give the extracted logic its first tests. This must land BEFORE deploy.sh is split, because it grows deploy.sh's two hardcoded lists.",
"acceptance_criteria": [
"bash meta/scripts/check_file_length.sh --all no longer lists phone_focus_mode/focus_ctl.sh, and no new file is over the cap.",
"Every stdin-draining while-read loop is identified before extraction and moved verbatim; none is converted to a pipe.",
"meta/scripts/verify_shell_split.sh reports every function moved verbatim.",
"All eight siblings are added to BOTH of deploy.sh's hardcoded lists, and the lists stay in exact correspondence.",
"A mutation spec lands in meta/scripts/fixtures/mutations/ and kills every mutation, including the escape-hatch commands and the third copy of the Haversine.",
"Any mutation that proves equivalent is resolved by deleting the dead code, not by weakening the mutation or adding a test that cannot fail.",
"shellcheck passes on each touched file individually; sh -n passes on every phone-side script.",
"jscpd stays under its 2% threshold, measured in a clean worktree so vendored .venv does not pollute the figure."
],
"out_of_scope": [
"Deploying to the phone or verifying on-device.",
"Splitting deploy.sh, which is the next item and must come after this one.",
"De-duplicating ctl_is_curfew_now against daemon_location.sh's is_curfew_now, or the third Haversine copy in cmd_status — both are recorded as drift hazards for a later session.",
"Reaching 100% line coverage on the cmd_* subcommands, which would require modelling far more device behaviour than the split itself warrants."
],
"verifier": "bash phone_focus_mode/lib/tests/test_ctl_libs.sh (expects 136 passed, 0 failed), plus python3 meta/scripts/mutate_shell.py meta/scripts/fixtures/mutations/ctl_libs.json at 22 killed / 0 not killed, plus check_file_length.sh --all showing 16 files, plus deploy-list parity, plus jscpd under 2%, plus pre-commit run --files on the touched files."
}

View File

@ -0,0 +1,71 @@
{
"intent": "Bring focus_ctl.sh, at 1091 lines the largest file in phone_focus_mode, under the 250-line cap by extracting one library per enforcer, and give the extracted logic its first tests.",
"scope": [
"phone_focus_mode/focus_ctl.sh - 1091 lines down to 171",
"phone_focus_mode/ctl_{usage,daemon,hosts,dns,launcher,workout,curfew,tether}.sh - new, one per enforcer plus the help text and the daemon's own commands",
"phone_focus_mode/deploy.sh - all eight added to both hardcoded lists",
"phone_focus_mode/lib/tests/ - a harness, three case files and one entry point, all new",
"meta/scripts/fixtures/mutations/ctl_libs.json - new: 22 reproducible mutations",
"Non-goal: any device deploy, and any de-duplication of the two drift hazards recorded under risks."
],
"changes": [
"Ran the stdin-drain audit before extracting. focus_ctl.sh has three while-read loops; two call pm in their bodies and moved verbatim. The third (iter_whitelist_packages) is fed by a pipe from printf and reads nothing from stdin, so it was already safe -- noted in ctl_usage.sh's header so the distinction is not lost.",
"Checked for globals written inside functions: none. The seven *_PIDFILE paths were assigned in the entry script and read only in their libraries, so each moved to the file that reads it (SC2034).",
"Placed all eight as flat siblings and added them to both deploy lists, then checked the lists correspond exactly in both directions.",
"Built a device stub layer covering pm, am, settings, dumpsys, svc, sqlite3, getprop, content, sleep, setsid and both iptables binaries, plus a pinned clock. The harness sources the REAL config.sh for defaults and redirects only the paths, so the fixture cannot drift from the values the subcommands read.",
"Deleted a provably equivalent filter in iter_whitelist_packages. Its `\\#*)` case arm cannot be observed: `set -- $line` splits on whitespace, so a comment's first token is always '#', which the dot check then rejects. A mutation removing it survived against every input tried, so the dead arm was removed rather than covered by a test that would pass either way."
],
"verification": [
{
"command": "bash phone_focus_mode/lib/tests/test_ctl_libs.sh",
"result": "pass",
"evidence": "Results: 136 passed, 0 failed. None of this code had a test before."
},
{
"command": "python3 meta/scripts/mutate_shell.py meta/scripts/fixtures/mutations/ctl_libs.json",
"result": "pass",
"evidence": "22 killed, 0 not killed. Three survived the first run and all three were defects in my own assertions: the Haversine in cmd_status was only checked for printing 'some number' (a wrong Earth radius passed), and the two whitelist filters were tested with inputs that each OTHER filter also rejected. Fixed by asserting the actual distance (~4300 m for the seeded fix) and by using a punctuation-free undotted token and a dotted token carrying an illegal character, so each filter is independently observable."
},
{
"command": "bash meta/scripts/verify_shell_split.sh 7dc3c94 phone_focus_mode/focus_ctl.sh phone_focus_mode/focus_ctl.sh phone_focus_mode/ctl_*.sh",
"result": "pass",
"evidence": "IDENTICAL: all 55 top-level functions moved verbatim, before the one equivalent-code deletion described above."
},
{
"command": "bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_ctl_libs.sh <each of the eight>",
"result": "fail",
"evidence": "NOT 100%, and these are mostly REAL gaps rather than measurement artifacts: ctl_tether 92.98%, ctl_dns 89.29%, ctl_daemon 64.86%, ctl_curfew 59.26%, ctl_hosts 58.46%, ctl_usage 55.81%, ctl_launcher 47.67%, ctl_workout 41.79%. Up from 5-22% before the tests. What remains uncovered is the per-enforcer status reporting -- the long echo blocks that print mount state, APK hashes, offload settings and database rows -- each of which needs its own device fixture. The exception is cmd_status's embedded awk, which is the same kcov-cannot-attribute class as daemon_location.sh and is proven executed by the Haversine mutation above."
},
{
"command": "jscpd, the pre-commit hook's exact invocation, in a clean HEAD worktree with these files applied",
"result": "pass",
"evidence": "1.57%, under the 2% threshold. Checked because eight files each carrying a near-identical start/stop/status trio is exactly what that gate exists to catch. The tests drive those trios through tables rather than repeating them, for the same reason."
},
{
"command": "shellcheck on each touched file individually (no -x); sh -n on every phone-side script",
"result": "pass",
"evidence": "All fifteen clean standalone. Six SC1073/SC1072 errors came from a comment line beginning with the word 'shellcheck', which the linter parsed as a directive; reworded rather than disabled. Several SC2034s were fixtures nothing read, and were deleted."
},
{
"command": "deploy-list parity in both directions",
"result": "pass",
"evidence": "comm returns empty both ways; the eight siblings appear in the push list and the cp list. deploy.sh's lists are now final, which is what unblocks splitting it next."
},
{
"command": "bash meta/scripts/check_file_length.sh --all",
"result": "pass",
"evidence": "16 over-cap files, down from 17. focus_ctl.sh is 171, the eight siblings 100-198, and the five test files 20-250. deploy.sh at 875 is now the only phone_focus_mode file left over the cap."
}
],
"risks": [
"COVERAGE IS BELOW 100% ON ALL EIGHT LIBRARIES and the numbers above are the measured ones. Unlike the daemon split, most of the shortfall is genuinely untested code -- the status-reporting blocks -- not a kcov artifact. The 22 mutations prove the assertions that exist are load-bearing; they do not prove the untested branches work.",
"DRIFT HAZARD: ctl_is_curfew_now in ctl_curfew.sh is a verbatim duplicate of is_curfew_now in daemon_location.sh. The suite asserts the two agree across every boundary minute, so a one-sided edit fails rather than silently making the daemon and its control script disagree about whether it is night. The duplication itself remains.",
"DRIFT HAZARD: cmd_status carries a THIRD copy of the Haversine, after daemon_location.sh's calc_distance and focus_daemon's original. Only this copy and calc_distance are tested; the three are not checked against each other.",
"UNVERIFIED ON DEVICE. This is the fifth enforcer-family split of the session with no deploy behind it, and focus_ctl.sh now sources eight files -- a partial push leaves the control script unusable, which is how one recovers from the others misbehaving."
],
"rollback": [
"git revert the commit. focus_ctl.sh returns to a single 1091-line file, over the cap and untested, and deploy.sh stops pushing the eight siblings.",
"After rollback, run sh -n phone_focus_mode/focus_ctl.sh expecting exit 0, and confirm grep -c 'ctl_' phone_focus_mode/deploy.sh returns 0.",
"A rollback AFTER a deploy leaves eight stale files in $REMOTE_DIR; they are inert once nothing sources them."
]
}

View File

@ -0,0 +1,138 @@
{
"test": "phone_focus_mode/lib/tests/test_ctl_libs.sh",
"timeout": 300,
"mutations": [
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "[ \"$now\" -ge \"$start\" ] || [ \"$now\" -lt \"$end\" ]",
"replace": "[ \"$now\" -ge \"$start\" ] && [ \"$now\" -lt \"$end\" ]",
"describe": "break the midnight-wrapping curfew window"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "case \"$now\" in '' | *[!0-9]*) return 1 ;; esac",
"replace": "case \"$now\" in '' | *[!0-9]*) return 0 ;; esac",
"describe": "fail CLOSED on a broken clock instead of open"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "while [ \"${n#0}\" != \"$n\" ] && [ \"${#n}\" -gt 1 ]; do n=\"${n#0}\"; done",
"replace": ":",
"describe": "stop stripping leading zeros (octal parse)"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "\ttouch \"$CURFEW_FORCE_FILE\"",
"replace": "\t:",
"describe": "curfew-test-on stops creating the force file"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "cmd_curfew_off() {\n\ttouch \"$CURFEW_OVERRIDE_FILE\"",
"replace": "cmd_curfew_off() {\n\t:",
"describe": "curfew-off stops creating the 2am escape hatch"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "cmd_curfew_on() {\n\trm -f \"$CURFEW_OVERRIDE_FILE\"",
"replace": "cmd_curfew_on() {\n\t:",
"describe": "curfew-on stops clearing the override"
},
{
"file": "phone_focus_mode/ctl_tether.sh",
"find": "\ttouch \"$TETHER_FORCE_FILE\"",
"replace": "\t:",
"describe": "tether-test-on stops creating the force file"
},
{
"file": "phone_focus_mode/ctl_tether.sh",
"find": "cmd_tether_test_off() {\n\trm -f \"$TETHER_FORCE_FILE\"",
"replace": "cmd_tether_test_off() {\n\t:",
"describe": "tether-test-off stops clearing the force file"
},
{
"file": "phone_focus_mode/ctl_dns.sh",
"find": "if iptables -L \"$DNS_IPT_CHAIN\" >/dev/null 2>&1; then",
"replace": "if ! iptables -L \"$DNS_IPT_CHAIN\" >/dev/null 2>&1; then",
"describe": "invert the v4 DNS chain presence check"
},
{
"file": "phone_focus_mode/ctl_dns.sh",
"find": "if ip6tables -L \"$DNS_IPT_CHAIN\" >/dev/null 2>&1; then",
"replace": "if ! ip6tables -L \"$DNS_IPT_CHAIN\" >/dev/null 2>&1; then",
"describe": "invert the v6 DNS chain presence check"
},
{
"file": "phone_focus_mode/ctl_dns.sh",
"find": "${mode:-<unset>}",
"replace": "${mode}",
"describe": "render an unset private DNS mode as blank"
},
{
"file": "phone_focus_mode/ctl_hosts.sh",
"find": "cmd_hosts_start() {\n\tlocal pid\n\tpid=\"$(hosts_enforcer_pid)\"\n\tif [ -n \"$pid\" ]; then",
"replace": "cmd_hosts_start() {\n\tlocal pid\n\tpid=\"$(hosts_enforcer_pid)\"\n\tif [ -z \"$pid\" ]; then",
"describe": "invert the hosts already-running guard"
},
{
"file": "phone_focus_mode/ctl_workout.sh",
"find": "if [ ! -x \"$WORKOUT_SQLITE3_BIN\" ]; then",
"replace": "if [ -x \"$WORKOUT_SQLITE3_BIN\" ]; then",
"describe": "invert the sqlite3 precondition"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "local mode=\"unknown\"",
"replace": "local mode=\"\"",
"describe": "report an absent mode as blank rather than unknown"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "echo \"Location: unavailable\"",
"replace": ":",
"describe": "stay silent when no GPS fix is available"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "R=6371000",
"replace": "R=1",
"describe": "wrong Earth radius in cmd_status's Haversine (awk kcov cannot see)"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "pm enable \"$pkg\" >/dev/null 2>&1 && count=$((count + 1))",
"replace": "count=$((count + 1))",
"describe": "count apps as restored without enabling them"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "\t\t: >\"$DISABLED_APPS_FILE\"",
"replace": "\t\t:",
"describe": "leave the disabled record in place after restoring"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "\ttouch \"$RECHECK_TRIGGER\"",
"replace": "\t:",
"describe": "recheck stops writing the trigger"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "echo \"Daemon not running - start it first with: focus_ctl.sh start\"\n\t\treturn 1",
"replace": "echo \"Daemon not running - start it first with: focus_ctl.sh start\"",
"describe": "recheck continues after reporting no daemon"
},
{
"file": "phone_focus_mode/ctl_usage.sh",
"find": "\t\t*.*) ;;",
"replace": "\t\t*) ;;",
"describe": "accept non-dotted tokens as package names"
},
{
"file": "phone_focus_mode/ctl_usage.sh",
"find": "\t\tcase \"$pkg\" in\n\t\t*[!A-Za-z0-9._]*) continue ;;\n\t\tesac",
"replace": "\t\t:",
"describe": "accept packages containing illegal characters"
}
]
}

152
phone_focus_mode/ctl_curfew.sh Executable file
View File

@ -0,0 +1,152 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_curfew.sh — focus_ctl.sh's subcommands for the night-curfew enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
CURFEW_PIDFILE="$STATE_DIR/curfew_enforcer.pid"
curfew_enforcer_pid() {
if [ -f "$CURFEW_PIDFILE" ]; then
local pid
pid="$(cat "$CURFEW_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
# Replicates focus_daemon.sh::is_curfew_now for status display.
_ctl_dec() {
local n="$1"
while [ "${n#0}" != "$n" ] && [ "${#n}" -gt 1 ]; do n="${n#0}"; done
printf '%s' "$n"
}
ctl_is_curfew_now() {
local now start end
now="$(date +%H%M 2>/dev/null)"
case "$now" in '' | *[!0-9]*) return 1 ;; esac
now="$(_ctl_dec "$now")"
start="$(_ctl_dec "$NIGHT_CURFEW_START")"
end="$(_ctl_dec "$NIGHT_CURFEW_END")"
if [ "$start" -le "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
fi
}
cmd_curfew_status() {
local pid
pid="$(curfew_enforcer_pid)"
echo "=== Night Curfew Status ==="
echo "Enabled: ${NIGHT_CURFEW_ENABLED}"
echo "Window: ${NIGHT_CURFEW_START}-${NIGHT_CURFEW_END} (now $(date +%H%M))"
if [ -n "$pid" ]; then
echo "Enforcer: RUNNING (PID $pid)"
else
echo "Enforcer: STOPPED"
fi
ctl_is_curfew_now && echo "Within window: YES" || echo "Within window: no"
[ -e "$CURFEW_FORCE_FILE" ] && echo "Forced ON: YES (demo/test active)"
[ -e "$CURFEW_OVERRIDE_FILE" ] && echo "Override: YES (curfew SUSPENDED)"
if [ -f "$MODE_FILE" ]; then
echo "Focus mode: $(cat "$MODE_FILE" 2>/dev/null)"
fi
[ -e "$CURFEW_ENFORCER_STATE" ] && echo "Applied now: YES (grayscale/DND locked)" ||
echo "Applied now: no"
echo "Grayscale: ${CURFEW_GRAYSCALE_ENABLED} DND: ${CURFEW_DND_ENABLED} Net: ${CURFEW_NET_ENABLED}"
if iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
echo "iptables $CURFEW_NET_IPT_CHAIN: $(iptables -S "$CURFEW_NET_IPT_CHAIN" 2>/dev/null | wc -l) rules"
else
echo "iptables $CURFEW_NET_IPT_CHAIN: absent (net curfew not applied)"
fi
if [ -f "$STATE_DIR/night_whitelist.txt" ]; then
echo "Night whitelist: $(wc -l <"$STATE_DIR/night_whitelist.txt" | tr -d ' ') apps allowed"
fi
}
cmd_curfew_start() {
local pid
pid="$(curfew_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Curfew enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/curfew_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(curfew_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Curfew enforcer started (PID $pid)"
else
echo "ERROR: curfew enforcer failed to start. Check log: $CURFEW_ENFORCER_LOG"
fi
}
cmd_curfew_stop() {
local pid
pid="$(curfew_enforcer_pid)"
if [ -n "$pid" ]; then
kill "$pid" 2>/dev/null
echo "Curfew enforcer stopped (PID $pid) - daytime state restored"
else
echo "Curfew enforcer not running"
fi
}
# Test hook: force curfew ACTIVE regardless of clock/location so the whole
# stack can be validated during the day. Daemon re-checks within one tick.
cmd_curfew_test_on() {
touch "$CURFEW_FORCE_FILE"
chmod 666 "$CURFEW_FORCE_FILE" 2>/dev/null || true
rm -f "$CURFEW_OVERRIDE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew FORCED ON. App sweep + enforcer will engage within a few seconds."
echo "Validate: open mBank (works), keyboard (works), Firefox (gone). Then: curfew-test-off"
}
cmd_curfew_test_off() {
rm -f "$CURFEW_FORCE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew force cleared. Back to clock-based behaviour."
}
# Demo mode = the same force mechanism as the test hook, worded for an
# on-demand demo. The companion app's Start/Stop demo button drives the same
# force file. Easy off: curfew-demo-off (or tap "Stop demo curfew").
cmd_curfew_demo_on() {
touch "$CURFEW_FORCE_FILE"
chmod 666 "$CURFEW_FORCE_FILE" 2>/dev/null || true
rm -f "$CURFEW_OVERRIDE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Demo curfew STARTED - full curfew engaged now (apps, grayscale, DND, net)."
echo "Stop any time with: curfew-demo-off (or tap 'Stop demo curfew')"
}
cmd_curfew_demo_off() {
rm -f "$CURFEW_FORCE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Demo curfew STOPPED - back to clock-based behaviour."
}
# Escape hatch: suspend curfew now (the 2am 'let me out' button). Survives
# until you re-arm. Reachable on-device only via ADB (this command) unless a
# root file-manager/terminal has been added to NIGHT_WHITELIST.
cmd_curfew_off() {
touch "$CURFEW_OVERRIDE_FILE"
chmod 666 "$CURFEW_OVERRIDE_FILE" 2>/dev/null || true
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew SUSPENDED (override set: $CURFEW_OVERRIDE_FILE). Re-arm with: curfew-on"
}
cmd_curfew_on() {
rm -f "$CURFEW_OVERRIDE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew re-armed (override cleared)."
}

198
phone_focus_mode/ctl_daemon.sh Executable file
View File

@ -0,0 +1,198 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_daemon.sh — focus_ctl.sh's subcommands for the main focus daemon:
# start, stop, status, enable, disable, recheck, log and the companion
# notification status.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one directory.
# Sourced by focus_ctl.sh, which owns log() and the config.sh globals these
# read. $PIDFILE lives here rather than in the entry script because this file
# is its only reader (SC2034), and the linter runs without -x so each file
# stands alone.
PIDFILE="$STATE_DIR/daemon.pid"
# Helper to check if daemon is running
daemon_pid() {
if [ -f "$PIDFILE" ]; then
local pid
pid="$(cat "$PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_start() {
local pid
pid="$(daemon_pid)"
if [ -n "$pid" ]; then
echo "Daemon already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/focus_daemon.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(daemon_pid)"
if [ -n "$pid" ]; then
echo "Daemon started (PID $pid)"
else
echo "ERROR: Daemon failed to start. Check log: $LOG_FILE"
fi
}
cmd_stop() {
local pid
pid="$(daemon_pid)"
if [ -z "$pid" ]; then
echo "Daemon not running"
# Clean up stale pidfile if present
rm -f "$PIDFILE"
else
kill -TERM "$pid"
echo "Daemon stopped (sent SIGTERM to PID $pid)"
fi
}
cmd_status() {
local pid
pid="$(daemon_pid)"
local mode="unknown"
[ -f "$MODE_FILE" ] && mode="$(cat "$MODE_FILE")"
echo "=== Focus Mode Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Mode: $mode"
echo "Home: $HOME_LAT, $HOME_LON (radius: ${RADIUS}m)"
echo ""
# Show current location if available
location="$(dumpsys location 2>/dev/null |
grep -oE 'Location\[.*[-]?[0-9]{1,3}\.[0-9]+,[-]?[0-9]{1,3}\.[0-9]+' |
grep -oE '[-]?[0-9]{1,3}\.[0-9]+,[-]?[0-9]{1,3}\.[0-9]+' |
head -1)"
if [ -n "$location" ]; then
lat="$(echo "$location" | cut -d',' -f1)"
lon="$(echo "$location" | cut -d',' -f2)"
dist="$(echo "$lat $lon $HOME_LAT $HOME_LON" | awk '{
PI=3.14159265358979; R=6371000
a1=$1*PI/180; o1=$2*PI/180
a2=$3*PI/180; o2=$4*PI/180
da=a2-a1; dlon=o2-o1
x=sin(da/2)^2+cos(a1)*cos(a2)*sin(dlon/2)^2
printf "%d", R*2*atan2(sqrt(x),sqrt(1-x))
}')"
echo "Location: $lat, $lon"
echo "Distance: ${dist}m from home"
else
echo "Location: unavailable"
fi
echo ""
if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then
echo "=== Apps disabled by focus mode ==="
cat "$DISABLED_APPS_FILE"
else
echo "No apps currently disabled by focus mode"
fi
}
cmd_enable() {
# Disable daemon temporarily, force focus
echo "Forcing focus mode ON..."
. "$SCRIPT_DIR/config.sh"
# Source common functions - inline here for standalone use
: >"$STATE_DIR/disabled_by_focus.txt"
local count=0
for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do
# Check whitelist
whitelisted=0
for w in $(iter_whitelist_packages); do
w_clean="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w_clean" ] && continue
[ "$pkg" = "$w_clean" ] && {
whitelisted=1
break
}
done
[ "$whitelisted" -eq 1 ] && continue
# Check system protection
protected=0
for prefix in $SYSTEM_NEVER_DISABLE; do
prefix_clean="$(echo "$prefix" | tr -d '[:space:]')"
[ -z "$prefix_clean" ] && continue
case "$pkg" in
"$prefix_clean"*)
protected=1
break
;;
esac
done
[ "$protected" -eq 1 ] && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
echo "$pkg" >>"$STATE_DIR/disabled_by_focus.txt"
count=$((count + 1))
fi
done
echo "focus" >"$MODE_FILE"
echo "Done: disabled $count apps"
}
cmd_recheck() {
# Write the trigger file; the daemon's sleep_with_recheck() will pick it
# up within ~1 second and perform an immediate location check.
if [ ! -f "$PIDFILE" ] || ! kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
echo "Daemon not running - start it first with: focus_ctl.sh start"
return 1
fi
touch "$RECHECK_TRIGGER"
chmod 666 "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Recheck requested. Tail the log to see the next reading:"
echo " tail -f $LOG_FILE"
}
cmd_notif_status() {
if [ -f "$STATUS_FILE" ]; then
echo "=== $STATUS_FILE ==="
cat "$STATUS_FILE"
echo
else
echo "No status snapshot yet (daemon has not written $STATUS_FILE)."
fi
if command -v dumpsys >/dev/null 2>&1; then
echo "=== Companion app state ==="
dumpsys package com.kuhy.focusstatus 2>/dev/null | grep -E 'enabled=|installed=|userId=' | head -5 || true
fi
}
cmd_disable() {
echo "Forcing focus mode OFF..."
if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then
local count=0
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
pm enable "$pkg" >/dev/null 2>&1 && count=$((count + 1))
done <"$DISABLED_APPS_FILE"
: >"$DISABLED_APPS_FILE"
echo "Done: re-enabled $count apps"
else
echo "No apps to re-enable"
fi
echo "normal" >"$MODE_FILE"
}
cmd_log() {
local lines="${1:-50}"
if [ -f "$LOG_FILE" ]; then
tail -n "$lines" "$LOG_FILE"
else
echo "Log file not found: $LOG_FILE"
fi
}

100
phone_focus_mode/ctl_dns.sh Executable file
View File

@ -0,0 +1,100 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_dns.sh — focus_ctl.sh's subcommands for the DNS enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
DNS_PIDFILE="$STATE_DIR/dns_enforcer.pid"
dns_enforcer_pid() {
if [ -f "$DNS_PIDFILE" ]; then
local pid
pid="$(cat "$DNS_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_dns_status() {
local pid
pid="$(dns_enforcer_pid)"
echo "=== DNS Enforcer Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
local mode spec
mode="$(settings get global private_dns_mode 2>/dev/null)"
spec="$(settings get global private_dns_specifier 2>/dev/null)"
echo "private_dns_mode: ${mode:-<unset>}"
echo "private_dns_specifier: ${spec:-<unset>}"
if iptables -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
local v4rules
v4rules="$(iptables -S "$DNS_IPT_CHAIN" 2>/dev/null | wc -l)"
echo "iptables $DNS_IPT_CHAIN: $v4rules rules"
else
echo "iptables $DNS_IPT_CHAIN: MISSING"
fi
if ip6tables -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
local v6rules
v6rules="$(ip6tables -S "$DNS_IPT_CHAIN" 2>/dev/null | wc -l)"
echo "ip6tables $DNS_IPT_CHAIN: $v6rules rules"
else
echo "ip6tables $DNS_IPT_CHAIN: MISSING"
fi
}
cmd_dns_start() {
local pid
pid="$(dns_enforcer_pid)"
if [ -n "$pid" ]; then
echo "DNS enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/dns_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(dns_enforcer_pid)"
if [ -n "$pid" ]; then
echo "DNS enforcer started (PID $pid)"
else
echo "ERROR: DNS enforcer failed to start. Check log: $DNS_LOG"
fi
}
cmd_dns_stop() {
local pid
pid="$(dns_enforcer_pid)"
if [ -z "$pid" ]; then
echo "DNS enforcer not running"
rm -f "$DNS_PIDFILE"
else
kill -TERM "$pid"
echo "DNS enforcer stopped (sent SIGTERM to PID $pid)"
fi
# Explicit teardown of the iptables chain so maintenance work can
# use DoH. The enforcer itself leaves the chain intact on TERM to
# keep the block closed between periodic re-applies.
iptables -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null || true
iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || true
iptables -X "$DNS_IPT_CHAIN" 2>/dev/null || true
ip6tables -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null || true
ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || true
ip6tables -X "$DNS_IPT_CHAIN" 2>/dev/null || true
echo "iptables chain $DNS_IPT_CHAIN removed"
}
cmd_dns_log() {
local lines="${1:-50}"
if [ -f "$DNS_LOG" ]; then
tail -n "$lines" "$DNS_LOG"
else
echo "DNS enforcer log not found: $DNS_LOG"
fi
}

118
phone_focus_mode/ctl_hosts.sh Executable file
View File

@ -0,0 +1,118 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_hosts.sh — focus_ctl.sh's subcommands for the hosts-blocklist enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
HOSTS_PIDFILE="$STATE_DIR/hosts_enforcer.pid"
hosts_enforcer_pid() {
if [ -f "$HOSTS_PIDFILE" ]; then
local pid
pid="$(cat "$HOSTS_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_hosts_status() {
local pid
pid="$(hosts_enforcer_pid)"
echo "=== Hosts Enforcer Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Canonical: $HOSTS_CANONICAL"
echo "Target: $HOSTS_TARGET"
if grep -qE "[[:space:]]${HOSTS_TARGET}[[:space:]]" /proc/self/mounts 2>/dev/null; then
# A mount exists on the target path, but on Android the OEM sometimes
# already mounts its own hosts file here. Trust the sha check below.
echo "Mount: present (integrity check below tells us if ours)"
else
echo "Mount: NOT mounted (unprotected)"
fi
if [ -f "$HOSTS_CANONICAL" ]; then
local expected actual
expected="$(cat "$HOSTS_SHA_FILE" 2>/dev/null)"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$HOSTS_TARGET" 2>/dev/null | awk '{print $1}')"
else
actual="$(md5sum "$HOSTS_TARGET" 2>/dev/null | awk '{print $1}')"
fi
echo "Expected: ${expected:-<none>}"
echo "Actual: ${actual:-<unreadable>}"
if [ -n "$expected" ] && [ "$expected" = "$actual" ]; then
echo "Integrity: OK"
else
echo "Integrity: MISMATCH"
fi
else
echo "Canonical hosts file missing - run deploy.sh"
fi
# Magisk Systemless Hosts module protection state.
local module_dir="/data/adb/modules/hosts"
if [ -d "$module_dir" ]; then
local lock_state="UNLOCKED (Magisk app can disable!)"
if lsattr -d "$module_dir" 2>/dev/null | awk '{print $1}' | grep -q i; then
lock_state="LOCKED (chattr +i)"
fi
echo "Magisk dir: $module_dir [$lock_state]"
local marker_warn=""
for marker in disable remove update; do
if [ -e "$module_dir/$marker" ]; then
marker_warn="$marker_warn $marker"
fi
done
if [ -n "$marker_warn" ]; then
echo "WARN: Magisk markers present:$marker_warn (will be auto-removed by hosts_enforcer)"
fi
else
echo "Magisk dir: <missing - module not installed>"
fi
}
cmd_hosts_start() {
local pid
pid="$(hosts_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Hosts enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/hosts_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(hosts_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Hosts enforcer started (PID $pid)"
else
echo "ERROR: hosts enforcer failed to start. Check log: $HOSTS_LOG"
fi
}
cmd_hosts_stop() {
local pid
pid="$(hosts_enforcer_pid)"
if [ -z "$pid" ]; then
echo "Hosts enforcer not running"
rm -f "$HOSTS_PIDFILE"
return
fi
kill -TERM "$pid"
echo "Hosts enforcer stopped (sent SIGTERM to PID $pid)"
}
cmd_hosts_log() {
local lines="${1:-50}"
if [ -f "$HOSTS_LOG" ]; then
tail -n "$lines" "$HOSTS_LOG"
else
echo "Hosts enforcer log not found: $HOSTS_LOG"
fi
}

154
phone_focus_mode/ctl_launcher.sh Executable file
View File

@ -0,0 +1,154 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_launcher.sh — focus_ctl.sh's subcommands for the minimalist-launcher enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
LAUNCHER_PIDFILE="$STATE_DIR/launcher_enforcer.pid"
DISABLED_COMPETITORS_FILE="$STATE_DIR/disabled_competitors.txt"
launcher_enforcer_pid() {
if [ -f "$LAUNCHER_PIDFILE" ]; then
local pid
pid="$(cat "$LAUNCHER_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_launcher_snapshot() {
# Find the APK path for the currently-installed launcher and copy it
# to LAUNCHER_APK. Also capture the current HOME activity component.
local apk_path
apk_path="$(pm path "$LAUNCHER_PACKAGE" 2>/dev/null | head -1 | sed 's/^package://')"
if [ -z "$apk_path" ] || [ ! -f "$apk_path" ]; then
echo "ERROR: $LAUNCHER_PACKAGE is not installed. Install it once via Aurora/Play Store, then rerun this command."
return 1
fi
mkdir -p "$(dirname "$LAUNCHER_APK")"
chattr -i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true
cp "$apk_path" "$LAUNCHER_APK" || return 1
chmod 644 "$LAUNCHER_APK"
sha256sum "$LAUNCHER_APK" | awk '{print $1}' >"$LAUNCHER_SHA_FILE"
chmod 644 "$LAUNCHER_SHA_FILE"
# Resolve the current HOME activity (or the launcher's default activity
# if it isn't yet the default).
local component
component="$(cmd package resolve-activity --brief \
-c android.intent.category.HOME \
-a android.intent.action.MAIN 2>/dev/null | awk 'NR==2{print}')"
if [ -z "$component" ] || [ "${component%%/*}" != "$LAUNCHER_PACKAGE" ]; then
# Fall back to the launcher's MAIN/LAUNCHER activity
component="$(cmd package resolve-activity --brief \
-c android.intent.category.LAUNCHER \
-a android.intent.action.MAIN "$LAUNCHER_PACKAGE" 2>/dev/null |
awk 'NR==2{print}')"
fi
if [ -z "$component" ]; then
echo "ERROR: could not resolve HOME activity for $LAUNCHER_PACKAGE"
return 1
fi
echo "$component" >"$LAUNCHER_ACTIVITY_FILE"
chmod 644 "$LAUNCHER_ACTIVITY_FILE"
# Make snapshot immutable so even root-in-a-terminal can't overwrite
# it without first running `chattr -i`.
chattr +i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true
echo "Snapshot saved:"
echo " APK: $LAUNCHER_APK ($(wc -c <"$LAUNCHER_APK") bytes)"
echo " SHA256: $(cat "$LAUNCHER_SHA_FILE")"
echo " Activity: $component"
}
cmd_launcher_status() {
local pid
pid="$(launcher_enforcer_pid)"
echo "=== Launcher Enforcer Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Package: $LAUNCHER_PACKAGE"
if pm path "$LAUNCHER_PACKAGE" >/dev/null 2>&1; then
echo "Installed: YES ($(pm path "$LAUNCHER_PACKAGE" | head -1))"
else
echo "Installed: NO"
fi
local desired actual
desired="$(cat "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null)"
actual="$(cmd package resolve-activity --brief \
-c android.intent.category.HOME -a android.intent.action.MAIN \
2>/dev/null | awk 'NR==2{print}')"
echo "Expected: ${desired:-<not armed - run launcher-snapshot>}"
echo "Actual: ${actual:-<unresolved>}"
if [ -n "$desired" ] && [ "$desired" = "$actual" ]; then
echo "Default: OK (pinned)"
else
echo "Default: MISMATCH"
fi
echo "Snapshot: $LAUNCHER_APK"
if [ -f "$LAUNCHER_APK" ]; then
echo "Snapshot size: $(wc -c <"$LAUNCHER_APK") bytes"
fi
if [ -s "$DISABLED_COMPETITORS_FILE" ]; then
echo "Disabled competitors:"
sed 's/^/ - /' "$DISABLED_COMPETITORS_FILE"
fi
}
cmd_launcher_start() {
local pid
pid="$(launcher_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Launcher enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/launcher_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(launcher_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Launcher enforcer started (PID $pid)"
else
echo "ERROR: launcher enforcer failed to start. Check log: $LAUNCHER_LOG"
fi
}
cmd_launcher_stop() {
local pid
pid="$(launcher_enforcer_pid)"
if [ -z "$pid" ]; then
echo "Launcher enforcer not running"
rm -f "$LAUNCHER_PIDFILE"
else
kill -TERM "$pid"
echo "Launcher enforcer stopped (sent SIGTERM to PID $pid)"
fi
# Re-enable any competitors we disabled so the device is usable if the
# enforcer is intentionally stopped (e.g. during maintenance).
if [ -s "$DISABLED_COMPETITORS_FILE" ]; then
while read -r pkg; do
[ -z "$pkg" ] && continue
pm enable --user 0 "$pkg" >/dev/null 2>&1 &&
echo "Re-enabled competing launcher: $pkg"
done <"$DISABLED_COMPETITORS_FILE"
: >"$DISABLED_COMPETITORS_FILE"
fi
}
cmd_launcher_log() {
local lines="${1:-50}"
if [ -f "$LAUNCHER_LOG" ]; then
tail -n "$lines" "$LAUNCHER_LOG"
else
echo "Launcher enforcer log not found: $LAUNCHER_LOG"
fi
}

104
phone_focus_mode/ctl_tether.sh Executable file
View File

@ -0,0 +1,104 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_tether.sh — focus_ctl.sh's subcommands for the tethering enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
TETHER_PIDFILE="$STATE_DIR/tether_enforcer.pid"
tether_enforcer_pid() {
if [ -f "$TETHER_PIDFILE" ]; then
local pid
pid="$(cat "$TETHER_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_tether_status() {
local pid
pid="$(tether_enforcer_pid)"
echo "=== Hotspot / Tethering Block Status ==="
echo "Enabled: ${TETHER_ENFORCER_ENABLED}"
if [ -n "$pid" ]; then
echo "Enforcer: RUNNING (PID $pid)"
else
echo "Enforcer: STOPPED"
fi
if [ -f "$MODE_FILE" ]; then
echo "Focus mode: $(cat "$MODE_FILE" 2>/dev/null)"
fi
[ -e "$TETHER_FORCE_FILE" ] && echo "Forced ON: YES (test active)"
[ -e "$TETHER_OVERRIDE_FILE" ] && echo "Override: YES (block SUSPENDED)"
[ -e "$TETHER_ENFORCER_STATE" ] && echo "Applied now: YES (offload off + FORWARD reject)" ||
echo "Applied now: no"
local off
off="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)"
echo "tether_offload_disabled: ${off:-<unset>}"
if iptables -C FORWARD -j "$TETHER_IPT_CHAIN" >/dev/null 2>&1; then
echo "iptables $TETHER_IPT_CHAIN: pinned to FORWARD ($(iptables -S "$TETHER_IPT_CHAIN" 2>/dev/null | grep -c '^-A') rule)"
else
echo "iptables $TETHER_IPT_CHAIN: absent (block not applied)"
fi
}
cmd_tether_start() {
local pid
pid="$(tether_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Tether enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/tether_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(tether_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Tether enforcer started (PID $pid)"
else
echo "ERROR: tether enforcer failed to start. Check log: $TETHER_LOG"
fi
}
cmd_tether_stop() {
local pid
pid="$(tether_enforcer_pid)"
if [ -z "$pid" ]; then
echo "Tether enforcer not running"
rm -f "$TETHER_PIDFILE"
else
kill -TERM "$pid"
echo "Tether enforcer stopped (sent SIGTERM to PID $pid)"
fi
# Explicit teardown so maintenance can tether even if the daemon was already
# dead (its own TERM handler reverts too; this is the belt-and-suspenders).
iptables -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null || true
iptables -F "$TETHER_IPT_CHAIN" 2>/dev/null || true
iptables -X "$TETHER_IPT_CHAIN" 2>/dev/null || true
ip6tables -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null || true
ip6tables -F "$TETHER_IPT_CHAIN" 2>/dev/null || true
ip6tables -X "$TETHER_IPT_CHAIN" 2>/dev/null || true
echo "FORWARD chain $TETHER_IPT_CHAIN removed (tethering restored)"
}
cmd_tether_log() { tail -n "${1:-50}" "$TETHER_LOG" 2>/dev/null || echo "No tether log yet."; }
# Test hook: force the block ACTIVE regardless of location so the full stack can
# be validated during the day with a real second phone on the hotspot.
cmd_tether_test_on() {
touch "$TETHER_FORCE_FILE"
chmod 666 "$TETHER_FORCE_FILE" 2>/dev/null || true
rm -f "$TETHER_OVERRIDE_FILE"
echo "Tether block FORCED ON. Enforcer engages within ${TETHER_CHECK_INTERVAL}s."
echo "Validate: second phone on the hotspot loses internet; this phone stays online."
}
cmd_tether_test_off() {
rm -f "$TETHER_FORCE_FILE"
echo "Tether force cleared. Back to focus-mode-gated behaviour."
}

144
phone_focus_mode/ctl_usage.sh Executable file
View File

@ -0,0 +1,144 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_usage.sh — focus_ctl.sh's help text and the two commands that report on
# the whitelist: list-apps and whitelist.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one directory.
# Sourced by focus_ctl.sh, which owns the config.sh globals these read.
#
# NOTE on iter_whitelist_packages: its `while read` loop is fed by a pipe from
# printf, and nothing in the body reads stdin, so the pipe is safe here. That
# is NOT true of the pm-driven loops elsewhere in this repo -- see the warning
# in daemon_apps.sh before converting any redirect-driven loop to a pipe.
# Emit one valid package name per line from WHITELIST.
# This strips comments/blank lines from the multi-line quoted string and avoids
# treating heading text (e.g. "---") as package tokens.
iter_whitelist_packages() {
printf '%s\n' "$WHITELIST" | while IFS= read -r line; do
line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
# Blank lines only. A comment needs no case of its own: `set -- $line`
# below splits on whitespace, so a comment's first token is always "#",
# which the dot check then rejects. A dedicated `\#*)` arm here was
# provably equivalent -- a mutation removing it could not be
# distinguished by any input -- and equivalent code is a claim of a
# check that is not actually being made.
case "$line" in
"") continue ;;
esac
# Keep first token only; ignore any inline prose if present.
# Intentional word split to grab the first token. This runs under
# /system/bin/sh on the phone, where arrays do not exist, so an
# unquoted expansion is the POSIX way to do it.
# shellcheck disable=SC2086
set -- $line
pkg="$1"
# Package names are dot-delimited identifiers.
case "$pkg" in
*.*) ;;
*) continue ;;
esac
case "$pkg" in
*[!A-Za-z0-9._]*) continue ;;
esac
echo "$pkg"
done
}
usage() {
echo "Usage: focus_ctl.sh <command>"
echo ""
echo "Commands:"
echo " start - Start the focus mode daemon"
echo " stop - Stop the daemon and re-enable all apps"
echo " status - Show current mode, location and disabled apps"
echo " enable - Force focus mode on (regardless of location)"
echo " disable - Force focus mode off (regardless of location)"
echo " log - Show daemon log"
echo " list-apps - List all non-whitelisted third-party apps"
echo " whitelist - List currently whitelisted packages"
echo " restart - Restart the daemon"
echo " hosts-status - Show hosts enforcer state (mount + hash)"
echo " hosts-start - Start the hosts enforcer daemon"
echo " hosts-stop - Stop the hosts enforcer daemon"
echo " hosts-log - Show hosts enforcer log"
echo " dns-status - Show DNS enforcer state (Private DNS + iptables)"
echo " dns-start - Start the DNS enforcer daemon"
echo " dns-stop - Stop the DNS enforcer daemon (removes iptables chain)"
echo " dns-log - Show DNS enforcer log"
echo " launcher-status - Show launcher enforcer state"
echo " launcher-start - Start the launcher enforcer daemon"
echo " launcher-stop - Stop the launcher enforcer daemon"
echo " launcher-log - Show launcher enforcer log"
echo " launcher-snapshot - Back up currently-installed launcher APK"
echo " workout-status - Show StrongLifts workout-detection state"
echo " workout-start - Start the workout detector daemon"
echo " workout-stop - Stop the workout detector daemon (sets flag=0)"
echo " workout-log - Show workout detector log"
echo " recheck - Nudge the daemon to perform a fresh location check now"
echo " curfew-status - Show night-curfew + enforcer state"
echo " curfew-start - Start the curfew enforcer (grayscale/DND/net)"
echo " curfew-stop - Stop it and restore daytime display/DND"
echo " curfew-log - Show curfew enforcer log"
echo " curfew-test-on - Force curfew ACTIVE now (daytime validation)"
echo " curfew-test-off - Clear the test force"
echo " curfew-demo-on - Start a demo: full curfew now, easy one-tap off"
echo " curfew-demo-off - Stop the demo"
echo " curfew-off - Escape hatch: suspend curfew now (2am opt-out)"
echo " curfew-on - Re-arm curfew (clear the override)"
echo " tether-status - Show hotspot/tethering-block state"
echo " tether-start - Start the tether enforcer (FORWARD block + offload off)"
echo " tether-stop - Stop it and restore tethering (teardown FORWARD chain)"
echo " tether-log - Show tether enforcer log"
echo " tether-test-on - Force the tether block ACTIVE now (daytime validation)"
echo " tether-test-off - Clear the tether force"
echo " notif-status - Show companion status-notification details"
echo ""
}
cmd_list_apps() {
echo "=== Third-party apps NOT in whitelist ==="
for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do
whitelisted=0
for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue
[ "$pkg" = "$w" ] && {
whitelisted=1
break
}
done
if [ "$whitelisted" -eq 0 ]; then
# Check if currently disabled by focus mode
if grep -qF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null; then
echo " [BLOCKED] $pkg"
else
echo " [active] $pkg"
fi
fi
done
echo ""
echo "=== Whitelisted apps ==="
for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue
echo " [allowed] $w"
done
}
cmd_whitelist() {
echo "=== Whitelisted packages ==="
for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue
# Check if installed
if pm list packages "$w" 2>/dev/null | grep -qF "$w"; then
echo " [installed] $w"
else
echo " [not found] $w"
fi
done
}

122
phone_focus_mode/ctl_workout.sh Executable file
View File

@ -0,0 +1,122 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_workout.sh — focus_ctl.sh's subcommands for the workout detector:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
WORKOUT_PIDFILE="$STATE_DIR/workout_detector.pid"
workout_detector_pid() {
if [ -f "$WORKOUT_PIDFILE" ]; then
local pid
pid="$(cat "$WORKOUT_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_workout_status() {
local pid
pid="$(workout_detector_pid)"
echo "=== Workout Detector Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Package: $WORKOUT_TRIGGER_PACKAGE"
if pm path "$WORKOUT_TRIGGER_PACKAGE" >/dev/null 2>&1; then
echo "Installed: YES"
else
echo "Installed: NO (detector will always report inactive)"
fi
echo "sqlite3: $WORKOUT_SQLITE3_BIN"
if [ -x "$WORKOUT_SQLITE3_BIN" ]; then
echo "sqlite3 ver: $("$WORKOUT_SQLITE3_BIN" -version 2>/dev/null | awk '{print $1}')"
else
echo "sqlite3 ver: <missing or not executable — detector cannot query DB>"
fi
echo "DB path: $WORKOUT_DB_PATH"
if [ -f "$WORKOUT_DB_PATH" ]; then
echo "DB present: YES"
else
echo "DB present: NO"
fi
echo "Poll interval: ${WORKOUT_DETECTOR_INTERVAL}s"
local flag="<unset>"
if [ -f "$WORKOUT_ACTIVE_FILE" ]; then
flag="$(cat "$WORKOUT_ACTIVE_FILE" 2>/dev/null)"
fi
case "$flag" in
1) echo "Workout flag: 1 (workout IN PROGRESS → YouTube hosts UNBLOCKED)" ;;
0) echo "Workout flag: 0 (no workout → YouTube hosts BLOCKED)" ;;
*) echo "Workout flag: '$flag' (treated as 0, fail-closed)" ;;
esac
# Live one-shot query so the user can see ground truth without waiting
# for the next poll cycle. Best-effort — never fails the status command.
if [ -x "$WORKOUT_SQLITE3_BIN" ] && [ -f "$WORKOUT_DB_PATH" ]; then
local live_count
live_count="$("$WORKOUT_SQLITE3_BIN" "file:${WORKOUT_DB_PATH}?mode=ro" \
"SELECT COUNT(*) FROM workouts WHERE start>0 AND (finish IS NULL OR finish=0);" \
2>/dev/null)"
echo "Live DB query: in-progress workouts = ${live_count:-<query failed>}"
fi
if [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then
echo "Workout hosts: $HOSTS_CANONICAL_WORKOUT ($(wc -l <"$HOSTS_CANONICAL_WORKOUT" 2>/dev/null) lines)"
else
echo "Workout hosts: <missing — deploy.sh must regenerate it>"
fi
}
cmd_workout_start() {
local pid
pid="$(workout_detector_pid)"
if [ -n "$pid" ]; then
echo "Workout detector already running (PID $pid)"
return
fi
if [ ! -x "$WORKOUT_SQLITE3_BIN" ]; then
echo "ERROR: $WORKOUT_SQLITE3_BIN missing or not executable. Re-run deploy.sh."
return 1
fi
setsid sh "$SCRIPT_DIR/workout_detector.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(workout_detector_pid)"
if [ -n "$pid" ]; then
echo "Workout detector started (PID $pid)"
else
echo "ERROR: Workout detector failed to start. Check log: $WORKOUT_DETECTOR_LOG"
fi
}
cmd_workout_stop() {
local pid
pid="$(workout_detector_pid)"
if [ -z "$pid" ]; then
echo "Workout detector not running"
rm -f "$WORKOUT_PIDFILE"
else
kill -TERM "$pid"
echo "Workout detector stopped (sent SIGTERM to PID $pid)"
fi
# Fail-closed on manual stop: write 0 so the hosts enforcer reverts to
# the full-block canonical and YouTube goes back to being blocked.
printf '0\n' >"$WORKOUT_ACTIVE_FILE" 2>/dev/null || true
chmod 666 "$WORKOUT_ACTIVE_FILE" 2>/dev/null || true
echo "workout_active flag forced to 0"
}
cmd_workout_log() {
local lines="${1:-50}"
if [ -f "$WORKOUT_DETECTOR_LOG" ]; then
tail -n "$lines" "$WORKOUT_DETECTOR_LOG"
else
echo "Workout detector log not found: $WORKOUT_DETECTOR_LOG"
fi
}

View File

@ -382,6 +382,14 @@ do_deploy() {
adb_cmd push "$DEPLOY_DIR/daemon_state.sh" "/data/local/tmp/focus_stage/daemon_state.sh"
adb_cmd push "$DEPLOY_DIR/daemon_apps.sh" "/data/local/tmp/focus_stage/daemon_apps.sh"
adb_cmd push "$DEPLOY_DIR/focus_ctl.sh" "/data/local/tmp/focus_stage/focus_ctl.sh"
adb_cmd push "$DEPLOY_DIR/ctl_usage.sh" "/data/local/tmp/focus_stage/ctl_usage.sh"
adb_cmd push "$DEPLOY_DIR/ctl_daemon.sh" "/data/local/tmp/focus_stage/ctl_daemon.sh"
adb_cmd push "$DEPLOY_DIR/ctl_hosts.sh" "/data/local/tmp/focus_stage/ctl_hosts.sh"
adb_cmd push "$DEPLOY_DIR/ctl_dns.sh" "/data/local/tmp/focus_stage/ctl_dns.sh"
adb_cmd push "$DEPLOY_DIR/ctl_launcher.sh" "/data/local/tmp/focus_stage/ctl_launcher.sh"
adb_cmd push "$DEPLOY_DIR/ctl_workout.sh" "/data/local/tmp/focus_stage/ctl_workout.sh"
adb_cmd push "$DEPLOY_DIR/ctl_curfew.sh" "/data/local/tmp/focus_stage/ctl_curfew.sh"
adb_cmd push "$DEPLOY_DIR/ctl_tether.sh" "/data/local/tmp/focus_stage/ctl_tether.sh"
adb_cmd push "$DEPLOY_DIR/hosts_enforcer.sh" "/data/local/tmp/focus_stage/hosts_enforcer.sh"
adb_cmd push "$DEPLOY_DIR/hosts_magisk.sh" "/data/local/tmp/focus_stage/hosts_magisk.sh"
adb_cmd push "$DEPLOY_DIR/hosts_mount.sh" "/data/local/tmp/focus_stage/hosts_mount.sh"
@ -511,6 +519,14 @@ do_deploy() {
adb_root "cp /data/local/tmp/focus_stage/daemon_state.sh $REMOTE_DIR/daemon_state.sh"
adb_root "cp /data/local/tmp/focus_stage/daemon_apps.sh $REMOTE_DIR/daemon_apps.sh"
adb_root "cp /data/local/tmp/focus_stage/focus_ctl.sh $REMOTE_DIR/focus_ctl.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_usage.sh $REMOTE_DIR/ctl_usage.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_daemon.sh $REMOTE_DIR/ctl_daemon.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_hosts.sh $REMOTE_DIR/ctl_hosts.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_dns.sh $REMOTE_DIR/ctl_dns.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_launcher.sh $REMOTE_DIR/ctl_launcher.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_workout.sh $REMOTE_DIR/ctl_workout.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_curfew.sh $REMOTE_DIR/ctl_curfew.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_tether.sh $REMOTE_DIR/ctl_tether.sh"
adb_root "cp /data/local/tmp/focus_stage/hosts_enforcer.sh $REMOTE_DIR/hosts_enforcer.sh"
adb_root "cp /data/local/tmp/focus_stage/hosts_magisk.sh $REMOTE_DIR/hosts_magisk.sh"
adb_root "cp /data/local/tmp/focus_stage/hosts_mount.sh $REMOTE_DIR/hosts_mount.sh"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,250 @@
#!/usr/bin/env bash
# lib/tests/ctl_cases_commands.sh — assertions for focus_ctl.sh's subcommands,
# against the device stubs in test_ctl_libs.sh.
#
# The start/stop/status trio is the same shape in all seven enforcer
# libraries, so it is driven through tables rather than seven near-identical
# blocks: the duplication is in the subject, and repeating it here would add
# nothing and trip the jscpd gate. The per-enforcer specifics that tables
# cannot express get their own cases below.
set -euo pipefail
# Run a subcommand for its side effects only. These are CLI entry points whose
# exit status is not part of their contract, and an unguarded nonzero return
# aborts the whole file under set -e.
_run_quiet() {
"$1" >/dev/null 2>&1 || return 0
}
# _start_case <cmd> <pid-fn> <pidfile-var> <label>
#
# A start command must refuse when already running, and must report the
# failure rather than claim success when the daemon does not come up. The
# stubbed setsid never actually starts anything, so "came up" is modelled by
# the pidfile appearing.
_start_case() {
local cmd="$1" pidvar="$2" label="$3"
local file="${!pidvar}"
printf '%s\n' "$$" >"${file}"
_reset_calls
case "$(${cmd} || true)" in
*"already running"*) _t_pass "${label} refuses to start a second copy" ;;
*) _t_fail "${label} must not start when already running" ;;
esac
if _calls | grep -q "^setsid"; then
_t_fail "${label} launched a daemon that was already running"
else
_t_pass "${label} launches nothing when already running"
fi
rm -f "${file}"
_reset_calls
case "$(${cmd} 2>&1 || true)" in
*ERROR*) _t_pass "${label} reports a daemon that failed to come up" ;;
*) _t_fail "${label} must report a failed start, not claim success" ;;
esac
if _calls | grep -q "^setsid"; then
_t_pass "${label} does try to launch when nothing is running"
else
_t_fail "${label} should have launched a daemon"
fi
}
# _stop_case <cmd> <pidfile-var> <label>
#
# The live pid must NOT be $$: these commands call `kill -TERM "$pid"`, so
# seeding the test's own pid makes the suite signal itself and die silently
# mid-run. A disposable background process is used instead.
_stop_case() {
local cmd="$1" pidvar="$2" label="$3"
local file="${!pidvar}" victim=""
rm -f "${file}"
_reset_calls
case "$(${cmd} 2>&1 || true)" in
*"not running"*) _t_pass "${label} reports nothing to stop" ;;
*) _t_fail "${label} must report that nothing is running" ;;
esac
/usr/bin/sleep 30 &
victim=$!
printf '%s\n' "${victim}" >"${file}"
_reset_calls
_run_quiet "${cmd}"
# kill is a shell builtin, so no PATH stub can observe it. The effect is
# what is asserted: the victim must no longer be alive afterwards.
/usr/bin/sleep 0.2
if kill -0 "${victim}" 2>/dev/null; then
_t_fail "${label} left the running daemon alive"
kill "${victim}" 2>/dev/null || true
else
_t_pass "${label} signals the running daemon"
fi
wait "${victim}" 2>/dev/null || true
rm -f "${file}"
}
# _status_case <cmd> <pidfile-var> <label>
_status_case() {
local cmd="$1" pidvar="$2" label="$3"
local file="${!pidvar}"
rm -f "${file}"
case "$(${cmd} 2>&1 || true)" in
*STOPPED*) _t_pass "${label} reports STOPPED with no daemon" ;;
*) _t_fail "${label} must report STOPPED when nothing is running" ;;
esac
printf '%s\n' "$$" >"${file}"
case "$(${cmd} 2>&1 || true)" in
*RUNNING*) _t_pass "${label} reports RUNNING with a live daemon" ;;
*) _t_fail "${label} must report RUNNING when the daemon is up" ;;
esac
rm -f "${file}"
}
# --- the four enforcers whose trio is uniform -------------------------------
_start_case cmd_hosts_start HOSTS_PIDFILE "hosts start"
_stop_case cmd_hosts_stop HOSTS_PIDFILE "hosts stop"
_status_case cmd_hosts_status HOSTS_PIDFILE "hosts status"
_start_case cmd_dns_start DNS_PIDFILE "dns start"
_stop_case cmd_dns_stop DNS_PIDFILE "dns stop"
_status_case cmd_dns_status DNS_PIDFILE "dns status"
_start_case cmd_launcher_start LAUNCHER_PIDFILE "launcher start"
_stop_case cmd_launcher_stop LAUNCHER_PIDFILE "launcher stop"
_status_case cmd_launcher_status LAUNCHER_PIDFILE "launcher status"
_start_case cmd_workout_start WORKOUT_PIDFILE "workout start"
# The workout detector needs the sqlite3 binary deploy.sh pushes alongside it.
# Missing, it must refuse rather than launch a detector that cannot read the
# StrongLifts database.
rm -f "${WORKOUT_PIDFILE}"
_saved_sqlite="${WORKOUT_SQLITE3_BIN}"
WORKOUT_SQLITE3_BIN="${RUN}/state/no-such-sqlite3"
_reset_calls
case "$(cmd_workout_start 2>&1 || true)" in
*"missing or not executable"*) _t_pass "workout start refuses without sqlite3" ;;
*) _t_fail "workout start must refuse when sqlite3 is missing" ;;
esac
if _calls | grep -q "^setsid"; then
_t_fail "workout start launched a detector with no sqlite3"
else
_t_pass "workout start launches nothing without sqlite3"
fi
WORKOUT_SQLITE3_BIN="${_saved_sqlite}"
_stop_case cmd_workout_stop WORKOUT_PIDFILE "workout stop"
_start_case cmd_curfew_start CURFEW_PIDFILE "curfew start"
_stop_case cmd_curfew_stop CURFEW_PIDFILE "curfew stop"
_start_case cmd_tether_start TETHER_PIDFILE "tether start"
_stop_case cmd_tether_stop TETHER_PIDFILE "tether stop"
_status_case cmd_tether_status TETHER_PIDFILE "tether status"
_start_case cmd_start PIDFILE "daemon start"
_stop_case cmd_stop PIDFILE "daemon stop"
# --- dns status: the chain report -------------------------------------------
# A missing firewall chain is the whole point of the check, so it must be
# reported as MISSING rather than as a zero-rule chain.
rm -f "${DNS_PIDFILE}"
_clear_chain iptables
_clear_chain ip6tables
out="$(cmd_dns_status 2>&1)"
case "${out}" in
*"iptables ${DNS_IPT_CHAIN}: MISSING"*) _t_pass "dns status reports a missing v4 chain" ;;
*) _t_fail "dns status must report a missing v4 chain" ;;
esac
case "${out}" in
*"ip6tables ${DNS_IPT_CHAIN}: MISSING"*) _t_pass "dns status reports a missing v6 chain" ;;
*) _t_fail "dns status must report a missing v6 chain" ;;
esac
_seed_chain iptables "$(printf -- '-A one\n-A two\n-A three')"
_seed_chain ip6tables "-A one"
out="$(cmd_dns_status 2>&1)"
case "${out}" in
*"iptables ${DNS_IPT_CHAIN}: 3 rules"*) _t_pass "dns status counts the v4 rules" ;;
*) _t_fail "dns status must count the v4 rules" ;;
esac
case "${out}" in
*"ip6tables ${DNS_IPT_CHAIN}: 1 rules"*) _t_pass "dns status counts the v6 rules" ;;
*) _t_fail "dns status must count the v6 rules" ;;
esac
# Private DNS is the setting the enforcer exists to hold down, so an unset
# value must read as <unset> rather than as an empty line.
_seed_out settings ""
case "$(cmd_dns_status 2>&1)" in
*"private_dns_mode: <unset>"*) _t_pass "dns status renders an unset mode explicitly" ;;
*) _t_fail "dns status must render an unset private DNS mode" ;;
esac
rm -f "${DEV}/out_settings"
# --- curfew status and the override/force hooks -----------------------------
_set_now "0000"
rm -f "${CURFEW_PIDFILE}" "${CURFEW_OVERRIDE_FILE}" "${CURFEW_FORCE_FILE}"
cmd_curfew_test_on >/dev/null 2>&1
if [[ -e "${CURFEW_FORCE_FILE}" ]]; then
_t_pass "curfew-test-on creates the force file"
else
_t_fail "curfew-test-on must create the force file"
fi
cmd_curfew_test_off >/dev/null 2>&1
if [[ -e "${CURFEW_FORCE_FILE}" ]]; then
_t_fail "curfew-test-off left the force file behind"
else
_t_pass "curfew-test-off clears the force file"
fi
# The escape hatch: this is the 2am opt-out, so it must actually take effect.
cmd_curfew_off >/dev/null 2>&1
if [[ -e "${CURFEW_OVERRIDE_FILE}" ]]; then
_t_pass "curfew-off creates the override that suspends the curfew"
else
_t_fail "curfew-off must create the override file"
fi
cmd_curfew_on >/dev/null 2>&1
if [[ -e "${CURFEW_OVERRIDE_FILE}" ]]; then
_t_fail "curfew-on left the override in place"
else
_t_pass "curfew-on clears the override and re-arms the curfew"
fi
# --- tether test hooks ------------------------------------------------------
rm -f "${TETHER_FORCE_FILE}"
cmd_tether_test_on >/dev/null 2>&1
if [[ -e "${TETHER_FORCE_FILE}" ]]; then
_t_pass "tether-test-on creates the force file"
else
_t_fail "tether-test-on must create the force file"
fi
cmd_tether_test_off >/dev/null 2>&1
if [[ -e "${TETHER_FORCE_FILE}" ]]; then
_t_fail "tether-test-off left the force file behind"
else
_t_pass "tether-test-off clears the force file"
fi
# --- usage ------------------------------------------------------------------
usage_text="$(usage 2>&1)"
for sub in start stop status hosts-status dns-status launcher-status \
workout-status curfew-status tether-status curfew-off; do
case "${usage_text}" in
*"${sub}"*) _t_pass "usage documents ${sub}" ;;
*) _t_fail "usage must document ${sub}" ;;
esac
done

View File

@ -0,0 +1,144 @@
#!/usr/bin/env bash
# lib/tests/ctl_cases_curfew.sh — assertions for the pure logic in the ctl
# libraries: the zero-stripping helper, the night-curfew window, and the
# seven pidfile helpers.
#
# Sourced by test_ctl_libs.sh after ctl_libs_harness.sh, which owns the stubs,
# the subjects and the PASS/FAIL counters this file adds to.
set -euo pipefail
# --- _ctl_dec ---------------------------------------------------------------
# Zero-padded times must not reach shell arithmetic as octal: "0830" is not a
# valid octal number and would abort the comparison mid-check.
_t_eq "830" "$(_ctl_dec 0830)" "_ctl_dec strips the leading zero from 0830"
_t_eq "500" "$(_ctl_dec 0500)" "_ctl_dec strips the leading zero from 0500"
_t_eq "0" "$(_ctl_dec 0000)" "_ctl_dec keeps a single digit for midnight"
_t_eq "2300" "$(_ctl_dec 2300)" "_ctl_dec leaves an unpadded time alone"
_t_eq "9" "$(_ctl_dec 0009)" "_ctl_dec strips several leading zeros"
# --- ctl_is_curfew_now ------------------------------------------------------
# The window wraps past midnight, which a naive start<=now<end comparison gets
# exactly backwards: it would report curfew all day and never at night.
NIGHT_CURFEW_START="2300"
NIGHT_CURFEW_END="0500"
for t in 2300 2359 0000 0430; do
_set_now "${t}"
if ctl_is_curfew_now; then
_t_pass "curfew is open at ${t} inside the wrapping window"
else
_t_fail "curfew should be open at ${t}"
fi
done
for t in 0500 1200 2259; do
_set_now "${t}"
if ! ctl_is_curfew_now; then
_t_pass "curfew is closed at ${t} outside the wrapping window"
else
_t_fail "curfew should be closed at ${t}"
fi
done
# A same-day window includes its start and excludes its end.
NIGHT_CURFEW_START="0900"
NIGHT_CURFEW_END="1700"
_set_now "0900"
if ctl_is_curfew_now; then
_t_pass "a same-day window includes its start minute"
else
_t_fail "0900 should be inside a 0900-1700 window"
fi
_set_now "1700"
if ! ctl_is_curfew_now; then
_t_pass "a same-day window excludes its end minute"
else
_t_fail "1700 should be outside a 0900-1700 window"
fi
_set_now "1200"
if ctl_is_curfew_now; then
_t_pass "a same-day window is open midway through"
else
_t_fail "1200 should be inside a 0900-1700 window"
fi
NIGHT_CURFEW_START="2300"
NIGHT_CURFEW_END="0500"
_t_eq "2300-0500" "${NIGHT_CURFEW_START}-${NIGHT_CURFEW_END}" \
"the wrapping window is restored for the clock cases"
# A broken clock must fail OPEN: reporting permanent curfew because `date`
# misbehaved would lock the phone down with no way to see why.
for bad in "not-a-time" "" "12:00"; do
_set_now "${bad}"
if ! ctl_is_curfew_now; then
_t_pass "a malformed clock ('${bad}') reads as no curfew"
else
_t_fail "a malformed clock ('${bad}') must not enable the curfew"
fi
done
# --- the two copies must agree ---------------------------------------------
# daemon_location.sh carries its own is_curfew_now. The phone's daemon and its
# control script disagreeing about whether it is night is a silent, confusing
# failure, so the copies are compared directly rather than trusted to match.
_curfew_table() {
local answers="" t=""
for t in 2259 2300 2359 0000 0430 0500 1200; do
_set_now "${t}"
if "$1"; then answers="${answers}1"; else answers="${answers}0"; fi
done
printf '%s' "${answers}"
}
ctl_answers="$(_curfew_table ctl_is_curfew_now)"
(
# Sourced in a subshell: daemon_location.sh defines is_curfew_now and
# curfew_active, and only the former is wanted here.
# shellcheck source=../../daemon_location.sh
. "${PHONE_DIR}/daemon_location.sh"
_curfew_table is_curfew_now
) >"${RUN}/state/daemon_answers"
_t_eq "${ctl_answers}" "$(cat "${RUN}/state/daemon_answers")" \
"ctl_is_curfew_now and daemon is_curfew_now agree on every boundary"
# 2259 closed, 2300/2359/0000/0430 open, 0500 closed (end is exclusive),
# 1200 closed. Spelled out so the agreement check above cannot pass by
# both copies being wrong in the same way.
_t_eq "0111100" "${ctl_answers}" "the shared window answers match the 2300-0500 spec"
# --- the seven pid helpers --------------------------------------------------
# One table rather than seven near-identical blocks: the duplication is in the
# subject, and repeating it here would add nothing and trip the jscpd gate.
_pid_case() {
local fn="$1" var="$2"
local file="${RUN}/state/${fn}.pid"
# printf -v assigns to a name held in a variable without eval, which the
# repo's shell rules forbid.
printf -v "${var}" '%s' "${file}"
rm -f "${file}"
_t_eq "" "$(${fn})" "${fn} is empty with no pidfile"
printf '999999\n' >"${file}"
_t_eq "" "$(${fn})" "${fn} is empty when the recorded pid is dead"
printf '%s\n' "$$" >"${file}"
_t_eq "$$" "$(${fn})" "${fn} returns a live pid"
}
_pid_case hosts_enforcer_pid HOSTS_PIDFILE
_pid_case dns_enforcer_pid DNS_PIDFILE
_pid_case launcher_enforcer_pid LAUNCHER_PIDFILE
_pid_case workout_detector_pid WORKOUT_PIDFILE
_pid_case curfew_enforcer_pid CURFEW_PIDFILE
_pid_case tether_enforcer_pid TETHER_PIDFILE
_pid_case daemon_pid PIDFILE

View File

@ -0,0 +1,178 @@
#!/usr/bin/env bash
# lib/tests/ctl_cases_daemon.sh — assertions for focus_ctl.sh's own commands:
# status, disable, recheck, log, notif-status and the whitelist iterator.
#
# Sourced by test_ctl_libs.sh after ctl_cases_commands.sh, which owns the
# table helpers and the counters. Split out to stay under the 250-line cap.
set -euo pipefail
# --- cmd_status --------------------------------------------------------------
# The location block is the only part that talks to the device; the rest is a
# report of files the tests control.
rm -f "${PIDFILE}"
printf 'focus\n' >"${MODE_FILE}"
printf 'com.a\ncom.b\n' >"${DISABLED_APPS_FILE}"
_seed_out dumpsys 'last location=Location[fused 52.2297,21.0122 hAcc=12]'
out="$(cmd_status 2>&1 || true)"
case "${out}" in
*"Mode: focus"*) _t_pass "status reports the recorded mode" ;;
*) _t_fail "status must report the mode from MODE_FILE" ;;
esac
case "${out}" in
*"Location: 52.2297, 21.0122"*) _t_pass "status reports the current fix" ;;
*) _t_fail "status must report the current location" ;;
esac
# The distance is the THIRD copy of the Haversine in this repo. Asserting only
# that some number is printed lets a wrong Earth radius through, so the value
# is checked: HOME_LAT/HOME_LON are Warsaw and the seeded fix is ~4.3 km away.
case "${out}" in
*"Distance: "*"m from home"*) _t_pass "status reports a distance from home" ;;
*) _t_fail "status must report the distance from home" ;;
esac
status_dist="$(printf '%s\n' "${out}" | sed -n 's/^Distance: \([0-9]*\)m from home$/\1/p')"
if [[ "${status_dist}" -ge 4000 && "${status_dist}" -le 4600 ]]; then
_t_pass "status computes a correct distance (${status_dist} m)"
else
_t_fail "status distance should be ~4300 m, got '${status_dist}'"
fi
case "${out}" in
*com.a*) _t_pass "status lists the apps focus mode disabled" ;;
*) _t_fail "status must list the disabled apps" ;;
esac
# No fix available must say so rather than print an empty distance.
rm -f "${DEV}/out_dumpsys"
case "$(cmd_status 2>&1 || true)" in
*"Location: unavailable"*) _t_pass "status reports an unavailable location" ;;
*) _t_fail "status must report when no fix is available" ;;
esac
# An empty disabled list reads as a sentence, not as blank output.
: >"${DISABLED_APPS_FILE}"
case "$(cmd_status 2>&1 || true)" in
*"No apps currently disabled"*) _t_pass "status states when nothing is disabled" ;;
*) _t_fail "status must state when nothing is disabled" ;;
esac
# An unset mode file is reported as unknown, not as an empty field.
rm -f "${MODE_FILE}"
case "$(cmd_status 2>&1 || true)" in
*"Mode: unknown"*) _t_pass "status reports an unknown mode explicitly" ;;
*) _t_fail "status must report an unknown mode explicitly" ;;
esac
# --- cmd_disable -------------------------------------------------------------
# The restore pass must re-enable every recorded package and then clear the
# record, or the next run would try to re-enable them all again.
printf 'com.one\ncom.two\n' >"${DISABLED_APPS_FILE}"
_reset_calls
_run_quiet cmd_disable
_t_eq "2" "$(_calls | grep -c '^pm enable')" "disable re-enables every recorded app"
if [[ -s "${DISABLED_APPS_FILE}" ]]; then
_t_fail "disable left the record of disabled apps in place"
else
_t_pass "disable clears the record once everything is restored"
fi
: >"${DISABLED_APPS_FILE}"
case "$(cmd_disable 2>&1 || true)" in
*"No apps to re-enable"*) _t_pass "disable states when there is nothing to restore" ;;
*) _t_fail "disable must state when there is nothing to restore" ;;
esac
# --- cmd_recheck / cmd_log / cmd_notif_status --------------------------------
# A recheck with no daemon has nothing to wake, so it must refuse rather than
# leave a trigger file that nothing will ever consume.
rm -f "${RECHECK_TRIGGER}" "${PIDFILE}"
case "$(cmd_recheck 2>&1 || true)" in
*"not running"*) _t_pass "recheck refuses when the daemon is not running" ;;
*) _t_fail "recheck must refuse when the daemon is not running" ;;
esac
if [[ -e "${RECHECK_TRIGGER}" ]]; then
_t_fail "recheck left a trigger nothing will consume"
else
_t_pass "recheck writes no trigger when the daemon is down"
fi
/usr/bin/sleep 30 &
_recheck_victim=$!
printf '%s\n' "${_recheck_victim}" >"${PIDFILE}"
_run_quiet cmd_recheck
if [[ -e "${RECHECK_TRIGGER}" ]]; then
_t_pass "recheck touches the trigger the daemon polls for"
else
_t_fail "recheck must create the trigger file"
fi
kill "${_recheck_victim}" 2>/dev/null || true
wait "${_recheck_victim}" 2>/dev/null || true
rm -f "${PIDFILE}"
printf 'line one\nline two\n' >"${LOG_FILE}"
case "$(cmd_log 2>&1 || true)" in
*"line two"*) _t_pass "log shows the daemon log" ;;
*) _t_fail "log must show the daemon log" ;;
esac
printf '{"mode":"focus"}\n' >"${STATUS_FILE}"
case "$(cmd_notif_status 2>&1 || true)" in
*focus*) _t_pass "notif-status shows the companion snapshot" ;;
*) _t_fail "notif-status must show the status snapshot" ;;
esac
# --- iter_whitelist_packages -------------------------------------------------
# The list is a multi-line quoted string with comments and section headings;
# only real package names may come out, or the sweep would try to disable a
# heading like "---".
_saved_whitelist="${WHITELIST}"
# The undotted token and the comment must each be caught by their OWN filter,
# so the fixture separates them: "# com.commented.out" is a comment whose
# first token IS dotted and IS charset-clean, so only the comment check can
# reject it. "not-a-package" is undotted, so only the dot check rejects it.
# A fixture without both leaves each filter deletable with no test failing.
WHITELIST="
# a comment
# --- a section heading ---
# com.commented.out
com.real.one
com.real.two
notapackage
com.bad!name
com.real.three trailing prose
"
_t_eq "com.real.one com.real.two com.real.three" \
"$(iter_whitelist_packages | tr '\n' ' ' | sed 's/ $//')" \
"iter_whitelist_packages emits only dotted package names"
# The two rejections are asserted separately, because the combined list above
# passes even if only one filter is doing any work. Captured ONCE here, before
# the fixture is replaced below: re-running the function after that would
# assert against a list holding neither an undotted token nor a comment, and
# would pass with both filters deleted.
whitelist_out="$(iter_whitelist_packages)"
case "${whitelist_out}" in
*"notapackage"*) _t_fail "an undotted token was emitted as a package" ;;
*) _t_pass "an undotted token is rejected by the dot check" ;;
esac
case "${whitelist_out}" in
*"com.bad!name"*) _t_fail "a package with illegal characters was emitted" ;;
*) _t_pass "an illegal character is rejected by the charset check" ;;
esac
case "${whitelist_out}" in
*"com.commented.out"*) _t_fail "a commented-out package name was emitted" ;;
*) _t_pass "a comment line is rejected even when its token looks valid" ;;
esac
# A comment whose first token IS dotted would slip past the dot check alone,
# so the comment filter has to be doing the work on its own.
WHITELIST="
# com.commented.out is deliberately disabled
com.real.only
"
_t_eq "com.real.only" "$(iter_whitelist_packages | tr '\n' ' ' | sed 's/ $//')" \
"a commented-out package name is not emitted"
WHITELIST="${_saved_whitelist}"

View File

@ -0,0 +1,165 @@
#!/usr/bin/env bash
# lib/tests/ctl_libs_harness.sh — the fake device behind the tests for the
# libraries split out of focus_ctl.sh.
#
# Sourced, not executed. The subcommands drive the phone through real binaries
# (pm, iptables, settings, setsid, ...), so the stubs go on PATH. The real
# config.sh supplies every default so this fixture cannot drift from the
# values the subcommands actually read; only the paths are redirected.
set -euo pipefail
# TESTS_DIR is declared readonly by the entry point that sources this file.
readonly PHONE_DIR="${TESTS_DIR}/../.."
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
_t_eq() {
local want="$1" got="$2" what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
RUN="$(mktemp -d)"
trap 'rm -rf "${RUN}"' EXIT
mkdir -p "${RUN}/bin" "${RUN}/state"
readonly DEV="${RUN}/state"
# `date` answers from a pinned clock so the window cases are deterministic
# rather than dependent on when the suite happens to run.
cat >"${RUN}/bin/date" <<'STUB'
#!/usr/bin/env bash
if [[ -f "${DEV}/now" && "$1" == "+%H%M" ]]; then
cat "${DEV}/now"
exit 0
fi
exec /usr/bin/date "$@"
STUB
chmod +x "${RUN}/bin/date"
# --- device stubs -----------------------------------------------------------
#
# Every cmd_* subcommand drives the phone through one of these. They log each
# call so a command's effect is assertable, and each honours a fail_<name>
# flag so the "not running" / "chain missing" branches are reachable.
#
# setsid is the important one: the start commands launch a real daemon with
# it. Stubbing it keeps the suite from spawning enforcers on this machine,
# and lets a test decide whether the launch "worked" by seeding the pidfile.
_mk_stub() {
cat >"${RUN}/bin/$1" <<STUB
#!/usr/bin/env bash
printf '%s %s\n' "$1" "\$*" >>"\${DEV}/calls.log"
[[ -f "\${DEV}/fail_$1" ]] && exit 1
[[ -f "\${DEV}/out_$1" ]] && cat "\${DEV}/out_$1"
exit 0
STUB
chmod +x "${RUN}/bin/$1"
}
for _s in pm am settings dumpsys svc sqlite3 getprop content kill sleep setsid; do
_mk_stub "$_s"
done
# iptables/ip6tables also model chain existence, so the MISSING branches and
# the rule counts in the status commands are both reachable.
cat >"${RUN}/bin/iptables" <<'STUB'
#!/usr/bin/env bash
bin="$(basename "$0")"
printf '%s %s\n' "$bin" "$*" >>"${DEV}/calls.log"
case "$1" in
-L) [[ -f "${DEV}/chain_${bin}" ]] && exit 0 || exit 1 ;;
-S) [[ -f "${DEV}/chain_${bin}" ]] && cat "${DEV}/chain_${bin}" || exit 1 ;;
esac
exit 0
STUB
chmod +x "${RUN}/bin/iptables"
cp "${RUN}/bin/iptables" "${RUN}/bin/ip6tables"
export DEV
export PATH="${RUN}/bin:${PATH}"
_calls() { cat "${DEV}/calls.log" 2>/dev/null || printf ''; }
_reset_calls() { : >"${DEV}/calls.log"; }
_fail_op() { touch "${DEV}/fail_$1"; }
_clear_fail() { rm -f "${DEV}/fail_$1"; }
_seed_out() { printf '%s\n' "$2" >"${DEV}/out_$1"; }
_seed_chain() { printf '%s\n' "${2:--A rule}" >"${DEV}/chain_$1"; }
_clear_chain() { rm -f "${DEV}/chain_$1"; }
_set_now() { printf '%s\n' "$1" >"${DEV}/now"; }
# --- ambient definitions the libraries expect from focus_ctl.sh ------------
log() { :; }
# The real config.sh supplies every default, so this fixture cannot drift
# from the values the subcommands actually read. FOCUS_MODE_SCRIPT_DIR points
# its SCRIPT_DIR at the checkout; the paths are then redirected at a temp dir
# so nothing here touches /data/local/tmp.
FOCUS_MODE_SCRIPT_DIR="${PHONE_DIR}"
export FOCUS_MODE_SCRIPT_DIR
# shellcheck source=../../config.sh
. "${PHONE_DIR}/config.sh"
STATE_DIR="${RUN}/state"
LOG_FILE="${STATE_DIR}/focus.log"
MODE_FILE="${STATE_DIR}/mode"
STATUS_FILE="${STATE_DIR}/status.json"
DISABLED_APPS_FILE="${STATE_DIR}/disabled.txt"
DISABLED_COMPETITORS_FILE="${STATE_DIR}/competitors.txt"
RECHECK_TRIGGER="${STATE_DIR}/trigger_recheck"
CURFEW_OVERRIDE_FILE="${STATE_DIR}/curfew_override"
CURFEW_FORCE_FILE="${STATE_DIR}/curfew_force"
CURFEW_ENFORCER_STATE="${STATE_DIR}/curfew_applied"
CURFEW_ENFORCER_LOG="${STATE_DIR}/curfew.log"
TETHER_FORCE_FILE="${STATE_DIR}/tether_force"
TETHER_OVERRIDE_FILE="${STATE_DIR}/tether_override"
TETHER_ENFORCER_STATE="${STATE_DIR}/tether_applied"
TETHER_LOG="${STATE_DIR}/tether.log"
HOSTS_LOG="${STATE_DIR}/hosts.log"
HOSTS_CANONICAL="${STATE_DIR}/hosts.canonical"
HOSTS_SHA_FILE="${STATE_DIR}/hosts.sha256"
HOSTS_TARGET="${STATE_DIR}/hosts_target"
DNS_LOG="${STATE_DIR}/dns.log"
LAUNCHER_LOG="${STATE_DIR}/launcher.log"
LAUNCHER_ACTIVITY_FILE="${STATE_DIR}/launcher.activity"
LAUNCHER_APK="${STATE_DIR}/launcher.apk"
LAUNCHER_SHA_FILE="${STATE_DIR}/launcher.sha256"
WORKOUT_DETECTOR_LOG="${STATE_DIR}/workout.log"
WORKOUT_ACTIVE_FILE="${STATE_DIR}/workout_active"
WORKOUT_DB_PATH="${STATE_DIR}/stronglifts.db"
WORKOUT_SQLITE3_BIN="${RUN}/bin/sqlite3"
NIGHT_CURFEW_START="2300"
NIGHT_CURFEW_END="0500"
# shellcheck source=../../ctl_curfew.sh
. "${PHONE_DIR}/ctl_curfew.sh"
# shellcheck source=../../ctl_hosts.sh
. "${PHONE_DIR}/ctl_hosts.sh"
# shellcheck source=../../ctl_dns.sh
. "${PHONE_DIR}/ctl_dns.sh"
# shellcheck source=../../ctl_launcher.sh
. "${PHONE_DIR}/ctl_launcher.sh"
# shellcheck source=../../ctl_workout.sh
. "${PHONE_DIR}/ctl_workout.sh"
# shellcheck source=../../ctl_tether.sh
. "${PHONE_DIR}/ctl_tether.sh"
# shellcheck source=../../ctl_daemon.sh
. "${PHONE_DIR}/ctl_daemon.sh"
# shellcheck source=../../ctl_usage.sh
. "${PHONE_DIR}/ctl_usage.sh"

View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Unit tests for the libraries split out of focus_ctl.sh, against the fake
# device in ctl_libs_harness.sh. No real phone is needed.
#
# This is the only entry point; the cases live in sourced files purely to stay
# under the 250-line cap, so one coverage command measures every subject.
set -euo pipefail
TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly TESTS_DIR
# shellcheck source=ctl_libs_harness.sh
. "${TESTS_DIR}/ctl_libs_harness.sh"
# shellcheck source=ctl_cases_curfew.sh
. "${TESTS_DIR}/ctl_cases_curfew.sh"
# shellcheck source=ctl_cases_commands.sh
. "${TESTS_DIR}/ctl_cases_commands.sh"
# shellcheck source=ctl_cases_daemon.sh
. "${TESTS_DIR}/ctl_cases_daemon.sh"
printf '\nResults: %d passed, %d failed\n' "${PASS}" "${FAIL}"
[[ "${FAIL}" -eq 0 ]]