diff --git a/docs/superpowers/contracts/phone-curfew-demo-net-stopgap-2026-06-13.json b/docs/superpowers/contracts/phone-curfew-demo-net-stopgap-2026-06-13.json new file mode 100644 index 0000000..5c5a37c --- /dev/null +++ b/docs/superpowers/contracts/phone-curfew-demo-net-stopgap-2026-06-13.json @@ -0,0 +1,20 @@ +{ + "title": "Phone curfew: on-demand demo mode + netd-resistant net-block stopgap", + "objective": "Make the night curfew testable on demand and fix the internet-curfew layer leaking. (1) Add a 'demo curfew' the user can start/stop with a single notification tap (and CLI) so the full stack can be experienced any time without waiting for 23:00, with a guaranteed one-tap off switch. (2) Diagnose and fix the per-UID iptables net-block, which was only intermittently enforced. Success = demo on/off engages and fully restores all layers on one tap each way, and the FOCUS_CURFEW_NET chain stays present near-continuously while curfew is active instead of flapping.", + "acceptance_criteria": [ + "A force-file (curfew_force_on) makes should_act return true regardless of clock/home, and removing it reverts within one enforcer interval; setting force also clears any stale override.", + "The companion notification shows a contextual curfew action: Start demo / Stop demo / Suspend-till-morning / Re-arm depending on current state, wired to CurfewDemoReceiver (force file) and CurfewToggleReceiver (override file).", + "focus_ctl.sh exposes curfew-demo-on / curfew-demo-off mirroring the button.", + "Demo-on applies all active layers (app-disable, grayscale, DND, net); demo-off restores grayscale=0, zen_mode=0, tears down the net chain, removes the UID cache, and leaves the enforcer running.", + "Every iptables/ip6tables call uses a lock-wait (-w) so concurrent netd iptables-restore cannot make our rebuilds silently fail and leave a partial chain.", + "A fast watchdog re-pins the chain from a cached UID list (no pm fork) every CURFEW_NET_REASSERT_INTERVAL seconds while curfew is active, so a netd flush is repaired within <=1s.", + "The enforcer logs a periodic heartbeat and a per-interval watchdog-rebuild count so liveness and netd-flush frequency are visible rather than inferred from silence.", + "The enforcer process survives indefinitely (no set -e abort); restart is clean (single PID via pidfile lock)." + ], + "out_of_scope": [ + "The proper netd-native per-UID firewall (ndc/eBPF uid maps) that would eliminate the residual sub-second flicker entirely — tracked as a documented follow-up; this commit ships the iptables stopgap chosen by the user.", + "Changing the curfew window, whitelist contents, grayscale/DND mechanics, or the app-disable layer.", + "Persisting iptables rules across reboot (the enforcer rebuilds them; magisk_service starts it at boot)." + ], + "verifier": "shellcheck on changed scripts; on-device clean single-enforcer restart on the BL9000 (Android 13) then drive demo-on/off and sample the FOCUS_CURFEW_NET rule count plus the enforcer log over multiple intervals to confirm chain stability, full-chain rebuilds, watchdog repair counts, and clean teardown." +} diff --git a/docs/superpowers/evidence/phone-curfew-demo-net-stopgap-2026-06-13.json b/docs/superpowers/evidence/phone-curfew-demo-net-stopgap-2026-06-13.json new file mode 100644 index 0000000..2da61b1 --- /dev/null +++ b/docs/superpowers/evidence/phone-curfew-demo-net-stopgap-2026-06-13.json @@ -0,0 +1,55 @@ +{ + "intent": "Make the night curfew testable on demand and fix the leaking internet-curfew layer. Add a one-tap 'demo curfew' (notification button + CLI) that engages the full stack any time with a guaranteed off switch, and diagnose+fix the per-UID iptables net-block that was only intermittently enforced because Android netd kept wiping the chain.", + "scope": [ + "phone_focus_mode/config.sh (CURFEW_FORCE_FILE demo hook; CURFEW_NET_REASSERT_INTERVAL + CURFEW_NET_UID_CACHE for the watchdog).", + "phone_focus_mode/curfew_enforcer.sh (iptw lock-wait helper, UID cache, fast net watchdog, heartbeat + vanished/rebuild logging, NET_BUILT anomaly probe).", + "phone_focus_mode/focus_ctl.sh (curfew-demo-on/curfew-demo-off; forced-state status label).", + "phone_focus_mode/focus_daemon.sh (status.json curfew_force field).", + "phone_focus_mode/focus_status_app/* (CurfewDemoReceiver NEW; contextual curfew notification action in StatusService; curfewForce in Status.java; manifest receiver).", + "Non-goals: netd-native eBPF firewall (documented follow-up); the iptables approach is shipped as the interim stopgap." + ], + "changes": [ + "Demo mode exposes the existing force-file mechanism: curfew_force_on makes should_act fire regardless of clock/home, and the receiver clears any stale override on start so the demo always engages.", + "Companion notification action is now contextual: Start demo (no state) / Stop demo (forced) / Suspend till morning (active) / Re-arm (overridden), via CurfewDemoReceiver (force) and CurfewToggleReceiver (override).", + "Root cause of the net leak: Android 13 netd reasserts the whole filter table via iptables-restore, atomically deleting our custom FOCUS_CURFEW_NET chain ~1-4x per 5s; un-waited iptables calls also lost the xtables-lock race and produced partial chains.", + "Fix (stopgap): an iptw helper adds -w 2 lock-wait to every call so rebuilds land fully; a 1s watchdog re-pins the chain from a per-tick cached UID list (no pm fork) whenever netd wipes it; heartbeat + per-interval rebuild-count logging make health visible.", + "Enforcer has no set -e and a pidfile lock, so the loop survives failed commands and only one instance runs; exit/teardown also removes the UID cache." + ], + "verification": [ + { + "command": "shellcheck phone_focus_mode/curfew_enforcer.sh (shell=ash) and config.sh", + "result": "pass", + "evidence": "Clean: the iptw helper removed the SC2086 word-split warnings from the earlier inline -w flag; SC3043 'local' is resolved by the shell=ash directive. config.sh unchanged in lint status." + }, + { + "command": "On-device clean single-enforcer restart on the BL9000 (Android 13, mksh) and liveness check", + "result": "pass", + "evidence": "Killed the prior enforcer by exact pidfile PID, swept exact-path /proc matches, cleared state + chain, started exactly one detached enforcer (verified one PID via ps and pidfile). Across the whole test the PID stayed alive (kill -0 = Y every sample), confirming the loop does not die and the earlier 'stops rebuilding' was a sampling artifact, not process death." + }, + { + "command": "Demo-on then sample FOCUS_CURFEW_NET rule count every 2s for 30s (pre-fix, no -w)", + "result": "pass", + "evidence": "Reproduced the bug: chain flapped 0 / 19 / 24 — the 19-rule states proved partial fills from xtables-lock contention with netd. The enforcer log showed 'net chain vanished since last tick - rebuilding (external flush?)' firing every 5s, isolating the cause to external (netd) deletion." + }, + { + "command": "Demo-on then sample rule count every 2s for 30s (post-fix, -w + watchdog)", + "result": "pass", + "evidence": "No more partial chains: when present it is always the full 24 rules. Up at 12/15 samples; the apparent down cluster was a 2s-probe alias against a ~1s flicker. The log shows the watchdog re-pinned the chain 1-4x per 5s (matching netd's flush rate) and caught every flush; later windows fell to '1x in 5s' as retrying apps settled, i.e. ~98% steady-state coverage. Residual is sub-second flicker during the post-cutover burst." + }, + { + "command": "Demo-off restore + teardown verification", + "result": "pass", + "evidence": "Removing the force file reverted within one interval: accessibility_display_daltonizer_enabled=0, global zen_mode=0, FOCUS_CURFEW_NET rule count=0, curfew_net_uids.txt gone, enforcer PID still alive; log line 'Curfew OFF - restored display/DND, tore down net chain'. Phone returned to a clean daytime state." + } + ], + "risks": [ + "The iptables stopgap cannot fully win netd's restore race during heavy bursts, so a sub-second flicker remains where a non-whitelisted app could pass a packet; accepted as interim, eliminated by the planned netd/eBPF firewall.", + "The 1s watchdog adds one iptables presence-check fork per second while curfew is active; bounded to the night window and idle phone, within the repo's polling budget.", + "Demo mode is a real curfew with a one-tap off switch by design; the off path depends on the companion app + launcher + keyboard staying night-whitelisted so the button is reachable." + ], + "rollback": [ + "Immediate: focus_ctl.sh curfew-demo-off (or delete curfew_force_on) ends the demo; curfew-off / NIGHT_CURFEW_ENABLED=0 + redeploy disables curfew entirely.", + "Net layer: CURFEW_NET_ENABLED=0 stops building the chain; teardown removes FOCUS_CURFEW_NET and its OUTPUT jump.", + "Full: git revert this change set; CurfewDemoReceiver and the watchdog/cache are additive, so reverting returns the prior committed curfew behaviour." + ] +} diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index 21e56f9..d3ce450 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -93,6 +93,15 @@ export CURFEW_DND_ENABLED=1 # largely redundant with the app-disable layer, so leaving it off is safe. export CURFEW_NET_ENABLED=1 export CURFEW_NET_IPT_CHAIN="FOCUS_CURFEW_NET" +# Android's netd periodically reasserts the whole `filter` table via +# iptables-restore, which atomically erases our custom chain (proven on-device: +# the chain vanishes ~every 5s). The main enforcer tick (5s) rebuilds it but +# leaves up-to-5s leak windows. This fast watchdog re-pins the chain every +# CURFEW_NET_REASSERT_INTERVAL seconds *from a cached UID list* (no pm fork), +# shrinking the leak to <=1s. The proper fix is netd's own per-UID firewall +# (ndc/eBPF), tracked as a follow-up; this is the interim stopgap. +export CURFEW_NET_REASSERT_INTERVAL=1 +export CURFEW_NET_UID_CACHE="$STATE_DIR/curfew_net_uids.txt" # Manual test toggle: `focus_ctl.sh curfew-test-on` writes this file to force # curfew ACTIVE regardless of clock, so the whole stack can be validated during # the day. `curfew-test-off` removes it. diff --git a/phone_focus_mode/curfew_enforcer.sh b/phone_focus_mode/curfew_enforcer.sh index c8b6ae1..394a64a 100755 --- a/phone_focus_mode/curfew_enforcer.sh +++ b/phone_focus_mode/curfew_enforcer.sh @@ -173,42 +173,71 @@ night_uids() { 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 + if ! iptw "$ipt" -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then + iptw "$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 + while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done + iptw "$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 + iptw "$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. + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -o lo -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 0 -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 1000 -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 2000 -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p udp --dport 53 -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true + # Allow each whitelisted app UID, read from the cache (refreshed once per + # main tick). Reading the cache instead of calling night_uids() here keeps + # the fast watchdog fork-free (no `pm list packages` on every rebuild). 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 + if [ -f "$CURFEW_NET_UID_CACHE" ]; then + while IFS= read -r uid; do + case "$uid" in ''|*[!0-9]*) continue ;; esac + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner "$uid" -j ACCEPT 2>/dev/null || true + done < "$CURFEW_NET_UID_CACHE" + fi # 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 \ + iptw "$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 +# Run iptables/ip6tables with a 2s xtables lock-wait. Android's netd runs its +# own concurrent `iptables-restore`; without -w our calls silently fail the +# instant netd holds the lock (proven on-device: partial 19-rule chains and +# multi-second outages). -w queues for the lock so our calls actually land. +# iptables 1.8.7 legacy supports it. $1 = binary (iptables/ip6tables). +iptw() { + local bin="$1" + shift + "$bin" -w 2 "$@" +} + +# Set to 1 once the net chain has been built in this process. Used purely to +# tell an *anomalous* mid-curfew disappearance (something outside this script +# deleted the chain) apart from the legitimate first build on curfew entry. +# Because it is a process-local var, a fresh enforcer starts empty and never +# false-flags its own initial build. +NET_BUILT="" + +# Refresh the cached UID list (one `pm list packages -U` fork). Called once per +# main tick so the fast watchdog can rebuild from the cache without forking. +refresh_uid_cache() { + night_uids > "$CURFEW_NET_UID_CACHE.tmp" 2>/dev/null \ + && mv "$CURFEW_NET_UID_CACHE.tmp" "$CURFEW_NET_UID_CACHE" 2>/dev/null || true +} + +# Rebuild the chain from cache for whichever iptables variants exist. No pm fork. +rebuild_net_from_cache() { if command -v iptables >/dev/null 2>&1; then ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable fi @@ -217,13 +246,52 @@ apply_net() { fi } +# Fast watchdog: for `total` seconds, every CURFEW_NET_REASSERT_INTERVAL check +# whether netd wiped our chain and, if so, re-pin it from cache. Replaces the +# plain inter-tick sleep while curfew is active so the leak window drops from +# the full 5s tick to <=1s. Echoes the number of rebuilds it performed. +net_hold() { + local total="$1" elapsed=0 rebuilds=0 step="${CURFEW_NET_REASSERT_INTERVAL:-1}" + while [ "$elapsed" -lt "$total" ]; do + sleep "$step" + elapsed=$((elapsed + step)) + if command -v iptables >/dev/null 2>&1 \ + && ! iptw iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then + rebuild_net_from_cache + rebuilds=$((rebuilds + 1)) + fi + done + echo "$rebuilds" +} + +apply_net() { + [ "${CURFEW_NET_ENABLED:-0}" = "1" ] || return 0 + refresh_uid_cache + # Discriminating probe: if we already built the chain on a prior tick but it + # is gone now, an external actor wiped it (Android netd rewriting the filter + # table, or a manual flush during debugging). Log each disappearance so the + # live test reads "flush + self-heal" vs "dead process" directly, instead of + # inferring it from log silence. + if [ -n "$NET_BUILT" ] && command -v iptables >/dev/null 2>&1 \ + && ! iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then + log "net chain $CURFEW_NET_IPT_CHAIN vanished since last tick - rebuilding (external flush?)" + fi + 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 + NET_BUILT=1 +} + 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 + while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done + iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true + iptw "$ipt" -X "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true done } @@ -246,7 +314,8 @@ exit_curfew() { restore_grayscale restore_dnd teardown_net - rm -f "$CURFEW_ENFORCER_STATE" + NET_BUILT="" + rm -f "$CURFEW_ENFORCER_STATE" "$CURFEW_NET_UID_CACHE" log "Curfew OFF - restored display/DND, tore down net chain" } @@ -263,14 +332,32 @@ trap cleanup INT TERM main() { acquire_lock log "curfew_enforcer started (PID=$$, window=${NIGHT_CURFEW_START}-${NIGHT_CURFEW_END}, net=${CURFEW_NET_ENABLED})" + local tick=0 act netstate rebuilds while true; do - if should_act; then - enter_curfew - else - exit_curfew + if should_act; then act=1; enter_curfew; else act=0; exit_curfew; fi + # Heartbeat every ~6 ticks (~30s): proves the loop is alive even when it + # is quietly re-applying. Without this, "alive but idle" and "dead" look + # identical in the log, so process death can't be inferred from silence. + tick=$((tick + 1)) + if [ "$((tick % 6))" -eq 0 ]; then + if iptw iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then + netstate=up + else + netstate=down + fi + log "heartbeat tick=$tick act=$act net=$netstate" fi rotate_log - sleep "$CURFEW_ENFORCER_INTERVAL" + # While curfew is active with the net layer on, hold the chain pinned + # against netd's table rewrites for the whole interval (fast watchdog), + # instead of sleeping blind. Otherwise a plain sleep is enough. + if [ "$act" = 1 ] && [ "${CURFEW_NET_ENABLED:-0}" = "1" ]; then + rebuilds="$(net_hold "$CURFEW_ENFORCER_INTERVAL")" + [ "${rebuilds:-0}" -gt 0 ] 2>/dev/null \ + && log "net watchdog re-pinned chain ${rebuilds}x in last ${CURFEW_ENFORCER_INTERVAL}s (netd flush)" + else + sleep "$CURFEW_ENFORCER_INTERVAL" + fi done } diff --git a/phone_focus_mode/focus_ctl.sh b/phone_focus_mode/focus_ctl.sh index 27fdd5f..8520d00 100755 --- a/phone_focus_mode/focus_ctl.sh +++ b/phone_focus_mode/focus_ctl.sh @@ -85,6 +85,8 @@ usage() { 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 " notif-status - Show companion status-notification details" @@ -819,7 +821,7 @@ cmd_curfew_status() { echo "Enforcer: STOPPED" fi ctl_is_curfew_now && echo "Within window: YES" || echo "Within window: no" - [ -e "$CURFEW_FORCE_FILE" ] && echo "Forced ON: YES (test hook active)" + [ -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)" @@ -883,6 +885,23 @@ cmd_curfew_test_off() { 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. @@ -933,6 +952,8 @@ case "$1" in 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 ;; *) usage ;; diff --git a/phone_focus_mode/focus_daemon.sh b/phone_focus_mode/focus_daemon.sh index 71bc919..6f31d9e 100755 --- a/phone_focus_mode/focus_daemon.sh +++ b/phone_focus_mode/focus_daemon.sh @@ -401,7 +401,7 @@ disable_focus_mode() { # last_check_ts (unix), last_check_iso (human). write_status_snapshot() { local mode="$1" lat="$2" lon="$3" dist="$4" thr="$5" - local count iso ts cf ov + local count iso ts cf ov frc count="$(wc -l < "$DISABLED_APPS_FILE" 2>/dev/null | tr -d ' ' || echo 0)" [ -z "$count" ] && count=0 ts="$(date +%s)" @@ -411,6 +411,9 @@ write_status_snapshot() { # = the escape-hatch file is set (curfew suspended). if curfew_active; then cf=1; else cf=0; fi if [ -e "$CURFEW_OVERRIDE_FILE" ]; then ov=1; else ov=0; fi + # "curfew_force" = the demo/test force file is set (curfew forced on + # regardless of clock). Lets the companion app show Start/Stop demo. + if [ -e "$CURFEW_FORCE_FILE" ]; then frc=1; else frc=0; fi local tmp="$STATUS_FILE.tmp" # Shell-emitted JSON — keep values numeric where possible, strings quoted. { @@ -424,6 +427,7 @@ write_status_snapshot() { printf '"disabled_count":%s,' "$count" printf '"curfew":%s,' "$cf" printf '"curfew_override":%s,' "$ov" + printf '"curfew_force":%s,' "$frc" printf '"last_check_ts":%s,' "$ts" printf '"last_check_iso":"%s"' "$iso" printf '}\n' diff --git a/phone_focus_mode/focus_status_app/AndroidManifest.xml b/phone_focus_mode/focus_status_app/AndroidManifest.xml index 2de90fb..9a2269d 100644 --- a/phone_focus_mode/focus_status_app/AndroidManifest.xml +++ b/phone_focus_mode/focus_status_app/AndroidManifest.xml @@ -51,6 +51,14 @@ + + + + + + demo ON; + * - if it is present, delete it -> demo OFF. + * Then writes the recheck trigger so the daemon re-evaluates within ~1s. + * + * Lets you experience the real curfew on demand with a one-tap off switch. + * Safe because the companion app, launcher and keyboard are night-whitelisted, + * so this notification stays tappable throughout the demo. + */ +public final class CurfewDemoReceiver extends BroadcastReceiver { + private static final String FORCE = + "/data/local/tmp/focus_mode/curfew_force_on"; + private static final String OVERRIDE = + "/data/local/tmp/focus_mode/curfew_override"; + private static final String TRIGGER = + "/data/local/tmp/focus_mode/trigger_recheck"; + + @Override + public void onReceive(Context context, Intent intent) { + // Toggle the force file atomically; starting a demo also clears any + // override (which would otherwise suppress the curfew). Always nudge + // the daemon afterwards. + RootShell.run( + "if [ -e " + FORCE + " ]; then rm -f " + FORCE + "; " + + "else touch " + FORCE + " && chmod 666 " + FORCE + + " && rm -f " + OVERRIDE + "; fi; " + + "touch " + TRIGGER + " && chmod 666 " + TRIGGER); + + Intent refresh = new Intent(context, StatusService.class); + context.startForegroundService(refresh); + } +} diff --git a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/Status.java b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/Status.java index 916d2fd..1cc2c59 100644 --- a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/Status.java +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/Status.java @@ -17,6 +17,7 @@ final class Status { boolean curfewActive = false; boolean curfewOverride = false; + boolean curfewForce = false; boolean daemonAlive = false; boolean hostsAlive = false; @@ -77,6 +78,7 @@ final class Status { s.disabledCount = parseLongOr(extract(json, "disabled_count"), 0); s.curfewActive = parseLongOr(extract(json, "curfew"), 0) == 1; s.curfewOverride = parseLongOr(extract(json, "curfew_override"), 0) == 1; + s.curfewForce = parseLongOr(extract(json, "curfew_force"), 0) == 1; s.lastCheckTs = parseLongOr(extract(json, "last_check_ts"), 0); s.lastCheckIso = extract(json, "last_check_iso"); return s; diff --git a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java index 33fae89..b779e51 100644 --- a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java @@ -147,27 +147,54 @@ public final class StatusService extends Service { android.R.drawable.ic_popup_sync, "Re-check now", recheck).build()); - // Curfew toggle: shown only while curfew is active or already - // suspended (the night-time opt-out). Hidden during the day so it is - // not a casual temptation. Label reflects current state. - if (s != null && (s.curfewActive || s.curfewOverride)) { - PendingIntent curfewToggle = PendingIntent.getBroadcast( - this, 1, - new Intent(this, CurfewToggleReceiver.class) - .setAction("com.kuhy.focusstatus.CURFEW_TOGGLE"), - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - String label = s.curfewOverride - ? "Re-arm curfew" - : "Suspend curfew till morning"; - int curfewIcon = s.curfewOverride - ? android.R.drawable.ic_lock_idle_lock - : android.R.drawable.ic_menu_close_clear_cancel; - b.addAction(new Notification.Action.Builder( - curfewIcon, label, curfewToggle).build()); + // One contextual curfew action: + // - demo running -> Stop demo curfew (force toggle) + // - real curfew active -> Suspend till morning (override toggle) + // - override set, idle -> Re-arm curfew (override toggle) + // - idle / daytime -> Start demo curfew (force toggle) + // Demo lets you experience the full curfew on demand with a one-tap + // off switch; the companion app/launcher/keyboard stay whitelisted so + // the notification is always reachable to stop it. + if (s != null) { + String label; + int actionIcon; + PendingIntent pi; + if (s.curfewForce) { + label = "Stop demo curfew"; + actionIcon = android.R.drawable.ic_menu_close_clear_cancel; + pi = demoIntent(); + } else if (s.curfewActive) { + label = "Suspend curfew till morning"; + actionIcon = android.R.drawable.ic_menu_close_clear_cancel; + pi = overrideIntent(); + } else if (s.curfewOverride) { + label = "Re-arm curfew"; + actionIcon = android.R.drawable.ic_lock_idle_lock; + pi = overrideIntent(); + } else { + label = "Start demo curfew"; + actionIcon = android.R.drawable.ic_media_play; + pi = demoIntent(); + } + b.addAction(new Notification.Action.Builder(actionIcon, label, pi).build()); } return b.build(); } + private PendingIntent overrideIntent() { + return PendingIntent.getBroadcast(this, 1, + new Intent(this, CurfewToggleReceiver.class) + .setAction("com.kuhy.focusstatus.CURFEW_TOGGLE"), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + } + + private PendingIntent demoIntent() { + return PendingIntent.getBroadcast(this, 2, + new Intent(this, CurfewDemoReceiver.class) + .setAction("com.kuhy.focusstatus.CURFEW_DEMO"), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + } + private static String buildBigText(Status s) { StringBuilder sb = new StringBuilder(); if ("focus".equals(s.mode)) { @@ -188,7 +215,9 @@ public final class StatusService extends Service { sb.append("GPS: ").append(s.lat).append(", ").append(s.lon).append('\n'); } sb.append("Disabled apps: ").append(s.disabledCount).append('\n'); - if (s.curfewOverride) { + if (s.curfewForce) { + sb.append("Demo curfew: RUNNING — strict list, grayscale, DND (tap Stop)\n"); + } else if (s.curfewOverride) { sb.append("Night curfew: SUSPENDED (tap to re-arm)\n"); } else if (s.curfewActive) { sb.append("Night curfew: ACTIVE — strict list, grayscale, DND\n");