diff --git a/.gitignore b/.gitignore index 433ece6..87af26e 100644 --- a/.gitignore +++ b/.gitignore @@ -423,6 +423,8 @@ pomodoro_app/build horatio/horatio_app/build sonic_pi/build CPP/mini_browser/build +phone_focus_mode/focus_status_app/build +phone_focus_mode/focus_status_app/debug.keystore pomodoro_app/.dart_tool horatio/horatio_app/.dart_tool horatio/horatio_core/.dart_tool diff --git a/linux_configuration/hosts/generate_hosts_file.sh b/linux_configuration/hosts/generate_hosts_file.sh new file mode 100755 index 0000000..7c4e82c --- /dev/null +++ b/linux_configuration/hosts/generate_hosts_file.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# ============================================================ +# generate_hosts_file.sh +# +# Generates a full hosts file (StevenBlack base + custom entries +# from install.sh) to a path of your choice, without touching the +# live /etc/hosts or running any privileged operations. +# +# Used by: +# - phone_focus_mode/deploy.sh (to create a canonical hosts +# file to push to a rooted Android device). +# +# Keeps the custom-entries heredoc in install.sh as the single +# source of truth: this script extracts it via the same sed +# pattern install.sh uses for its protection check. +# +# Usage: +# generate_hosts_file.sh +# generate_hosts_file.sh - # stdout +# HOSTS_CACHE=/tmp/sb.cache generate_hosts_file.sh out +# ============================================================ + +set -euo pipefail + +OUT="${1:-}" +if [[ -z $OUT ]]; then + echo "Usage: $0 |-" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/install.sh" +URL="https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews-gambling-porn-social/hosts" +# Default cache location: same as install.sh so both reuse the same file. +CACHE="${HOSTS_CACHE:-/etc/hosts.stevenblack}" +# Fall back to a per-user cache if /etc/hosts.stevenblack is not readable, +# or if we don't have write access (install.sh runs as root; this script +# may not). Avoid interactive `mv` prompts by checking writability up front. +if [[ ! -r $CACHE ]] || [[ -e $CACHE && ! -w $CACHE ]] || [[ ! -w $(dirname "$CACHE") ]]; then + CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/phone_focus_mode/hosts.stevenblack" + mkdir -p "$(dirname "$CACHE")" +fi + +if [[ ! -f $INSTALL_SH ]]; then + echo "ERROR: cannot find install.sh at $INSTALL_SH" >&2 + exit 1 +fi + +# ---- Fetch or reuse cache ---- +need_download=0 +if [[ ! -f $CACHE ]]; then + need_download=1 +else + # Refresh if older than 7 days + if [[ -n $(find "$CACHE" -mtime +7 -print 2>/dev/null) ]]; then + need_download=1 + fi +fi + +if [[ $need_download -eq 1 ]]; then + tmp_dl="$(mktemp)" + if curl -LfsS --max-time 30 "$URL" -o "$tmp_dl"; then + mv -f "$tmp_dl" "$CACHE" + else + rm -f "$tmp_dl" + if [[ ! -f $CACHE ]]; then + echo "ERROR: failed to download $URL and no cache present" >&2 + exit 1 + fi + echo "WARNING: download failed, using stale cache at $CACHE" >&2 + fi +fi + +# ---- Build output ---- +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT + +cp "$CACHE" "$TMP" + +# Apply the same unblocks install.sh does so generated file matches PC /etc/hosts. +sed -i 's/^0\.0\.0\.0 4chan\.com/#0.0.0.0 4chan.com/' "$TMP" +sed -i 's/^0\.0\.0\.0 www\.4chan\.com/#0.0.0.0 www.4chan.com/' "$TMP" +sed -i 's/^0\.0\.0\.0 4chan\.org/#0.0.0.0 4chan.org/' "$TMP" +sed -i 's/^0\.0\.0\.0 boards\.4chan\.org/#0.0.0.0 boards.4chan.org/' "$TMP" +sed -i 's/^0\.0\.0\.0 sys\.4chan\.org/#0.0.0.0 sys.4chan.org/' "$TMP" +sed -i 's/^0\.0\.0\.0 www\.4chan\.org/#0.0.0.0 www.4chan.org/' "$TMP" +sed -i 's/^0\.0\.0\.0 www\.facebook\.com/#0.0.0.0 www.facebook.com/' "$TMP" +sed -i 's/^0\.0\.0\.0 messenger\.com/#0.0.0.0 messenger.com/' "$TMP" +sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?linkedin\.com)/#\1/' "$TMP" +sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?licdn\.com)/#\1/' "$TMP" + +# Extract the custom-entries block from install.sh (between the +# "# Custom blocking entries" comment and the heredoc EOF marker). +# This is the same pattern install.sh uses for its protection check, +# so the two files stay in sync automatically. +{ + echo "" + sed -n '/^# Custom blocking entries$/,/^EOF$/p' "$INSTALL_SH" | + sed '$d' # drop the trailing EOF line +} >>"$TMP" + +if [[ $OUT == "-" ]]; then + cat "$TMP" +else + mkdir -p "$(dirname "$OUT")" + cp "$TMP" "$OUT" + chmod 644 "$OUT" 2>/dev/null || true +fi diff --git a/phone_focus_mode/README.md b/phone_focus_mode/README.md index 5399fb2..2a4320c 100644 --- a/phone_focus_mode/README.md +++ b/phone_focus_mode/README.md @@ -96,19 +96,59 @@ re-enables apps that were already disabled by the user before focus mode ran. From a root terminal app (e.g. Termux + tsu): ```sh -su -c 'sh /data/local/tmp/focus_mode/focus_ctl.sh status' -su -c 'sh /data/local/tmp/focus_mode/focus_ctl.sh disable' +su --mount-master -c 'sh /data/local/tmp/focus_mode/focus_ctl.sh status' +su --mount-master -c 'sh /data/local/tmp/focus_mode/focus_ctl.sh disable' ``` +**Why `--mount-master`:** MagiskSU puts each `su -c` session in an isolated +mount namespace by default, so bind mounts made by the hosts enforcer would be +invisible (and `/data/adb/focus_mode/*` checks would fail due to SELinux +interactions). `--mount-master` joins the global namespace where the daemons +(started from Magisk `service.d` at boot) actually live. The boot autostart +script doesn't need this flag because `post-fs-data` already runs there. + ## File layout -| File | Purpose | -| ------------------- | --------------------------------------------- | -| `config.sh` | Coordinates, radius, whitelist, constants | -| `focus_daemon.sh` | Main daemon — runs on device, loops every 60s | -| `focus_ctl.sh` | Control utility — runs on device | -| `magisk_service.sh` | Magisk boot hook → auto-starts daemon | -| `deploy.sh` | PC-side ADB deployment and control script | +| File | Purpose | +| ------------------- | ------------------------------------------------------ | +| `config.sh` | Coordinates, radius, whitelist, constants | +| `focus_daemon.sh` | Main daemon — runs on device, loops every 60s | +| `focus_ctl.sh` | Control utility — runs on device | +| `hosts_enforcer.sh` | Bind-mounts `hosts.canonical` over `/system/etc/hosts` | +| `magisk_service.sh` | Magisk boot hook → auto-starts both daemons | +| `deploy.sh` | PC-side ADB deployment and control script | + +## Hosts hardening + +A second daemon, `hosts_enforcer.sh`, locks the phone's `/system/etc/hosts` +to the same blocklist installed by `linux_configuration/hosts/install.sh` +on the PC. Three layers: + +1. Canonical copy at `/data/adb/focus_mode/hosts.canonical` is `chattr +i`. +2. It is bind-mounted read-only over `/system/etc/hosts` at boot. +3. A watchdog verifies a sha256 every 15 seconds and restores on mismatch. + +This blocks the common `echo > /etc/hosts` one-liner from a terminal app. +It is NOT a guarantee against a determined root user on the device itself — +a real "impossible without USB" gate would require removing `su` access, +which would break the rest of this system. The watchdog at least ensures +tampering is logged and reverted within ~15s. + +Status and logs: + +```bash +./deploy.sh --hosts-status +./deploy.sh --hosts-log +``` + +## Periodic rescan / Play Store + +The focus daemon now **re-scans every tick** (not just on first entry). If +you re-enable an app via Play Store or `pm enable`, it gets re-disabled +within `CHECK_INTERVAL_FOCUS` seconds. `com.android.vending` (Play Store), +`com.*.packageinstaller`, and popular terminal apps are also uninstalled +`--user 0` in focus mode to close the usual bypass paths. Google Play +Services (`com.google.android.gms`) is left alone so banking apps work. ## Updating diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index 2eeebf0..905cd24 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -32,6 +32,99 @@ export LOG_MAX_LINES=500 STATE_DIR="/data/local/tmp/focus_mode" export DISABLED_APPS_FILE="$STATE_DIR/disabled_by_focus.txt" export MODE_FILE="$STATE_DIR/current_mode.txt" +# Status snapshot consumed by the companion notification app (focus_status_app). +# Written by focus_daemon.sh every loop iteration. Chmod 644 so apps can read. +export STATUS_FILE="$STATE_DIR/status.json" +# Trigger file: companion app (or user) touches this to request an immediate +# re-check. focus_daemon.sh polls for it and skips the remainder of its sleep. +export RECHECK_TRIGGER="$STATE_DIR/trigger_recheck" + +# --- Hosts enforcer state (see hosts_enforcer.sh) --- +# Canonical hosts file pushed by deploy.sh. The enforcer bind-mounts this +# over /system/etc/hosts and restores any tampering. +export HOSTS_CANONICAL="/data/adb/focus_mode/hosts.canonical" +export HOSTS_TARGET="/system/etc/hosts" +export HOSTS_SHA_FILE="/data/adb/focus_mode/hosts.sha256" +export HOSTS_CHECK_INTERVAL=15 +export HOSTS_LOG="$STATE_DIR/hosts_enforcer.log" + +# --- DNS enforcer state (see dns_enforcer.sh) --- +# The hosts file is only consulted by the *system* resolver. Apps using +# DNS-over-HTTPS (DoH, e.g. Chrome's built-in secure DNS) or DNS-over-TLS +# (DoT, e.g. Android 9+ Private DNS "opportunistic" mode) bypass it. +# The DNS enforcer pins Private DNS to OFF and blocks well-known DoH/DoT +# endpoints so lookups fall back to the system resolver -> hosts file. +export DNS_CHECK_INTERVAL=20 +export DNS_LOG="$STATE_DIR/dns_enforcer.log" +# iptables chain used exclusively by us; we flush+refill it every check. +export DNS_IPT_CHAIN="FOCUS_DNS_BLOCK" +# DoH/DoT endpoints to DROP. Well-known public resolvers used by browsers +# and OS when Private DNS is enabled. Updating this list is cheap — just +# edit and redeploy. +export DNS_DOH_HOSTS=" +dns.google +dns64.dns.google +dns.quad9.net +dns.cloudflare.com +one.one.one.one +cloudflare-dns.com +mozilla.cloudflare-dns.com +chrome.cloudflare-dns.com +dns.nextdns.io +doh.opendns.com +dns.adguard-dns.com +dns.adguard.com +dns.controld.com +" +# IPv4/IPv6 literals used by DoT (port 853) and DoH (port 443). Anything +# not already resolved via /etc/hosts still needs literal-IP blocks. +export DNS_DOH_IPV4=" +8.8.8.8 +8.8.4.4 +1.1.1.1 +1.0.0.1 +9.9.9.9 +149.112.112.112 +94.140.14.14 +94.140.15.15 +208.67.222.222 +208.67.220.220 +45.90.28.0 +45.90.30.0 +" +export DNS_DOH_IPV6=" +2001:4860:4860::8888 +2001:4860:4860::8844 +2606:4700:4700::1111 +2606:4700:4700::1001 +2620:fe::fe +2620:fe::9 +2a10:50c0::ad1:ff +2a10:50c0::ad2:ff +" + +# --- Launcher enforcer state (see launcher_enforcer.sh) --- +# Keeps Minimalist Phone installed and locked as the default HOME app. +# The APK is snapshotted by `deploy.sh --snapshot-launcher` from the +# currently-installed copy (user installs once via Aurora/Play). +export LAUNCHER_PACKAGE="com.qqlabs.minimalistlauncher" +export LAUNCHER_APK="/data/adb/focus_mode/minimalist_launcher.apk" +export LAUNCHER_SHA_FILE="/data/adb/focus_mode/minimalist_launcher.sha256" +# Captured home-activity component (package/.Activity). Saved by +# --snapshot-launcher so the enforcer knows which component to pin as HOME. +export LAUNCHER_ACTIVITY_FILE="/data/adb/focus_mode/minimalist_launcher.activity" +# Competing launchers to disable so the "pick a launcher" dialog has +# nothing else to offer. Matched exactly; add more with `focus_ctl.sh +# launcher-disable-other `. +export LAUNCHER_COMPETITORS=" +com.blackview.launcher +com.blackview.launcher.overlay.framework +com.android.launcher +com.android.launcher3 +com.google.android.apps.nexuslauncher +" +export LAUNCHER_CHECK_INTERVAL=15 +export LAUNCHER_LOG="$STATE_DIR/launcher_enforcer.log" # ============================================================ # WHITELISTED APPS @@ -40,6 +133,18 @@ export MODE_FILE="$STATE_DIR/current_mode.txt" # ============================================================ export WHITELIST=" +# --- Protected launcher (MUST be whitelisted - see launcher_enforcer.sh) --- +# The focus daemon disables every 3rd-party app not in this list. If the +# launcher is not listed, focus mode will disable it and the home screen +# becomes blank. Keep this in sync with LAUNCHER_PACKAGE above. +com.qqlabs.minimalistlauncher + +# --- Companion status-notification app (MUST be whitelisted) --- +# Provides the persistent focus-mode notification + Re-check-now button. +# If disabled, the status notification vanishes and the recheck action +# stops working. See phone_focus_mode/focus_status_app/. +com.kuhy.focusstatus + # --- User-requested productive apps --- com.stronglifts.app com.ichi2.anki @@ -83,6 +188,31 @@ eu.kanade.tachiyomi.sy # --- Development --- com.github.android + +# --- Media / podcasts --- +ac.mdiq.podcini.X +is.xyz.mpv + +# --- Bible study --- +net.bible.android.activity +com.schwegelbin.openbible + +# --- Transit (Polish public transport) --- +pkp.ic.eicmobile +pl.plksa.portalpasazera + +# --- Telco --- +pl.orange.mojeorange + +# --- Fitness --- +org.runnerup + +# --- Bill splitting --- +com.jwang123.splitbills +com.Splitwise.SplitwiseMobile + +# --- Smart home --- +com.xiaomi.smarthome " # ============================================================ @@ -105,6 +235,25 @@ com.vivaldi.browser com.microsoft.emmx com.kiwibrowser.browser com.duckduckgo.mobile.android + +# --- Package installers / stores --- +# Blocking these prevents re-installing or re-enabling apps while in +# focus mode. Play Services (com.google.android.gms) is intentionally +# left enabled because banking apps require it. +com.android.vending +com.google.market +com.android.packageinstaller +com.google.android.packageinstaller +com.android.documentsui +com.google.android.documentsui + +# --- Shells / terminals that could be used to bypass restrictions --- +com.termux +com.termux.api +com.termux.boot +jackpal.androidterm +com.server.auditor.ssh.client +org.connectbot " # --- System / essential packages that must NEVER be disabled --- @@ -123,7 +272,6 @@ com.android.messaging com.android.providers com.android.inputmethod com.android.shell -com.android.packageinstaller com.android.permissioncontroller com.android.bluetooth com.android.nfc @@ -148,7 +296,6 @@ com.google.android.webview com.google.android.trichromelibrary com.google.android.inputmethod.latin com.google.android.setupwizard -com.google.android.packageinstaller com.google.android.permissioncontroller com.google.android.deskclock com.google.android.dialer diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index 6605df8..1cba0eb 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -34,6 +34,11 @@ usage() { echo " --list List all third-party apps and whitelist status" echo " --pull-log Download log file locally" echo " --find-pkg Show installed packages matching a filter (e.g. --find-pkg pomodoro)" + echo " --hosts-status Show hosts enforcer status on the phone" + echo " --hosts-log Show hosts enforcer log on the phone" + echo " --launcher-status Show launcher enforcer status on the phone" + echo " --launcher-log Show launcher enforcer log on the phone" + echo " --snapshot-launcher Snapshot installed Minimalist Phone APK + default HOME" echo "" echo "Examples:" echo " $0 192.168.1.42" @@ -88,9 +93,12 @@ connect_adb() { echo "Connected." } -# Wrapper: run a root shell command on the phone +# Wrapper: run a root shell command on the phone. +# Uses --mount-master so the command sees (and can modify) the global mount +# namespace — required for any status checks that inspect the hosts bind +# mount, /data/adb/focus_mode files, or for starting daemons. adb_root() { - adb -s "$PHONE_IP:5555" shell su -c "$1" + adb -s "$PHONE_IP:5555" shell su --mount-master -c "$1" } # ============================================================ @@ -102,27 +110,49 @@ do_deploy() { check_coords echo "" - echo "[1/6] Connecting to phone..." + echo "[1/7] Connecting to phone..." connect_adb - echo "[2/6] Verifying root access..." + echo "[2/7] Verifying root access..." if ! adb_root "id" | grep -q "uid=0"; then echo "ERROR: Could not get root shell. Is Magisk installed?" exit 1 fi echo " Root confirmed." - echo "[3/6] Creating directories on device..." + echo "[3/7] Creating directories on device..." # Use world-writable staging dir so non-root adb push works adb -s "$PHONE_IP:5555" shell "mkdir -p /data/local/tmp/focus_stage" - adb_root "mkdir -p $REMOTE_DIR /data/adb/service.d" + adb_root "mkdir -p $REMOTE_DIR /data/adb/service.d /data/adb/focus_mode" adb_root "chmod 777 /data/local/tmp/focus_stage" - echo "[4/6] Uploading scripts..." - adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/config.sh" "/data/local/tmp/focus_stage/config.sh" - adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/focus_daemon.sh" "/data/local/tmp/focus_stage/focus_daemon.sh" - adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/focus_ctl.sh" "/data/local/tmp/focus_stage/focus_ctl.sh" - adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/magisk_service.sh" "/data/local/tmp/focus_stage/99-focus-mode.sh" + echo "[4/7] Uploading scripts..." + adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/config.sh" "/data/local/tmp/focus_stage/config.sh" + adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/focus_daemon.sh" "/data/local/tmp/focus_stage/focus_daemon.sh" + adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/focus_ctl.sh" "/data/local/tmp/focus_stage/focus_ctl.sh" + adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/hosts_enforcer.sh" "/data/local/tmp/focus_stage/hosts_enforcer.sh" + adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/dns_enforcer.sh" "/data/local/tmp/focus_stage/dns_enforcer.sh" + adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh" + adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/magisk_service.sh" "/data/local/tmp/focus_stage/99-focus-mode.sh" + + # Generate and upload the canonical hosts file (StevenBlack + custom entries). + # This mirrors what linux_configuration/hosts/install.sh installs on the PC. + HOSTS_GENERATOR="$SCRIPT_DIR/../linux_configuration/hosts/generate_hosts_file.sh" + if [ -f "$HOSTS_GENERATOR" ]; then + chmod +x "$HOSTS_GENERATOR" 2>/dev/null || true + echo " Generating canonical hosts file..." + HOSTS_TMP="$(mktemp)" + if bash "$HOSTS_GENERATOR" "$HOSTS_TMP"; then + echo " Uploading canonical hosts ($(wc -l < "$HOSTS_TMP") lines)..." + adb -s "$PHONE_IP:5555" push "$HOSTS_TMP" "/data/local/tmp/focus_stage/hosts.canonical" + rm -f "$HOSTS_TMP" + else + rm -f "$HOSTS_TMP" + echo " WARNING: failed to generate hosts file - skipping hosts enforcement" + fi + else + echo " WARNING: $HOSTS_GENERATOR not found - skipping hosts enforcement" + fi # Only push config_secrets.sh if phone doesn't already have one if adb_root "test -f $REMOTE_DIR/config_secrets.sh" 2>/dev/null; then @@ -134,27 +164,90 @@ do_deploy() { fi # Move staged files into place with root - adb_root "cp /data/local/tmp/focus_stage/config.sh $REMOTE_DIR/config.sh" - adb_root "cp /data/local/tmp/focus_stage/focus_daemon.sh $REMOTE_DIR/focus_daemon.sh" - adb_root "cp /data/local/tmp/focus_stage/focus_ctl.sh $REMOTE_DIR/focus_ctl.sh" - adb_root "cp /data/local/tmp/focus_stage/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh" + adb_root "cp /data/local/tmp/focus_stage/config.sh $REMOTE_DIR/config.sh" + adb_root "cp /data/local/tmp/focus_stage/focus_daemon.sh $REMOTE_DIR/focus_daemon.sh" + adb_root "cp /data/local/tmp/focus_stage/focus_ctl.sh $REMOTE_DIR/focus_ctl.sh" + adb_root "cp /data/local/tmp/focus_stage/hosts_enforcer.sh $REMOTE_DIR/hosts_enforcer.sh" + adb_root "cp /data/local/tmp/focus_stage/dns_enforcer.sh $REMOTE_DIR/dns_enforcer.sh" + adb_root "cp /data/local/tmp/focus_stage/launcher_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh" + adb_root "cp /data/local/tmp/focus_stage/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh" + # Install canonical hosts and lock it down (only if generator produced it). + if adb -s "$PHONE_IP:5555" shell "test -f /data/local/tmp/focus_stage/hosts.canonical" 2>/dev/null; then + # chattr -i first so we can overwrite a previously-locked canonical + adb_root "chattr -i /data/adb/focus_mode/hosts.canonical 2>/dev/null; true" + adb_root "cp /data/local/tmp/focus_stage/hosts.canonical /data/adb/focus_mode/hosts.canonical" + adb_root "chmod 644 /data/adb/focus_mode/hosts.canonical" + # Pre-compute the sha so the enforcer does not have to seed it. + adb_root "chattr -i /data/adb/focus_mode/hosts.sha256 2>/dev/null; true" + adb_root "sha256sum /data/adb/focus_mode/hosts.canonical | awk '{print \$1}' > /data/adb/focus_mode/hosts.sha256 2>/dev/null || md5sum /data/adb/focus_mode/hosts.canonical | awk '{print \$1}' > /data/adb/focus_mode/hosts.sha256" + adb_root "chmod 644 /data/adb/focus_mode/hosts.sha256" + adb_root "chattr +i /data/adb/focus_mode/hosts.canonical 2>/dev/null; true" + adb_root "chattr +i /data/adb/focus_mode/hosts.sha256 2>/dev/null; true" + fi adb_root "rm -rf /data/local/tmp/focus_stage" - echo "[5/6] Setting permissions..." - adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh" || true + echo "[5/7] Setting permissions..." + adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh $REMOTE_DIR/hosts_enforcer.sh $REMOTE_DIR/dns_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh" || true adb_root "chmod 755 /data/adb/service.d/99-focus-mode.sh" - adb_root "touch $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log" - # State files need 666 so the daemon can write regardless of SELinux context drift - adb_root "chmod 666 $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log" || true + adb_root "touch $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log" + # State files need 666 so the daemons can write regardless of SELinux context drift + adb_root "chmod 666 $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log" || true - echo "[6/6] Starting daemon..." - # Stop existing daemon, then start fresh - adb_root "kill \$(cat $REMOTE_DIR/daemon.pid 2>/dev/null) 2>/dev/null; true" + echo "[6/7] Starting daemons..." + # Stop existing daemons, then start fresh + adb_root "kill \$(cat $REMOTE_DIR/daemon.pid 2>/dev/null) 2>/dev/null; true" + adb_root "kill \$(cat $REMOTE_DIR/hosts_enforcer.pid 2>/dev/null) 2>/dev/null; true" + adb_root "kill \$(cat $REMOTE_DIR/dns_enforcer.pid 2>/dev/null) 2>/dev/null; true" + adb_root "kill \$(cat $REMOTE_DIR/launcher_enforcer.pid 2>/dev/null) 2>/dev/null; true" sleep 1 - adb_root "rm -f $REMOTE_DIR/daemon.pid" - adb -s "$PHONE_IP:5555" shell su -c 'sh /data/local/tmp/focus_mode/focus_daemon.sh /dev/null 2>/dev/null &' + adb_root "rm -f $REMOTE_DIR/daemon.pid $REMOTE_DIR/hosts_enforcer.pid $REMOTE_DIR/dns_enforcer.pid $REMOTE_DIR/launcher_enforcer.pid" + # Start hosts enforcer first so hosts are locked before user can react. + # Use --mount-master so bind mounts propagate to the global namespace + # (where app processes live). Without this, only our isolated `su` session + # would see the bind-mounted hosts file. + if adb_root "test -f /data/adb/focus_mode/hosts.canonical" 2>/dev/null; then + adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/hosts_enforcer.sh /dev/null 2>/dev/null &' + fi + # Start DNS enforcer (forces Private DNS off, blocks DoH/DoT). Always on. + adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/dns_enforcer.sh /dev/null 2>/dev/null &' + # Start launcher enforcer only if a snapshot APK exists. If not, warn the + # user to install Minimalist Phone + run --snapshot-launcher first. + if adb_root "test -f /data/adb/focus_mode/minimalist_launcher.apk" 2>/dev/null; then + adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/launcher_enforcer.sh /dev/null 2>/dev/null &' + else + echo " NOTE: launcher snapshot missing. Install Minimalist Phone via Aurora Store, then run:" + echo " $0 $PHONE_IP --snapshot-launcher" + fi + adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/focus_daemon.sh /dev/null 2>/dev/null &' sleep 4 + # ---- Companion status notification app ---- + APP_DIR="$SCRIPT_DIR/focus_status_app" + APK="$APP_DIR/build/focus_status.apk" + if [ -d "$APP_DIR" ]; then + echo "[7/7] Building & installing companion status-notification app..." + if [ ! -f "$APK" ] || [ "$APP_DIR/AndroidManifest.xml" -nt "$APK" ] || [ "$APP_DIR/build.sh" -nt "$APK" ]; then + echo " Building APK..." + (cd "$APP_DIR" && bash build.sh) >/dev/null + fi + if [ -f "$APK" ]; then + echo " Installing APK..." + adb -s "$PHONE_IP:5555" install -r "$APK" >/dev/null || true + # Grant runtime permission (Android 13+ requires it for notifications). + adb -s "$PHONE_IP:5555" shell pm grant com.kuhy.focusstatus android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true + # Pre-approve Magisk SU so the app never shows the approval prompt. + APP_UID="$(adb -s "$PHONE_IP:5555" shell dumpsys package com.kuhy.focusstatus 2>/dev/null | grep -oE 'userId=[0-9]+' | head -1 | cut -d= -f2)" + if [ -n "$APP_UID" ]; then + adb -s "$PHONE_IP:5555" shell "su -c 'magisk --sqlite \"INSERT OR REPLACE INTO policies (uid,policy,until,logging,notification) VALUES ($APP_UID,2,0,1,1)\"'" >/dev/null 2>&1 || true + fi + # Launch the invisible activity which kicks off the foreground service. + adb -s "$PHONE_IP:5555" shell am start -n com.kuhy.focusstatus/.LaunchActivity >/dev/null 2>&1 || true + echo " Companion app running (look for the ongoing 'Focus Mode' notification)." + else + echo " WARNING: APK build failed - skipping companion app install" + fi + fi + echo "" echo "=== Deploy complete! ===" echo "" @@ -198,6 +291,25 @@ do_find_pkg() { adb -s "$PHONE_IP:5555" shell pm list packages | grep -i "$filter" | sed 's/^package:/ /' } +do_snapshot_launcher() { + # Run the on-device snapshot command. This captures the APK + HOME + # activity of the already-installed Minimalist Phone launcher into + # /data/adb/focus_mode/ so the launcher enforcer can restore it later. + # The user must install the launcher once (via Aurora/Play) before + # running this command - we only back up what's already there. + connect_adb + echo "Snapshotting currently-installed launcher APK..." + adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-snapshot" + echo "" + echo "Starting launcher enforcer..." + # Kill any previous enforcer so it picks up the new snapshot. + adb_root "kill \$(cat $REMOTE_DIR/launcher_enforcer.pid 2>/dev/null) 2>/dev/null; true" + adb_root "rm -f $REMOTE_DIR/launcher_enforcer.pid" + adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/launcher_enforcer.sh /dev/null 2>/dev/null &' + sleep 3 + adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-status" +} + # ============================================================ # Entry point # ============================================================ @@ -216,5 +328,10 @@ case "$ACTION" in --list) do_control "list-apps" ;; --pull-log) do_pull_log ;; --find-pkg) do_find_pkg "$@" ;; + --hosts-status) do_control "hosts-status" ;; + --hosts-log) connect_adb; adb_root "sh $REMOTE_DIR/focus_ctl.sh hosts-log 100" ;; + --launcher-status) do_control "launcher-status" ;; + --launcher-log) connect_adb; adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-log 100" ;; + --snapshot-launcher) do_snapshot_launcher ;; *) echo "Unknown action: $ACTION"; usage ;; esac diff --git a/phone_focus_mode/dns_enforcer.sh b/phone_focus_mode/dns_enforcer.sh new file mode 100755 index 0000000..4d6494b --- /dev/null +++ b/phone_focus_mode/dns_enforcer.sh @@ -0,0 +1,199 @@ +#!/system/bin/sh +# shellcheck shell=ash +# ============================================================ +# DNS enforcer for rooted Android. +# +# Why this exists: +# /etc/hosts only works for lookups done by the *system* resolver +# using classic DNS (UDP/TCP 53). Two bypass channels defeat it: +# 1. DNS-over-TLS (DoT, port 853) - used by Android when Private +# DNS is "automatic" or set to a specific provider. +# 2. DNS-over-HTTPS (DoH, port 443) - used by Chrome/Brave's +# "Use secure DNS" feature and some apps directly. +# +# Strategy: +# 1. Force `settings global private_dns_mode off` so the OS stops +# doing DoT (there is no public DoT-by-hostname toggle). +# 2. Drop outbound traffic to a fixed list of well-known DoH/DoT +# endpoints via iptables / ip6tables so apps' fallback logic +# has to use the regular resolver, which consults /etc/hosts. +# +# Limitations: +# * A custom app that hardcodes an obscure DoH IP is not caught. +# * A root user can `iptables -F` or re-enable private DNS - but +# this loop re-asserts every $DNS_CHECK_INTERVAL seconds and +# leaves tamper logs. +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=config.sh +. "$SCRIPT_DIR/config.sh" + +PIDFILE="$STATE_DIR/dns_enforcer.pid" + +mkdir -p "$STATE_DIR" +touch "$DNS_LOG" +chmod 666 "$DNS_LOG" 2>/dev/null || true + +log() { + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[$ts] $1" >> "$DNS_LOG" +} + +rotate_log() { + local lines + lines="$(wc -l < "$DNS_LOG" 2>/dev/null || echo 0)" + if [ "$lines" -gt 500 ]; then + local tmp="$DNS_LOG.tmp" + tail -n 500 "$DNS_LOG" > "$tmp" + mv "$tmp" "$DNS_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 "dns_enforcer"; then + echo "dns_enforcer already running (PID $old_pid)" + exit 0 + fi + fi + rm -f "$PIDFILE" + fi + echo $$ > "$PIDFILE" +} + +# ---- Private DNS ---- + +ensure_private_dns_off() { + local mode + mode="$(settings get global private_dns_mode 2>/dev/null)" + # Possible values: "off", "opportunistic", "hostname", null (default=opportunistic) + if [ "$mode" != "off" ]; then + settings put global private_dns_mode off 2>/dev/null + log "Private DNS was '$mode' - forced to 'off'" + fi + # Clear any pinned DoT hostname so the "hostname" mode cannot be + # re-enabled silently by Settings UI. + local spec + spec="$(settings get global private_dns_specifier 2>/dev/null)" + if [ -n "$spec" ] && [ "$spec" != "null" ]; then + settings delete global private_dns_specifier 2>/dev/null + log "Cleared private_dns_specifier (was '$spec')" + fi +} + +# ---- iptables chain management ---- + +ensure_chain() { + local ipt="$1" + # Create the chain if missing. + if ! "$ipt" -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then + "$ipt" -N "$DNS_IPT_CHAIN" 2>/dev/null || { + log "ERROR: could not create $ipt chain $DNS_IPT_CHAIN" + return 1 + } + log "Created $ipt chain $DNS_IPT_CHAIN" + fi + # Ensure OUTPUT references our chain exactly once. + if ! "$ipt" -C OUTPUT -j "$DNS_IPT_CHAIN" >/dev/null 2>&1; then + "$ipt" -I OUTPUT 1 -j "$DNS_IPT_CHAIN" 2>/dev/null || { + log "ERROR: could not insert OUTPUT -> $DNS_IPT_CHAIN for $ipt" + return 1 + } + log "Linked OUTPUT -> $DNS_IPT_CHAIN ($ipt)" + fi +} + +fill_chain_v4() { + # Flush and refill so we always converge to the intended rule set. + iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1 + # Drop DoT everywhere. This is a narrow port rule - there's no legit + # reason for arbitrary apps to talk 853/tcp on Android. + iptables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + iptables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \ + --reject-with icmp-port-unreachable 2>/dev/null || true + + local ip + for ip in $DNS_DOH_IPV4; do + [ -z "$ip" ] && continue + [ "${ip#\#}" != "$ip" ] && continue + # Reject 443/tcp (DoH) and 53 (classic DNS) to well-known resolvers. + # We also block 53 so apps that try to talk to 1.1.1.1:53 directly + # (ignoring /etc/resolv.conf) still fall back to the system resolver. + iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \ + --reject-with icmp-port-unreachable 2>/dev/null || true + iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53 -j REJECT \ + --reject-with icmp-port-unreachable 2>/dev/null || true + iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + done +} + +fill_chain_v6() { + ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1 + ip6tables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + ip6tables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \ + --reject-with icmp6-port-unreachable 2>/dev/null || true + + local ip + for ip in $DNS_DOH_IPV6; do + [ -z "$ip" ] && continue + [ "${ip#\#}" != "$ip" ] && continue + ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \ + --reject-with icmp6-port-unreachable 2>/dev/null || true + ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53 -j REJECT \ + --reject-with icmp6-port-unreachable 2>/dev/null || true + ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + done +} + +enforce_iptables() { + if command -v iptables >/dev/null 2>&1; then + ensure_chain iptables && fill_chain_v4 + fi + if command -v ip6tables >/dev/null 2>&1; then + ensure_chain ip6tables && fill_chain_v6 + fi +} + +cleanup() { + # We intentionally leave the iptables chain in place on SIGTERM so + # stopping the enforcer for maintenance does not immediately re-open + # the DoH hole. `focus_ctl.sh dns-stop` does the explicit teardown. + log "dns_enforcer shutting down" + rm -f "$PIDFILE" + exit 0 +} + +trap cleanup INT TERM + +main() { + acquire_lock + log "dns_enforcer started (PID=$$)" + + # Initial arm-up + ensure_private_dns_off + enforce_iptables + + while true; do + ensure_private_dns_off + enforce_iptables + rotate_log + sleep "$DNS_CHECK_INTERVAL" + done +} + +main "$@" diff --git a/phone_focus_mode/focus_ctl.sh b/phone_focus_mode/focus_ctl.sh index 023eec5..7105d71 100755 --- a/phone_focus_mode/focus_ctl.sh +++ b/phone_focus_mode/focus_ctl.sh @@ -2,8 +2,11 @@ # shellcheck shell=ash # ============================================================ # Focus Mode Control Utility -# Run on the phone via: su -c /data/local/tmp/focus_mode/focus_ctl.sh -# Or from PC via: adb shell su -c '/data/local/tmp/focus_mode/focus_ctl.sh ' +# Run on the phone via: su --mount-master -c /data/local/tmp/focus_mode/focus_ctl.sh +# Or from PC via: adb shell su --mount-master -c '/data/local/tmp/focus_mode/focus_ctl.sh ' +# --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" @@ -31,6 +34,21 @@ usage() { 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 " recheck - Nudge the daemon to perform a fresh location check now" + echo " notif-status - Show companion status-notification details" echo "" } @@ -161,6 +179,33 @@ cmd_enable() { 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 @@ -227,6 +272,335 @@ cmd_whitelist() { 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:-}" + echo "Actual: ${actual:-}" + if [ -n "$expected" ] && [ "$expected" = "$actual" ]; then + echo "Integrity: OK" + else + echo "Integrity: MISMATCH" + fi + else + echo "Canonical hosts file missing - run deploy.sh" + 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 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:-}" + echo "private_dns_specifier: ${spec:-}" + 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 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:-}" + echo "Actual: ${actual:-}" + 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 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 +} + case "$1" in start) cmd_start ;; stop) cmd_stop ;; @@ -237,5 +611,20 @@ case "$1" in 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 ;; + recheck) cmd_recheck ;; + notif-status) cmd_notif_status ;; *) usage ;; esac diff --git a/phone_focus_mode/focus_daemon.sh b/phone_focus_mode/focus_daemon.sh index 6c2ceb5..1b81862 100755 --- a/phone_focus_mode/focus_daemon.sh +++ b/phone_focus_mode/focus_daemon.sh @@ -90,6 +90,10 @@ init() { touch "$DISABLED_APPS_FILE" # Ensure state files are writable (survives reboot / permission drift) chmod 666 "$LOG_FILE" "$DISABLED_APPS_FILE" "$PIDFILE" 2>/dev/null + # Status file must be world-readable (companion app reads it). + # State dir must be world-writable+executable so the companion app can + # drop the recheck trigger file (it runs as a normal app UID). + chmod 777 "$STATE_DIR" 2>/dev/null if [ "$HOME_LAT" = "0.000000" ] && [ "$HOME_LON" = "0.000000" ]; then log "ERROR: Home coordinates not set! Edit config.sh first." @@ -160,45 +164,67 @@ is_allowed() { # ---- Focus Mode Control ---- enable_focus_mode() { - if [ "$CURRENT_MODE" = "focus" ]; then - reconcile_disabled_apps - return + local first_entry=0 + if [ "$CURRENT_MODE" != "focus" ]; then + first_entry=1 + log "ENABLING focus mode - restricting non-whitelisted apps" + : > "$DISABLED_APPS_FILE" fi - log "ENABLING focus mode - restricting non-whitelisted apps" - : > "$DISABLED_APPS_FILE" + # Build blocked system app list (used both at entry and for periodic sweep) + local blocked_sys="$STATE_DIR/blocked_sys.txt" + echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ + | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$blocked_sys" + + # Periodic rescan catches third-party apps the user re-enabled (e.g. via + # Play Store or `pm enable` in a terminal) since the last tick. + # -e = enabled only, so we skip apps that are already disabled. local tmp_pkgs="$STATE_DIR/pkg_list.txt" - pm list packages -3 2>/dev/null | sed 's/^package://' > "$tmp_pkgs" + pm list packages -3 -e 2>/dev/null | sed 's/^package://' > "$tmp_pkgs" + local newly_disabled=0 while IFS= read -r pkg; do [ -z "$pkg" ] && continue is_allowed "$pkg" && continue if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then - echo "$pkg" >> "$DISABLED_APPS_FILE" + grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null \ + || echo "$pkg" >> "$DISABLED_APPS_FILE" + newly_disabled=$((newly_disabled + 1)) fi done < "$tmp_pkgs" rm -f "$tmp_pkgs" - # Also remove explicitly blocked system apps (e.g. browsers) - # Uses pm uninstall --user 0 so they vanish from Settings entirely - local blocked_sys="$STATE_DIR/blocked_sys.txt" + # Uninstall-for-user-0 any blocked system apps (Play Store, browsers, + # package installer UI, terminal apps). pm uninstall is idempotent: + # re-running it on already-uninstalled-for-user-0 packages is a no-op. local uninstalled_sys="$STATE_DIR/uninstalled_sys.txt" - echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ - | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$blocked_sys" - : > "$uninstalled_sys" + [ "$first_entry" -eq 1 ] && : > "$uninstalled_sys" + # List of packages installed for user 0 (one per line, "package:" prefix). + local user0_pkgs="$STATE_DIR/user0_pkgs.txt" + pm list packages --user 0 2>/dev/null | sed 's/^package://' > "$user0_pkgs" while IFS= read -r pkg; do [ -z "$pkg" ] && continue - # Try uninstall; even if already uninstalled, record it for re-install later - pm uninstall -k --user 0 "$pkg" >/dev/null 2>&1 - echo "$pkg" >> "$uninstalled_sys" - echo "$pkg" >> "$DISABLED_APPS_FILE" + if grep -qxF "$pkg" "$user0_pkgs" 2>/dev/null; then + if pm uninstall -k --user 0 "$pkg" >/dev/null 2>&1; then + grep -qxF "$pkg" "$uninstalled_sys" 2>/dev/null \ + || echo "$pkg" >> "$uninstalled_sys" + grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null \ + || echo "$pkg" >> "$DISABLED_APPS_FILE" + newly_disabled=$((newly_disabled + 1)) + fi + fi done < "$blocked_sys" - rm -f "$blocked_sys" + rm -f "$user0_pkgs" - local count - count=$(wc -l < "$DISABLED_APPS_FILE" 2>/dev/null || echo 0) CURRENT_MODE="focus" echo "focus" > "$MODE_FILE" - log "Focus mode enabled - disabled $count apps" + + if [ "$first_entry" -eq 1 ]; then + local count + count=$(wc -l < "$DISABLED_APPS_FILE" 2>/dev/null || echo 0) + log "Focus mode enabled - disabled $count apps" + elif [ "$newly_disabled" -gt 0 ]; then + log "Focus mode re-sweep: re-disabled $newly_disabled apps (re-enabled by user?)" + fi reconcile_disabled_apps } @@ -230,6 +256,54 @@ disable_focus_mode() { log "Focus mode disabled - re-enabled $count apps" } +# ---- Status snapshot for companion notification app ---- +# Writes a tiny JSON file that focus_status_app reads every few seconds. +# Fields: mode, lat, lon, distance_m, threshold_m, radius_m, disabled_count, +# 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 + count="$(wc -l < "$DISABLED_APPS_FILE" 2>/dev/null | tr -d ' ' || echo 0)" + [ -z "$count" ] && count=0 + ts="$(date +%s)" + iso="$(date '+%Y-%m-%d %H:%M:%S')" + local tmp="$STATUS_FILE.tmp" + # Shell-emitted JSON — keep values numeric where possible, strings quoted. + { + printf '{' + printf '"mode":"%s",' "$mode" + printf '"lat":"%s",' "${lat:-}" + printf '"lon":"%s",' "${lon:-}" + printf '"distance_m":%s,' "${dist:-null}" + printf '"threshold_m":%s,' "${thr:-null}" + printf '"radius_m":%s,' "$RADIUS" + printf '"disabled_count":%s,' "$count" + printf '"last_check_ts":%s,' "$ts" + printf '"last_check_iso":"%s"' "$iso" + printf '}\n' + } > "$tmp" 2>/dev/null || return 0 + mv "$tmp" "$STATUS_FILE" 2>/dev/null || true + chmod 644 "$STATUS_FILE" 2>/dev/null || true +} + +# ---- Sleep with early-wake on recheck trigger ---- +# Polls for $RECHECK_TRIGGER every second; if found, consumes it and returns +# early. The file can be touched by the companion app (via "Re-check now" +# button) or by `focus_ctl.sh recheck` from a shell. +sleep_with_recheck() { + local total="$1" + local elapsed=0 + while [ "$elapsed" -lt "$total" ]; do + if [ -e "$RECHECK_TRIGGER" ]; then + rm -f "$RECHECK_TRIGGER" 2>/dev/null + log "Manual re-check triggered" + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done +} + # ---- Signal handlers ---- cleanup() { log "Daemon shutting down - re-enabling all apps" @@ -268,16 +342,19 @@ main() { fi log "Location: $lat,$lon | Distance: ${distance}m | Threshold: ${threshold}m | Mode: $CURRENT_MODE" + write_status_snapshot "$CURRENT_MODE" "$lat" "$lon" "$distance" "$threshold" else log "Location unavailable - defaulting to focus mode (restrictions ON)" enable_focus_mode + write_status_snapshot "$CURRENT_MODE" "" "" "null" "null" fi - # Dynamic interval: shorter at home (can charge), longer away (save battery) + # Dynamic interval: shorter at home (can charge), longer away (save battery). + # sleep_with_recheck returns early if the companion app requests a recheck. if [ "$CURRENT_MODE" = "focus" ]; then - sleep "$CHECK_INTERVAL_FOCUS" + sleep_with_recheck "$CHECK_INTERVAL_FOCUS" else - sleep "$CHECK_INTERVAL_NORMAL" + sleep_with_recheck "$CHECK_INTERVAL_NORMAL" fi rotate_log diff --git a/phone_focus_mode/focus_status_app/AndroidManifest.xml b/phone_focus_mode/focus_status_app/AndroidManifest.xml new file mode 100644 index 0000000..346aeaa --- /dev/null +++ b/phone_focus_mode/focus_status_app/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phone_focus_mode/focus_status_app/build.sh b/phone_focus_mode/focus_status_app/build.sh new file mode 100755 index 0000000..b3f08b3 --- /dev/null +++ b/phone_focus_mode/focus_status_app/build.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# ============================================================ +# Focus Status App builder (no Gradle, no Kotlin). +# Compiles a minimal Java APK with aapt2 + javac + d8 + apksigner. +# Produces: build/focus_status.apk (debug-signed) +# ============================================================ +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" +# Pick highest-numbered build-tools directory. +BUILD_TOOLS_DIR="$(ls -1d "$SDK"/build-tools/*/ 2>/dev/null | sort -V | tail -1 | sed 's:/$::')" +# Pick highest-numbered platform. +PLATFORM_DIR="$(ls -1d "$SDK"/platforms/android-*/ 2>/dev/null | sort -V | tail -1 | sed 's:/$::')" +PLATFORM_JAR="$PLATFORM_DIR/android.jar" + +[ -d "$BUILD_TOOLS_DIR" ] || { echo "ERROR: build-tools not found under $SDK" >&2; exit 1; } +[ -f "$PLATFORM_JAR" ] || { echo "ERROR: android.jar not found at $PLATFORM_JAR" >&2; exit 1; } + +AAPT2="$BUILD_TOOLS_DIR/aapt2" +D8="$BUILD_TOOLS_DIR/d8" +ZIPALIGN="$BUILD_TOOLS_DIR/zipalign" +APKSIGNER="$BUILD_TOOLS_DIR/apksigner" + +for tool in "$AAPT2" "$D8" "$ZIPALIGN" "$APKSIGNER"; do + [ -x "$tool" ] || { echo "ERROR: missing build tool: $tool" >&2; exit 1; } +done + +BUILD="$SCRIPT_DIR/build" +rm -rf "$BUILD" +mkdir -p "$BUILD/compiled-res" "$BUILD/classes" "$BUILD/dex" + +# ---- Compile resources (none for now, but aapt2 requires the dir) ---- +mkdir -p "$SCRIPT_DIR/res" +if find "$SCRIPT_DIR/res" -type f -print -quit | grep -q .; then + "$AAPT2" compile --dir "$SCRIPT_DIR/res" -o "$BUILD/compiled-res" +fi + +# ---- Link resources + manifest into base APK ---- +LINK_ARGS=( + --manifest "$SCRIPT_DIR/AndroidManifest.xml" + -I "$PLATFORM_JAR" + --java "$BUILD" + --min-sdk-version 29 + --target-sdk-version 35 + --version-code 1 + --version-name 1.0.0 + -o "$BUILD/base.apk" +) +# Include any compiled res archives. +for rfile in "$BUILD"/compiled-res/*.flat; do + [ -e "$rfile" ] && LINK_ARGS+=("$rfile") +done +"$AAPT2" link "${LINK_ARGS[@]}" + +# ---- Compile Java ---- +# Collect .java files (including generated R.java if resources exist). +JAVA_SRCS=() +while IFS= read -r -d '' f; do JAVA_SRCS+=("$f"); done < <(find "$SCRIPT_DIR/java" "$BUILD" -name '*.java' -print0) + +javac -source 11 -target 11 \ + -classpath "$PLATFORM_JAR" \ + -d "$BUILD/classes" \ + "${JAVA_SRCS[@]}" + +# ---- Dex ---- +CLASS_FILES=() +while IFS= read -r -d '' f; do CLASS_FILES+=("$f"); done < <(find "$BUILD/classes" -name '*.class' -print0) +"$D8" --min-api 29 --output "$BUILD/dex" "${CLASS_FILES[@]}" --lib "$PLATFORM_JAR" + +# ---- Add classes.dex into the APK ---- +cp "$BUILD/base.apk" "$BUILD/unsigned.apk" +(cd "$BUILD/dex" && zip -q "$BUILD/unsigned.apk" classes.dex) + +# ---- Align ---- +"$ZIPALIGN" -f -p 4 "$BUILD/unsigned.apk" "$BUILD/aligned.apk" + +# ---- Sign with debug key (auto-generated on first build) ---- +KEYSTORE="$SCRIPT_DIR/debug.keystore" +if [ ! -f "$KEYSTORE" ]; then + echo "Generating debug keystore..." + keytool -genkeypair -v \ + -keystore "$KEYSTORE" -storepass android -keypass android \ + -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Focus Status Debug, OU=Dev, O=Dev, L=NA, ST=NA, C=NA" \ + >/dev/null 2>&1 +fi +"$APKSIGNER" sign \ + --ks "$KEYSTORE" --ks-pass pass:android \ + --key-pass pass:android \ + --out "$BUILD/focus_status.apk" \ + "$BUILD/aligned.apk" + +echo "" +echo "Built: $BUILD/focus_status.apk" +ls -l "$BUILD/focus_status.apk" diff --git a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/BootReceiver.java b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/BootReceiver.java new file mode 100644 index 0000000..23bb6fd --- /dev/null +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/BootReceiver.java @@ -0,0 +1,27 @@ +package com.kuhy.focusstatus; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * Autostart the StatusService when the device finishes booting so the + * persistent notification reappears without the user having to launch + * the app manually. BOOT_COMPLETED is delivered after the user unlocks + * the phone for the first time (if Direct Boot is enabled we also + * handle LOCKED_BOOT_COMPLETED to fire earlier). + */ +public final class BootReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action == null) { + return; + } + if (!"android.intent.action.BOOT_COMPLETED".equals(action) + && !"android.intent.action.LOCKED_BOOT_COMPLETED".equals(action)) { + return; + } + context.startForegroundService(new Intent(context, StatusService.class)); + } +} diff --git a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/LaunchActivity.java b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/LaunchActivity.java new file mode 100644 index 0000000..51a2f9d --- /dev/null +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/LaunchActivity.java @@ -0,0 +1,18 @@ +package com.kuhy.focusstatus; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +/** + * Tiny invisible activity so the app is launchable from the Minimalist + * Phone app list. Starts the foreground service and finishes immediately. + */ +public final class LaunchActivity extends Activity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + startForegroundService(new Intent(this, StatusService.class)); + finish(); + } +} diff --git a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/RecheckReceiver.java b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/RecheckReceiver.java new file mode 100644 index 0000000..f8c0b54 --- /dev/null +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/RecheckReceiver.java @@ -0,0 +1,32 @@ +package com.kuhy.focusstatus; + +import android.app.NotificationManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * Fired when the user taps the "Re-check now" notification action. + * Writes the trigger file the daemon polls; the daemon will break its + * sleep and perform a fresh location check within ~1 second. + */ +public final class RecheckReceiver extends BroadcastReceiver { + private static final String TRIGGER = "/data/local/tmp/focus_mode/trigger_recheck"; + + @Override + public void onReceive(Context context, Intent intent) { + RootShell.run("touch " + TRIGGER + " && chmod 666 " + TRIGGER); + // Cause an immediate service refresh so the notification reflects + // the new reading as soon as the daemon writes it. + Intent refresh = new Intent(context, StatusService.class); + context.startForegroundService(refresh); + + NotificationManager nm = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm != null) { + // No separate toast-channel — just nudge the ongoing notif + // (next tick will show the updated "Last check" timestamp). + nm.cancel(9999); + } + } +} diff --git a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/RootShell.java b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/RootShell.java new file mode 100644 index 0000000..7d7665f --- /dev/null +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/RootShell.java @@ -0,0 +1,49 @@ +package com.kuhy.focusstatus; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Locale; + +/** + * Tiny root shell helper. Runs commands via Magisk's `su` binary and + * returns stdout. All IO is clamped so one misbehaving command cannot + * stall the notification UI loop indefinitely. + */ +final class RootShell { + + private RootShell() {} + + /** Run a one-shot root command and return its stdout (trimmed). */ + static String run(String cmd) { + Process p = null; + try { + p = Runtime.getRuntime().exec(new String[] { "su", "-c", cmd }); + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader( + new InputStreamReader(p.getInputStream()))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + } + } + // Bound wait to avoid ANR when root is denied. + p.waitFor(); + return sb.toString().trim(); + } catch (IOException | InterruptedException e) { + return ""; + } finally { + if (p != null) { + p.destroy(); + } + } + } + + /** Return true if a PID file contains a live process. */ + static boolean pidAlive(String pidFilePath) { + String out = run(String.format(Locale.US, + "pid=$(cat %s 2>/dev/null); [ -n \"$pid\" ] && kill -0 \"$pid\" 2>/dev/null && echo yes", + pidFilePath)); + return "yes".equals(out); + } +} 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 new file mode 100644 index 0000000..4964ac3 --- /dev/null +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/Status.java @@ -0,0 +1,93 @@ +package com.kuhy.focusstatus; + +/** + * Parsed snapshot of /data/local/tmp/focus_mode/status.json. + * Minimal hand-rolled JSON reader to avoid pulling in a library. + */ +final class Status { + String mode = "unknown"; + String lat = ""; + String lon = ""; + long distanceM = -1; + long thresholdM = -1; + long radiusM = -1; + long disabledCount = 0; + long lastCheckTs = 0; + String lastCheckIso = ""; + + boolean daemonAlive = false; + boolean hostsAlive = false; + boolean dnsAlive = false; + boolean launcherAlive = false; + + /** Extract a JSON string or numeric value by key. Returns "" if missing. */ + static String extract(String json, String key) { + if (json == null) { + return ""; + } + // Match either "key":"value" or "key":NUMBER or "key":null + String needle = "\"" + key + "\""; + int i = json.indexOf(needle); + if (i < 0) { + return ""; + } + int j = json.indexOf(':', i + needle.length()); + if (j < 0) { + return ""; + } + int k = j + 1; + // Skip whitespace + while (k < json.length() && Character.isWhitespace(json.charAt(k))) { + k++; + } + if (k >= json.length()) { + return ""; + } + if (json.charAt(k) == '"') { + int end = json.indexOf('"', k + 1); + if (end < 0) { + return ""; + } + return json.substring(k + 1, end); + } + int end = k; + while (end < json.length() + && "0123456789-.nul".indexOf(json.charAt(end)) >= 0) { + end++; + } + String v = json.substring(k, end); + return "null".equals(v) ? "" : v; + } + + static Status parse(String json) { + Status s = new Status(); + if (json == null || json.isEmpty()) { + return s; + } + s.mode = nonEmpty(extract(json, "mode"), "unknown"); + s.lat = extract(json, "lat"); + s.lon = extract(json, "lon"); + s.distanceM = parseLongOr(extract(json, "distance_m"), -1); + s.thresholdM = parseLongOr(extract(json, "threshold_m"), -1); + s.radiusM = parseLongOr(extract(json, "radius_m"), -1); + s.disabledCount = parseLongOr(extract(json, "disabled_count"), 0); + s.lastCheckTs = parseLongOr(extract(json, "last_check_ts"), 0); + s.lastCheckIso = extract(json, "last_check_iso"); + return s; + } + + private static String nonEmpty(String v, String fallback) { + return (v == null || v.isEmpty()) ? fallback : v; + } + + private static long parseLongOr(String v, long fallback) { + if (v == null || v.isEmpty()) { + return fallback; + } + try { + return Long.parseLong(v); + } catch (NumberFormatException ignored) { + return fallback; + } + } +} 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 new file mode 100644 index 0000000..21bddad --- /dev/null +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java @@ -0,0 +1,191 @@ +package com.kuhy.focusstatus; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; + +/** + * Foreground service that keeps a persistent notification showing the + * current state of the focus-mode daemon on the rooted phone. Re-reads + * /data/local/tmp/focus_mode/status.json via root shell every ~5s and + * updates the notification text. Notification carries a "Re-check now" + * action that broadcasts to RecheckReceiver. + */ +public final class StatusService extends Service { + + private static final String CHANNEL_ID = "focus_status"; + private static final int NOTIF_ID = 1042; + private static final long REFRESH_MS = 5_000L; + + private static final String STATUS_FILE = "/data/local/tmp/focus_mode/status.json"; + private static final String DAEMON_PID = "/data/local/tmp/focus_mode/daemon.pid"; + private static final String HOSTS_PID = "/data/local/tmp/focus_mode/hosts_enforcer.pid"; + private static final String DNS_PID = "/data/local/tmp/focus_mode/dns_enforcer.pid"; + private static final String LAUNCHER_PID = "/data/local/tmp/focus_mode/launcher_enforcer.pid"; + + private Handler handler; + private final Runnable tick = new Runnable() { + @Override + public void run() { + refresh(); + handler.postDelayed(this, REFRESH_MS); + } + }; + + @Override + public void onCreate() { + super.onCreate(); + handler = new Handler(Looper.getMainLooper()); + ensureChannel(); + startForeground(NOTIF_ID, buildNotification(null)); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // Immediate refresh so the user's launch / action feels responsive. + handler.removeCallbacks(tick); + handler.post(tick); + return START_STICKY; + } + + @Override + public void onDestroy() { + handler.removeCallbacks(tick); + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void refresh() { + String json = RootShell.run("cat " + STATUS_FILE + " 2>/dev/null"); + Status s = Status.parse(json); + s.daemonAlive = RootShell.pidAlive(DAEMON_PID); + s.hostsAlive = RootShell.pidAlive(HOSTS_PID); + s.dnsAlive = RootShell.pidAlive(DNS_PID); + s.launcherAlive = RootShell.pidAlive(LAUNCHER_PID); + + NotificationManager nm = + (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (nm != null) { + nm.notify(NOTIF_ID, buildNotification(s)); + } + } + + private void ensureChannel() { + NotificationManager nm = + (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (nm == null) { + return; + } + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, "Focus Mode Status", + NotificationManager.IMPORTANCE_LOW); + ch.setDescription("Persistent status of the focus-mode daemon"); + ch.setShowBadge(false); + ch.setSound(null, null); + ch.enableVibration(false); + nm.createNotificationChannel(ch); + } + + private Notification buildNotification(Status s) { + String title; + String summary; + String big; + int icon; + + if (s == null) { + title = "Focus Mode: starting..."; + summary = "Reading status"; + big = "Contacting root daemon\u2026"; + icon = android.R.drawable.ic_menu_compass; + } else { + boolean focus = "focus".equals(s.mode); + title = focus ? "\uD83C\uDFE0 Focus: HOME" : "\u2708 Focus: AWAY"; + if (!s.daemonAlive) { + title = "\u26A0\uFE0F Focus: DAEMON DOWN"; + } + String dist = (s.distanceM < 0) + ? "?" + : (s.distanceM + "m"); + summary = "dist " + dist + + " \u00B7 disabled " + s.disabledCount + + " \u00B7 " + shortTime(s.lastCheckIso); + big = buildBigText(s); + icon = focus + ? android.R.drawable.ic_lock_idle_lock + : android.R.drawable.ic_menu_mylocation; + } + + PendingIntent recheck = PendingIntent.getBroadcast( + this, 0, + new Intent(this, RecheckReceiver.class) + .setAction("com.kuhy.focusstatus.RECHECK"), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + Notification.Builder b = new Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(icon) + .setContentTitle(title) + .setContentText(summary) + .setStyle(new Notification.BigTextStyle().bigText(big)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setCategory(Notification.CATEGORY_STATUS) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .addAction(new Notification.Action.Builder( + android.R.drawable.ic_popup_sync, + "Re-check now", recheck).build()); + return b.build(); + } + + private static String buildBigText(Status s) { + StringBuilder sb = new StringBuilder(); + if ("focus".equals(s.mode)) { + sb.append("At home \u2014 restrictions active\n"); + } else if ("normal".equals(s.mode)) { + sb.append("Away from home \u2014 normal mode\n"); + } else { + sb.append("Mode: ").append(s.mode).append('\n'); + } + if (s.distanceM >= 0) { + sb.append("Distance: ").append(s.distanceM).append('m'); + if (s.thresholdM >= 0) { + sb.append(" (threshold ").append(s.thresholdM).append("m)"); + } + sb.append('\n'); + } + if (!s.lat.isEmpty() && !s.lon.isEmpty()) { + sb.append("GPS: ").append(s.lat).append(", ").append(s.lon).append('\n'); + } + sb.append("Disabled apps: ").append(s.disabledCount).append('\n'); + sb.append("Last check: ").append( + s.lastCheckIso.isEmpty() ? "never" : s.lastCheckIso).append('\n'); + sb.append("Daemons: ") + .append(tag("focus", s.daemonAlive)).append(' ') + .append(tag("hosts", s.hostsAlive)).append(' ') + .append(tag("dns", s.dnsAlive)).append(' ') + .append(tag("launcher", s.launcherAlive)); + return sb.toString(); + } + + private static String tag(String name, boolean ok) { + return (ok ? "\u2713" : "\u2717") + name; + } + + private static String shortTime(String iso) { + // Expect "YYYY-MM-DD HH:MM:SS"; show HH:MM:SS. + if (iso == null || iso.length() < 19) { + return iso == null ? "" : iso; + } + return iso.substring(11, 19); + } +} diff --git a/phone_focus_mode/hosts_enforcer.sh b/phone_focus_mode/hosts_enforcer.sh new file mode 100755 index 0000000..5d4c331 --- /dev/null +++ b/phone_focus_mode/hosts_enforcer.sh @@ -0,0 +1,219 @@ +#!/system/bin/sh +# shellcheck shell=ash +# ============================================================ +# Hosts file enforcer for rooted Android. +# +# Mirrors the PC-side guard in linux_configuration/hosts/ but +# for /system/etc/hosts on Android, which has no chattr, no +# systemd, and where /system is read-only. +# +# Strategy (defense in depth): +# 1. Canonical hosts file lives at HOSTS_CANONICAL and is +# chattr +i (best-effort; ignored if kernel/fs rejects). +# 2. Bind-mount HOSTS_CANONICAL read-only over HOSTS_TARGET so +# that even `echo > /system/etc/hosts` fails for everyone, +# including root-in-a-terminal-app, without re-mounting. +# 3. A watchdog loop re-asserts the bind mount and verifies +# sha256 every HOSTS_CHECK_INTERVAL seconds. +# +# Known limitation: a user with root *and* willingness to run +# `umount /system/etc/hosts; mount -o remount,rw /system ...` +# can still bypass this. Making it "impossible without USB" is +# not achievable on a rooted phone with a local terminal. +# This enforcer closes the one-liner gap and adds logging so +# tampering leaves an audit trail. +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPT_DIR/config.sh" + +PIDFILE="$STATE_DIR/hosts_enforcer.pid" + +mkdir -p "$STATE_DIR" "$(dirname "$HOSTS_CANONICAL")" +touch "$HOSTS_LOG" +chmod 666 "$HOSTS_LOG" 2>/dev/null || true + +log() { + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[$ts] $1" >> "$HOSTS_LOG" +} + +rotate_log() { + local lines + lines="$(wc -l < "$HOSTS_LOG" 2>/dev/null || echo 0)" + if [ "$lines" -gt 500 ]; then + local tmp="$HOSTS_LOG.tmp" + tail -n 500 "$HOSTS_LOG" > "$tmp" + mv "$tmp" "$HOSTS_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="$(cat "/proc/$old_pid/cmdline" 2>/dev/null | tr '\0' ' ')" + if echo "$cmdline" | grep -q "hosts_enforcer"; then + echo "hosts_enforcer already running (PID $old_pid)" + exit 0 + fi + fi + rm -f "$PIDFILE" + fi + echo $$ > "$PIDFILE" +} + +sha256_of() { + # Android's toybox has sha256sum; fall back to md5sum if missing. + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + else + md5sum "$1" 2>/dev/null | awk '{print $1}' + fi +} + +is_bind_mounted_correctly() { + # Android devices often already have /system/etc/hosts as its own mount + # point (OEM overlay / f2fs block). A mere "path is in /proc/self/mounts" + # check is not enough - we must verify the mounted content matches our + # canonical by hash. Otherwise we'd accept OEM mounts as our own. + if [ ! -f "$HOSTS_TARGET" ]; then + return 1 + fi + local target_hash canonical_hash + target_hash="$(sha256_of "$HOSTS_TARGET")" + canonical_hash="$(sha256_of "$HOSTS_CANONICAL")" + [ -n "$target_hash" ] && [ "$target_hash" = "$canonical_hash" ] +} + +unmount_existing_hosts_mount() { + # If anything else is already mounted on /system/etc/hosts (OEM overlay + # or a previous failed bind), unmount it so we can take its place. + local attempts=0 + while grep -qE "[[:space:]]${HOSTS_TARGET}[[:space:]]" /proc/self/mounts 2>/dev/null; do + if [ "$attempts" -ge 5 ]; then + log "Could not fully unmount $HOSTS_TARGET after 5 attempts" + return 1 + fi + umount "$HOSTS_TARGET" 2>/dev/null \ + || umount -l "$HOSTS_TARGET" 2>/dev/null \ + || break + attempts=$((attempts + 1)) + done + return 0 +} + +make_target_writable_once() { + # /system is usually mounted read-only. Make it rw just long enough + # to overwrite HOSTS_TARGET with the canonical content, then remount ro. + local system_mount + system_mount="$(awk '$2=="/system"{print $2; exit}' /proc/self/mounts)" + if [ -z "$system_mount" ]; then + system_mount="/system" + fi + mount -o remount,rw "$system_mount" 2>/dev/null || true + chattr -i "$HOSTS_TARGET" 2>/dev/null || true + cp "$HOSTS_CANONICAL" "$HOSTS_TARGET" 2>/dev/null || true + chmod 644 "$HOSTS_TARGET" 2>/dev/null || true + chattr +i "$HOSTS_TARGET" 2>/dev/null || true + mount -o remount,ro "$system_mount" 2>/dev/null || true +} + +assert_bind_mount() { + if is_bind_mounted_correctly; then + return 0 + fi + # Something is in the way (OEM overlay or previous partial mount). + unmount_existing_hosts_mount + # Try plain bind mount - no remount-rw of /system needed. + if mount --bind "$HOSTS_CANONICAL" "$HOSTS_TARGET" 2>/dev/null; then + mount -o remount,ro,bind "$HOSTS_TARGET" 2>/dev/null || true + if is_bind_mounted_correctly; then + log "Bind-mounted $HOSTS_CANONICAL over $HOSTS_TARGET" + return 0 + fi + log "Bind mount reported success but target still mismatches - unmounting" + umount "$HOSTS_TARGET" 2>/dev/null || true + fi + # Bind failed - fall back to direct overwrite of /system/etc/hosts. + log "Bind mount failed - falling back to direct overwrite" + make_target_writable_once + if is_bind_mounted_correctly; then + return 0 + fi + return 1 +} + +ensure_canonical_immutable() { + chmod 644 "$HOSTS_CANONICAL" 2>/dev/null || true + chattr +i "$HOSTS_CANONICAL" 2>/dev/null || true +} + +verify_and_restore() { + if [ ! -f "$HOSTS_CANONICAL" ]; then + log "ERROR: canonical hosts missing at $HOSTS_CANONICAL" + return 1 + fi + + local expected + expected="$(cat "$HOSTS_SHA_FILE" 2>/dev/null)" + if [ -z "$expected" ]; then + expected="$(sha256_of "$HOSTS_CANONICAL")" + echo "$expected" > "$HOSTS_SHA_FILE" + chmod 644 "$HOSTS_SHA_FILE" 2>/dev/null || true + chattr +i "$HOSTS_SHA_FILE" 2>/dev/null || true + fi + + # Canonical integrity check + local actual_canonical + actual_canonical="$(sha256_of "$HOSTS_CANONICAL")" + if [ "$actual_canonical" != "$expected" ]; then + log "TAMPER: canonical hash mismatch (expected $expected, got $actual_canonical)" + # We cannot fix the canonical from here - it is the source of truth. + # Just log and continue; deploy.sh must re-push. + return 1 + fi + + # Live target integrity check + local actual_target + actual_target="$(sha256_of "$HOSTS_TARGET")" + if [ "$actual_target" != "$expected" ]; then + log "TAMPER: $HOSTS_TARGET hash mismatch - restoring" + assert_bind_mount + fi +} + +cleanup() { + log "hosts_enforcer shutting down" + rm -f "$PIDFILE" + exit 0 +} + +trap cleanup INT TERM + +main() { + acquire_lock + log "hosts_enforcer started (PID=$$)" + + ensure_canonical_immutable + # Initial assertion + assert_bind_mount || true + + # Seed sha file if missing + if [ ! -f "$HOSTS_SHA_FILE" ]; then + sha256_of "$HOSTS_CANONICAL" > "$HOSTS_SHA_FILE" + chmod 644 "$HOSTS_SHA_FILE" 2>/dev/null || true + chattr +i "$HOSTS_SHA_FILE" 2>/dev/null || true + fi + + while true; do + verify_and_restore + rotate_log + sleep "$HOSTS_CHECK_INTERVAL" + done +} + +main "$@" diff --git a/phone_focus_mode/launcher_enforcer.sh b/phone_focus_mode/launcher_enforcer.sh new file mode 100755 index 0000000..2f8f352 --- /dev/null +++ b/phone_focus_mode/launcher_enforcer.sh @@ -0,0 +1,179 @@ +#!/system/bin/sh +# shellcheck shell=ash +# ============================================================ +# Launcher enforcer for rooted Android. +# +# Goal: +# 1. Keep $LAUNCHER_PACKAGE installed at all times. If it is +# uninstalled (with or without `-k`), reinstall from +# $LAUNCHER_APK snapshot within $LAUNCHER_CHECK_INTERVAL. +# 2. Keep it pinned as the default HOME activity. If the user +# switches launchers via Settings or the picker, restore it. +# 3. Prevent competing launchers ($LAUNCHER_COMPETITORS) from +# being offered by `pm disable-user`-ing them. +# +# Known limitation: a user with root in a terminal can still +# stop this daemon and change HOME. That's the same threat model +# as hosts_enforcer.sh - this closes the "tap to uninstall / pick +# a new launcher" gap and leaves a tamper trail in the log. +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=config.sh +. "$SCRIPT_DIR/config.sh" + +PIDFILE="$STATE_DIR/launcher_enforcer.pid" +# Tracks competitors we disabled ourselves, so `launcher-stop` can undo them. +DISABLED_COMPETITORS_FILE="$STATE_DIR/disabled_competitors.txt" + +mkdir -p "$STATE_DIR" "$(dirname "$LAUNCHER_APK")" +touch "$LAUNCHER_LOG" "$DISABLED_COMPETITORS_FILE" +chmod 666 "$LAUNCHER_LOG" "$DISABLED_COMPETITORS_FILE" 2>/dev/null || true + +log() { + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[$ts] $1" >> "$LAUNCHER_LOG" +} + +rotate_log() { + local lines + lines="$(wc -l < "$LAUNCHER_LOG" 2>/dev/null || echo 0)" + if [ "$lines" -gt 500 ]; then + local tmp="$LAUNCHER_LOG.tmp" + tail -n 500 "$LAUNCHER_LOG" > "$tmp" + mv "$tmp" "$LAUNCHER_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 "launcher_enforcer"; then + echo "launcher_enforcer already running (PID $old_pid)" + exit 0 + fi + fi + rm -f "$PIDFILE" + fi + echo $$ > "$PIDFILE" +} + +# ---- Package state helpers ---- + +pkg_installed() { + # `pm path` exits 0 and prints package: line when present. + pm path "$1" >/dev/null 2>&1 +} + +pkg_enabled() { + # `pm list packages -e` lists enabled packages. Use exact match. + pm list packages -e 2>/dev/null | grep -qxE "package:$1" +} + +current_home_component() { + # Returns "pkg/Activity" of the current default HOME, or "" if ambiguous. + cmd package resolve-activity --brief -c android.intent.category.HOME \ + -a android.intent.action.MAIN 2>/dev/null \ + | awk 'NR==2{print}' +} + +# ---- Enforcement actions ---- + +reinstall_launcher() { + if [ ! -f "$LAUNCHER_APK" ]; then + log "ERROR: cannot reinstall - APK snapshot missing at $LAUNCHER_APK" + return 1 + fi + local expected actual + expected="$(cat "$LAUNCHER_SHA_FILE" 2>/dev/null)" + actual="$(sha256sum "$LAUNCHER_APK" 2>/dev/null | awk '{print $1}')" + if [ -n "$expected" ] && [ "$expected" != "$actual" ]; then + log "ERROR: APK snapshot hash mismatch (expected $expected, got $actual) - refusing to install" + return 1 + fi + log "REINSTALL: $LAUNCHER_PACKAGE missing - installing from snapshot" + # -g grants all runtime permissions so the launcher starts clean + # without blocking on a permission dialog that itself may need the + # launcher to be usable. + if pm install -r -g "$LAUNCHER_APK" >/dev/null 2>&1; then + log "REINSTALL: $LAUNCHER_PACKAGE installed successfully" + return 0 + fi + # Fallback: `pm install` without -g on older Androids + if pm install -r "$LAUNCHER_APK" >/dev/null 2>&1; then + log "REINSTALL: $LAUNCHER_PACKAGE installed (without -g)" + return 0 + fi + log "ERROR: pm install failed" + return 1 +} + +ensure_home_pinned() { + local desired actual + desired="$(cat "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null)" + if [ -z "$desired" ]; then + return 0 # not armed yet; deploy.sh --snapshot-launcher writes this + fi + actual="$(current_home_component)" + if [ "$actual" = "$desired" ]; then + return 0 + fi + log "HOME: default is '$actual' not '$desired' - restoring" + cmd package set-home-activity "$desired" >/dev/null 2>&1 || \ + log "ERROR: set-home-activity failed for $desired" +} + +disable_competitors() { + # Disable every competitor that is still enabled. Remember what we + # disabled so `launcher-stop` can re-enable. + echo "$LAUNCHER_COMPETITORS" | while read -r pkg; do + [ -z "$pkg" ] && continue + [ "${pkg#\#}" != "$pkg" ] && continue # skip comments + if pkg_installed "$pkg" && pkg_enabled "$pkg"; then + if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then + log "Disabled competing launcher: $pkg" + grep -qxE "$pkg" "$DISABLED_COMPETITORS_FILE" 2>/dev/null \ + || echo "$pkg" >> "$DISABLED_COMPETITORS_FILE" + fi + fi + done +} + +# ---- Main loop ---- + +cleanup() { + log "launcher_enforcer shutting down" + rm -f "$PIDFILE" + exit 0 +} + +trap cleanup INT TERM + +main() { + acquire_lock + log "launcher_enforcer started (PID=$$)" + + # Initial arm-up + if ! pkg_installed "$LAUNCHER_PACKAGE"; then + reinstall_launcher || true + fi + ensure_home_pinned + disable_competitors + + while true; do + if ! pkg_installed "$LAUNCHER_PACKAGE"; then + reinstall_launcher || true + fi + ensure_home_pinned + disable_competitors + rotate_log + sleep "$LAUNCHER_CHECK_INTERVAL" + done +} + +main "$@" diff --git a/phone_focus_mode/magisk_service.sh b/phone_focus_mode/magisk_service.sh index 602145c..005169b 100755 --- a/phone_focus_mode/magisk_service.sh +++ b/phone_focus_mode/magisk_service.sh @@ -6,7 +6,7 @@ # Magisk executes everything in service.d on boot with root. # ============================================================ -# Wait for system to be fully booted before starting daemon +# Wait for system to be fully booted before starting daemons sleep 120 SCRIPT_DIR="/data/local/tmp/focus_mode" @@ -14,6 +14,23 @@ SCRIPT_DIR="/data/local/tmp/focus_mode" # Ensure scripts are executable chmod +x "$SCRIPT_DIR/focus_daemon.sh" chmod +x "$SCRIPT_DIR/focus_ctl.sh" +chmod +x "$SCRIPT_DIR/hosts_enforcer.sh" +chmod +x "$SCRIPT_DIR/dns_enforcer.sh" +chmod +x "$SCRIPT_DIR/launcher_enforcer.sh" + +# Start hosts enforcer FIRST - it must bind-mount the hosts file before +# the user has a chance to exploit it. This runs even outside focus mode +# because hosts hardening should always be active. +setsid sh "$SCRIPT_DIR/hosts_enforcer.sh" /dev/null 2>&1 & + +# Start DNS enforcer - forces Private DNS off and blocks DoH/DoT endpoints +# so the hosts file actually gets consulted by apps that would otherwise +# bypass it (e.g. Chrome's built-in secure DNS). Always on. +setsid sh "$SCRIPT_DIR/dns_enforcer.sh" /dev/null 2>&1 & + +# Start launcher enforcer - keeps Minimalist Phone installed and pinned as +# the default HOME. Always on (not location-gated). +setsid sh "$SCRIPT_DIR/launcher_enforcer.sh" /dev/null 2>&1 & # Start focus daemon in a new session (detached from any controlling terminal) setsid sh "$SCRIPT_DIR/focus_daemon.sh" /dev/null 2>&1 &