testsAndMisc/phone_focus_mode/focus_ctl.sh
Krzysztof Rudnicki b83ce5d3c7
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
billsplit CI / quality (push) Has been cancelled
reverse-survivors CI / check (push) Has been cancelled
Fail the build on any shellcheck finding; fix the 222 the old gate hid
The shellcheck hook ran with --severity=warning, so info- and style-level
findings never failed a commit. 222 had accumulated across 94 of 244 shell
files. They were not all style noise -- six were bugs:

  - Five scripts in single_use/misc/testsAndMisc-bash/ sourced
    $SCRIPT_DIR/../../lib/common.sh, one directory too shallow. Nothing exists
    at that path, so all five failed at runtime.
  - setup_personal_website.sh and setup_kcd2_dice_solver.sh called usage()
    (which ends in `exit 0`) and then `exit 1` for an unknown command. The
    exit 1 was unreachable, so a typo'd subcommand reported SUCCESS.
  - Six i3blocks scripts had a `# shellcheck source=` directive missing the
    scripts/periodic_background/ path segment. The stale path had silently
    disabled source-following for all of them.
  - diagnose_arch_performance.sh word-split a newline-joined file list; when
    the find came back empty, awk got no file operands, fell through to
    reading stdin, and the diagnostic hung.
  - lib/common.sh's handle_arg_help_or_unknown ended in an unreachable
    `return 0` while documenting "Returns: 0 if handled" -- a contract no code
    path could satisfy.
  - deploy.sh's SCRIPT_DIR is reassigned by config.sh, which it sources at top
    level, and config.sh can repoint it at the on-device path
    /data/local/tmp/focus_mode. Renamed to DEPLOY_DIR.

The SC1091 bulk (81 findings) was resolved rather than silenced: .shellcheckrc
sets external-sources + source-path=SCRIPTDIR so sourced files are actually
followed and analysed. That is what surfaced the i3blocks and deploy.sh bugs.

17 `# shellcheck disable=` directives remain, each with a written reason, and
only where the finding is provably wrong: injection-test payloads that must not
expand, literal source text being grepped for, POSIX sh scripts on the phone
where arrays do not exist, and source paths that only exist at runtime.
Everything else was fixed in code.

Verified in a CLEAN worktree, not just my own tree. config.sh sources the
gitignored config_secrets.sh, which exists locally but not in a fresh checkout;
a working-tree-only check passed while ci_mirror.sh -- which lints HEAD in a
clean worktree, exactly as CI does -- failed on it.

The gate is verified to bite: a file whose only finding is SC2016 (info) fails
the hook, while the old --severity=warning gate exits 0 on it.

third_party/ is excluded -- vendored upstream, matching the prettier hook.

Not fixed, reported: check_polling_antipatterns.sh carries a pre-existing
SC2034 disable that hides a real bug -- its `patterns` array is never used, so
the checker detects nothing. Confirmed unchanged by this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:49:11 +02:00

1092 lines
33 KiB
Bash
Executable File

#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# Focus Mode Control Utility
# Run on the phone via: su --mount-master -c /data/local/tmp/focus_mode/focus_ctl.sh <command>
# Or from PC via: adb shell su --mount-master -c '/data/local/tmp/focus_mode/focus_ctl.sh <command>'
# --mount-master is required so this script (and any daemon it spawns) joins
# the global mount namespace; otherwise the hosts bind mount is invisible and
# /data/adb/focus_mode/* checks fail due to per-session SELinux isolation.
# ============================================================
SCRIPT_DIR="/data/local/tmp/focus_mode"
. "$SCRIPT_DIR/config.sh"
PIDFILE="$STATE_DIR/daemon.pid"
# ---- Logging ----
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >>"$LOG_FILE"
}
# 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:]]*$//')"
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 ""
}
# 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
}
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
}
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
}
# ---- DNS enforcer ----
# Hosts file only works for the system resolver. Apps using DoH/DoT bypass
# /etc/hosts entirely. The DNS enforcer forces Private DNS off and blocks
# well-known DoH/DoT endpoints so /etc/hosts is actually consulted.
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
}
# ---- Launcher enforcer ----
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
}
# ---- Workout detector ----
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
}
# ============================================================
# Night-curfew control (see curfew_enforcer.sh / focus_daemon.sh)
# ============================================================
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
}
cmd_curfew_log() { tail -n "${1:-50}" "$CURFEW_ENFORCER_LOG" 2>/dev/null || echo "No curfew log yet."; }
# 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)."
}
# ============================================================
# Hotspot / tethering block control (see tether_enforcer.sh)
# ============================================================
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."
}
case "$1" in
start) cmd_start ;;
stop) cmd_stop ;;
status) cmd_status ;;
enable) cmd_enable ;;
disable) cmd_disable ;;
log) cmd_log "${2:-50}" ;;
list-apps) cmd_list_apps ;;
whitelist) cmd_whitelist ;;
restart)
cmd_stop
sleep 2
cmd_start
;;
hosts-status) cmd_hosts_status ;;
hosts-start) cmd_hosts_start ;;
hosts-stop) cmd_hosts_stop ;;
hosts-log) cmd_hosts_log "${2:-50}" ;;
dns-status) cmd_dns_status ;;
dns-start) cmd_dns_start ;;
dns-stop) cmd_dns_stop ;;
dns-log) cmd_dns_log "${2:-50}" ;;
launcher-status) cmd_launcher_status ;;
launcher-start) cmd_launcher_start ;;
launcher-stop) cmd_launcher_stop ;;
launcher-log) cmd_launcher_log "${2:-50}" ;;
launcher-snapshot) cmd_launcher_snapshot ;;
workout-status) cmd_workout_status ;;
workout-start) cmd_workout_start ;;
workout-stop) cmd_workout_stop ;;
workout-log) cmd_workout_log "${2:-50}" ;;
recheck) cmd_recheck ;;
notif-status) cmd_notif_status ;;
curfew-status) cmd_curfew_status ;;
curfew-start) cmd_curfew_start ;;
curfew-stop) cmd_curfew_stop ;;
curfew-log) cmd_curfew_log "${2:-50}" ;;
curfew-test-on) cmd_curfew_test_on ;;
curfew-test-off) cmd_curfew_test_off ;;
curfew-demo-on) cmd_curfew_demo_on ;;
curfew-demo-off) cmd_curfew_demo_off ;;
curfew-off) cmd_curfew_off ;;
curfew-on) cmd_curfew_on ;;
tether-status) cmd_tether_status ;;
tether-start) cmd_tether_start ;;
tether-stop) cmd_tether_stop ;;
tether-log) cmd_tether_log "${2:-50}" ;;
tether-test-on) cmd_tether_test_on ;;
tether-test-off) cmd_tether_test_off ;;
*) usage ;;
esac