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");