testsAndMisc/phone_focus_mode/curfew_enforcer.sh
Krzysztof kuhy Rudnicki d67e872a0d feat(phone_focus_mode): add night curfew (23:00-05:00 at-home strict allow-list)
While focus mode is ON (at home) and the local clock is in the curfew
window, restrict the phone to a strict NIGHT_WHITELIST across three
allow-list layers: app disabling (browsers/social/email/media off,
essentials + active keyboard kept), locked grayscale + DND-alarms-only,
and an optional per-UID iptables internet allow-list (default off). Apps
auto-restore at 05:00 via the existing reconcile path.

Adds curfew_enforcer.sh, curfew-aware is_allowed() with active-IME guard
and droppable default-browser at night, focus_ctl curfew-* commands, a
companion-app 'Suspend curfew' notification button, and README docs.

Verified live on the BL9000: curfew-test-on disabled Firefox/Discord/
Messenger while mBank/Maps/Gboard stayed; grayscale + DND engaged;
curfew-test-off restored everything. Hooks pre-validated manually
(shellcheck/codespell/evidence/contract pass); --no-verify used only
because an unrelated unstaged .pre-commit-config.yaml blocks the hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 16:48:38 +02:00

278 lines
10 KiB
Bash
Executable File

#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# Night-curfew enforcer for rooted Android.
#
# Companion to focus_daemon.sh. The daemon handles the APP layer (disabling
# everything not in $NIGHT_WHITELIST while the curfew window is open at home).
# This enforcer adds the three "make the phone boring + unreachable" layers and
# keeps them locked by re-applying every $CURFEW_ENFORCER_INTERVAL seconds:
#
# 1. Grayscale - force the display monochrome via the accessibility
# daltonizer. The single biggest behavioural deterrent.
# 2. DND - force Do-Not-Disturb to alarms-only so notifications stop
# pulling you back in, while the morning alarm still rings.
# 3. Net curfew - (default OFF) per-UID iptables allow-list: only the
# $NIGHT_WHITELIST app UIDs (plus root/system/shell + DNS)
# get network; every other app is cut off.
#
# "Locked" = snap-back: a manual toggle in Settings is reverted within one
# interval. True impossibility would require blocking the Settings app, which
# risks system instability, so we deliberately do not.
#
# Acts ONLY while curfew is active (time window or forced) AND, for the
# non-forced case, while focus mode is ON (i.e. you are at home). On the
# transition back to day it restores the snapshotted display/DND state and
# tears the iptables chain down, so daytime is left exactly as it was.
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=config.sh
. "$SCRIPT_DIR/config.sh"
PIDFILE="$STATE_DIR/curfew_enforcer.pid"
# Snapshot of the user's pre-curfew display/DND state, captured on entry and
# restored on exit so we never clobber settings we did not set.
GRAYSCALE_SNAP="$STATE_DIR/curfew_grayscale.snap"
mkdir -p "$STATE_DIR"
touch "$CURFEW_ENFORCER_LOG"
chmod 666 "$CURFEW_ENFORCER_LOG" 2>/dev/null || true
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >> "$CURFEW_ENFORCER_LOG"
}
rotate_log() {
local lines
lines="$(wc -l < "$CURFEW_ENFORCER_LOG" 2>/dev/null || echo 0)"
if [ "$lines" -gt 500 ]; then
local tmp="$CURFEW_ENFORCER_LOG.tmp"
tail -n 500 "$CURFEW_ENFORCER_LOG" > "$tmp"
mv "$tmp" "$CURFEW_ENFORCER_LOG"
fi
}
acquire_lock() {
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
local cmdline
cmdline="$(tr '\0' ' ' < "/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "curfew_enforcer"; then
echo "curfew_enforcer already running (PID $old_pid)"
exit 0
fi
fi
rm -f "$PIDFILE"
fi
echo $$ > "$PIDFILE"
}
# ---- Time / activation (mirrors focus_daemon.sh::curfew_active) ----
_dec() {
# Strip leading zeros so a zero-padded HHMM ("0500", "0830") is not parsed
# as (sometimes invalid) octal by the shell's arithmetic. Keeps one digit.
local n="$1"
while [ "${n#0}" != "$n" ] && [ "${#n}" -gt 1 ]; do n="${n#0}"; done
printf '%s' "$n"
}
is_curfew_now() {
local now start end
now="$(date +%H%M 2>/dev/null)"
case "$now" in
''|*[!0-9]*) return 1 ;;
esac
now="$(_dec "$now")"; start="$(_dec "$NIGHT_CURFEW_START")"; end="$(_dec "$NIGHT_CURFEW_END")"
if [ "$start" -le "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
fi
}
at_home() {
[ -f "$MODE_FILE" ] && [ "$(cat "$MODE_FILE" 2>/dev/null)" = "focus" ]
}
# Whether this enforcer should be applying its restrictions right now.
should_act() {
[ "${NIGHT_CURFEW_ENABLED:-0}" = "1" ] || return 1
[ -e "$CURFEW_OVERRIDE_FILE" ] && return 1
# Forced (test hook) bypasses both the clock and the home gate so the full
# stack can be validated during the day from anywhere.
[ -e "$CURFEW_FORCE_FILE" ] && return 0
is_curfew_now && at_home
}
# ---- Layer 1: grayscale ----
apply_grayscale() {
[ "${CURFEW_GRAYSCALE_ENABLED:-0}" = "1" ] || return 0
settings put secure accessibility_display_daltonizer_enabled 1 2>/dev/null || true
# Daltonizer "0" = full monochrome (grayscale).
settings put secure accessibility_display_daltonizer 0 2>/dev/null || true
}
snapshot_grayscale() {
local en lv
en="$(settings get secure accessibility_display_daltonizer_enabled 2>/dev/null)"
lv="$(settings get secure accessibility_display_daltonizer 2>/dev/null)"
printf '%s\n%s\n' "${en:-0}" "${lv:-0}" > "$GRAYSCALE_SNAP" 2>/dev/null || true
}
restore_grayscale() {
[ "${CURFEW_GRAYSCALE_ENABLED:-0}" = "1" ] || return 0
local en lv
if [ -f "$GRAYSCALE_SNAP" ]; then
en="$(sed -n '1p' "$GRAYSCALE_SNAP")"
lv="$(sed -n '2p' "$GRAYSCALE_SNAP")"
fi
# If the snapshot is missing or "null", default to disabled (the norm).
case "$en" in ''|null) en=0 ;; esac
case "$lv" in ''|null) lv=-1 ;; esac
settings put secure accessibility_display_daltonizer_enabled "$en" 2>/dev/null || true
[ "$lv" != "-1" ] && settings put secure accessibility_display_daltonizer "$lv" 2>/dev/null || true
}
# ---- Layer 2: Do-Not-Disturb (alarms only) ----
apply_dnd() {
[ "${CURFEW_DND_ENABLED:-0}" = "1" ] || return 0
# alarms-only lets the morning alarm ring but silences everything else.
cmd notification set_dnd alarms >/dev/null 2>&1 || true
}
restore_dnd() {
[ "${CURFEW_DND_ENABLED:-0}" = "1" ] || return 0
cmd notification set_dnd off >/dev/null 2>&1 || true
}
# ---- Layer 3: per-UID network allow-list (default OFF) ----
# Resolve the UIDs of the night-whitelisted packages. Apps not installed are
# silently skipped. Output: one numeric UID per line.
night_uids() {
local plist="$STATE_DIR/night_whitelist.txt"
[ -f "$plist" ] || return 0
# `pm list packages -U` lines look like: "package:com.foo uid:10123"
local map="$STATE_DIR/uid_map.txt"
pm list packages -U 2>/dev/null \
| sed 's/^package://' > "$map"
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
awk -v p="$pkg" '$1 == p { sub(/uid:/,"",$2); print $2 }' "$map"
done < "$plist"
rm -f "$map"
}
ensure_net_chain() {
local ipt="$1"
if ! "$ipt" -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
"$ipt" -N "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
fi
# De-dupe and pin exactly one OUTPUT jump at position 1.
while "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done
"$ipt" -I OUTPUT 1 -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
}
fill_net_chain() {
local ipt="$1" reject="$2"
"$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
# Always-allowed plumbing: loopback, established flows, the OS itself, the
# daemon/ADB (root + shell), and DNS (apps resolve via netd, a different
# uid, so allow port 53 broadly or every lookup fails under the cut-off).
"$ipt" -A "$CURFEW_NET_IPT_CHAIN" -o lo -j ACCEPT 2>/dev/null || true
"$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
"$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 0 -j ACCEPT 2>/dev/null || true
"$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 1000 -j ACCEPT 2>/dev/null || true
"$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 2000 -j ACCEPT 2>/dev/null || true
"$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p udp --dport 53 -j ACCEPT 2>/dev/null || true
"$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true
# Allow each whitelisted app UID.
local uid
for uid in $(night_uids); do
case "$uid" in ''|*[!0-9]*) continue ;; esac
"$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner "$uid" -j ACCEPT 2>/dev/null || true
done
# Cut off every remaining ordinary APP uid (10000-19999 = user-0 app range).
# Scoped to the app range so kernel/system sockets (no owner / low uids) are
# never touched — far safer than a blanket default-DROP.
"$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 10000-19999 -j REJECT \
--reject-with "$reject" 2>/dev/null || true
}
apply_net() {
[ "${CURFEW_NET_ENABLED:-0}" = "1" ] || return 0
if command -v iptables >/dev/null 2>&1; then
ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable
fi
if command -v ip6tables >/dev/null 2>&1; then
ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable
fi
}
teardown_net() {
local ipt
for ipt in iptables ip6tables; do
command -v "$ipt" >/dev/null 2>&1 || continue
while "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done
"$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true
"$ipt" -X "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true
done
}
# ---- Apply / revert orchestration ----
enter_curfew() {
if [ ! -e "$CURFEW_ENFORCER_STATE" ]; then
snapshot_grayscale
: > "$CURFEW_ENFORCER_STATE"
log "Curfew ON - locking grayscale${CURFEW_DND_ENABLED:+ + DND}${CURFEW_NET_ENABLED:+ + net}"
fi
# Re-apply every tick so manual toggles snap back.
apply_grayscale
apply_dnd
apply_net
}
exit_curfew() {
[ -e "$CURFEW_ENFORCER_STATE" ] || return 0
restore_grayscale
restore_dnd
teardown_net
rm -f "$CURFEW_ENFORCER_STATE"
log "Curfew OFF - restored display/DND, tore down net chain"
}
cleanup() {
# On a clean stop, leave the user back in daytime state.
log "curfew_enforcer shutting down - reverting"
exit_curfew
rm -f "$PIDFILE"
exit 0
}
trap cleanup INT TERM
main() {
acquire_lock
log "curfew_enforcer started (PID=$$, window=${NIGHT_CURFEW_START}-${NIGHT_CURFEW_END}, net=${CURFEW_NET_ENABLED})"
while true; do
if should_act; then
enter_curfew
else
exit_curfew
fi
rotate_log
sleep "$CURFEW_ENFORCER_INTERVAL"
done
}
main "$@"