phone_focus_mode: add persistent home-mode status notification

- New companion Android app (com.kuhy.focusstatus) under
  phone_focus_mode/focus_status_app/ with a pure-Java, Gradle-less
  command-line build pipeline (build.sh). Shows an ongoing
  notification titled 'Focus: HOME / AWAY / DAEMON DOWN' with
  distance, GPS, disabled-app count, last check, daemon checkmarks,
  and a 'Re-check now' action button.
- focus_daemon.sh: write_status_snapshot() + sleep_with_recheck()
  for JSON status + early-wake on trigger file. init() chmods
  STATE_DIR 777 so the app can drop the trigger file.
- config.sh: new STATUS_FILE / RECHECK_TRIGGER; WHITELIST expanded
  with com.kuhy.focusstatus and 11 more user-requested apps
  (podcini X, mpv, bible/openbible, pkp/portalpasazera, orange,
  runnerup, splitbills/splitwise, xiaomi smarthome).
- focus_ctl.sh: new 'recheck' + 'notif-status' subcommands.
- deploy.sh: new step [7/7] builds APK, installs, grants
  POST_NOTIFICATIONS, pre-approves Magisk SU policy, launches
  foreground service.
- .gitignore: exclude focus_status_app/build symlink + debug.keystore.

End-to-end verified on device: notification live with real values;
Re-check button triggers a daemon location check within ~1s.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-04-20 15:33:32 +02:00
parent 2efb81a497
commit 135ef0c62d
19 changed files with 2121 additions and 63 deletions

2
.gitignore vendored
View File

@ -423,6 +423,8 @@ pomodoro_app/build
horatio/horatio_app/build horatio/horatio_app/build
sonic_pi/build sonic_pi/build
CPP/mini_browser/build CPP/mini_browser/build
phone_focus_mode/focus_status_app/build
phone_focus_mode/focus_status_app/debug.keystore
pomodoro_app/.dart_tool pomodoro_app/.dart_tool
horatio/horatio_app/.dart_tool horatio/horatio_app/.dart_tool
horatio/horatio_core/.dart_tool horatio/horatio_core/.dart_tool

View File

@ -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 <output_path>
# 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 <output_path>|-" >&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

View File

@ -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): From a root terminal app (e.g. Termux + tsu):
```sh ```sh
su -c 'sh /data/local/tmp/focus_mode/focus_ctl.sh status' su --mount-master -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 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 layout
| File | Purpose | | File | Purpose |
| ------------------- | --------------------------------------------- | | ------------------- | ------------------------------------------------------ |
| `config.sh` | Coordinates, radius, whitelist, constants | | `config.sh` | Coordinates, radius, whitelist, constants |
| `focus_daemon.sh` | Main daemon — runs on device, loops every 60s | | `focus_daemon.sh` | Main daemon — runs on device, loops every 60s |
| `focus_ctl.sh` | Control utility — runs on device | | `focus_ctl.sh` | Control utility — runs on device |
| `magisk_service.sh` | Magisk boot hook → auto-starts daemon | | `hosts_enforcer.sh` | Bind-mounts `hosts.canonical` over `/system/etc/hosts` |
| `deploy.sh` | PC-side ADB deployment and control script | | `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 <ip> --hosts-status
./deploy.sh <ip> --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 ## Updating

View File

@ -32,6 +32,99 @@ export LOG_MAX_LINES=500
STATE_DIR="/data/local/tmp/focus_mode" STATE_DIR="/data/local/tmp/focus_mode"
export DISABLED_APPS_FILE="$STATE_DIR/disabled_by_focus.txt" export DISABLED_APPS_FILE="$STATE_DIR/disabled_by_focus.txt"
export MODE_FILE="$STATE_DIR/current_mode.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 <pkg>`.
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 # WHITELISTED APPS
@ -40,6 +133,18 @@ export MODE_FILE="$STATE_DIR/current_mode.txt"
# ============================================================ # ============================================================
export WHITELIST=" 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 --- # --- User-requested productive apps ---
com.stronglifts.app com.stronglifts.app
com.ichi2.anki com.ichi2.anki
@ -83,6 +188,31 @@ eu.kanade.tachiyomi.sy
# --- Development --- # --- Development ---
com.github.android 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.microsoft.emmx
com.kiwibrowser.browser com.kiwibrowser.browser
com.duckduckgo.mobile.android 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 --- # --- System / essential packages that must NEVER be disabled ---
@ -123,7 +272,6 @@ com.android.messaging
com.android.providers com.android.providers
com.android.inputmethod com.android.inputmethod
com.android.shell com.android.shell
com.android.packageinstaller
com.android.permissioncontroller com.android.permissioncontroller
com.android.bluetooth com.android.bluetooth
com.android.nfc com.android.nfc
@ -148,7 +296,6 @@ com.google.android.webview
com.google.android.trichromelibrary com.google.android.trichromelibrary
com.google.android.inputmethod.latin com.google.android.inputmethod.latin
com.google.android.setupwizard com.google.android.setupwizard
com.google.android.packageinstaller
com.google.android.permissioncontroller com.google.android.permissioncontroller
com.google.android.deskclock com.google.android.deskclock
com.google.android.dialer com.google.android.dialer

View File

@ -34,6 +34,11 @@ usage() {
echo " --list List all third-party apps and whitelist status" echo " --list List all third-party apps and whitelist status"
echo " --pull-log Download log file locally" echo " --pull-log Download log file locally"
echo " --find-pkg Show installed packages matching a filter (e.g. --find-pkg pomodoro)" 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 ""
echo "Examples:" echo "Examples:"
echo " $0 192.168.1.42" echo " $0 192.168.1.42"
@ -88,9 +93,12 @@ connect_adb() {
echo "Connected." 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_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 check_coords
echo "" echo ""
echo "[1/6] Connecting to phone..." echo "[1/7] Connecting to phone..."
connect_adb connect_adb
echo "[2/6] Verifying root access..." echo "[2/7] Verifying root access..."
if ! adb_root "id" | grep -q "uid=0"; then if ! adb_root "id" | grep -q "uid=0"; then
echo "ERROR: Could not get root shell. Is Magisk installed?" echo "ERROR: Could not get root shell. Is Magisk installed?"
exit 1 exit 1
fi fi
echo " Root confirmed." 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 # 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 -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" adb_root "chmod 777 /data/local/tmp/focus_stage"
echo "[4/6] Uploading scripts..." 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/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_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/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" 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 # 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 if adb_root "test -f $REMOTE_DIR/config_secrets.sh" 2>/dev/null; then
@ -134,27 +164,90 @@ do_deploy() {
fi fi
# Move staged files into place with root # 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/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_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/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/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" adb_root "rm -rf /data/local/tmp/focus_stage"
echo "[5/6] Setting permissions..." echo "[5/7] Setting permissions..."
adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh" || true 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 "chmod 755 /data/adb/service.d/99-focus-mode.sh"
adb_root "touch $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log" 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 daemon can write regardless of SELinux context drift # 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" || true 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..." echo "[6/7] Starting daemons..."
# Stop existing daemon, then start fresh # 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/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 sleep 1
adb_root "rm -f $REMOTE_DIR/daemon.pid" adb_root "rm -f $REMOTE_DIR/daemon.pid $REMOTE_DIR/hosts_enforcer.pid $REMOTE_DIR/dns_enforcer.pid $REMOTE_DIR/launcher_enforcer.pid"
adb -s "$PHONE_IP:5555" shell su -c 'sh /data/local/tmp/focus_mode/focus_daemon.sh </dev/null >/dev/null 2>/dev/null &' # 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 >/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 >/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 >/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 >/dev/null 2>/dev/null &'
sleep 4 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 ""
echo "=== Deploy complete! ===" echo "=== Deploy complete! ==="
echo "" echo ""
@ -198,6 +291,25 @@ do_find_pkg() {
adb -s "$PHONE_IP:5555" shell pm list packages | grep -i "$filter" | sed 's/^package:/ /' 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 >/dev/null 2>/dev/null &'
sleep 3
adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-status"
}
# ============================================================ # ============================================================
# Entry point # Entry point
# ============================================================ # ============================================================
@ -216,5 +328,10 @@ case "$ACTION" in
--list) do_control "list-apps" ;; --list) do_control "list-apps" ;;
--pull-log) do_pull_log ;; --pull-log) do_pull_log ;;
--find-pkg) do_find_pkg "$@" ;; --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 ;; *) echo "Unknown action: $ACTION"; usage ;;
esac esac

199
phone_focus_mode/dns_enforcer.sh Executable file
View File

@ -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 "$@"

View File

@ -2,8 +2,11 @@
# shellcheck shell=ash # shellcheck shell=ash
# ============================================================ # ============================================================
# Focus Mode Control Utility # Focus Mode Control Utility
# Run on the phone via: su -c /data/local/tmp/focus_mode/focus_ctl.sh <command> # Run on the phone via: su --mount-master -c /data/local/tmp/focus_mode/focus_ctl.sh <command>
# Or from PC via: adb shell su -c '/data/local/tmp/focus_mode/focus_ctl.sh <command>' # Or from PC via: adb shell su --mount-master -c '/data/local/tmp/focus_mode/focus_ctl.sh <command>'
# --mount-master is required so this script (and any daemon it spawns) joins
# the global mount namespace; otherwise the hosts bind mount is invisible and
# /data/adb/focus_mode/* checks fail due to per-session SELinux isolation.
# ============================================================ # ============================================================
SCRIPT_DIR="/data/local/tmp/focus_mode" SCRIPT_DIR="/data/local/tmp/focus_mode"
@ -31,6 +34,21 @@ usage() {
echo " list-apps - List all non-whitelisted third-party apps" echo " list-apps - List all non-whitelisted third-party apps"
echo " whitelist - List currently whitelisted packages" echo " whitelist - List currently whitelisted packages"
echo " restart - Restart the daemon" 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 "" echo ""
} }
@ -161,6 +179,33 @@ cmd_enable() {
echo "Done: disabled $count apps" 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() { cmd_disable() {
echo "Forcing focus mode OFF..." echo "Forcing focus mode OFF..."
if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then
@ -227,6 +272,335 @@ cmd_whitelist() {
done done
} }
HOSTS_PIDFILE="$STATE_DIR/hosts_enforcer.pid"
hosts_enforcer_pid() {
if [ -f "$HOSTS_PIDFILE" ]; then
local pid
pid="$(cat "$HOSTS_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_hosts_status() {
local pid
pid="$(hosts_enforcer_pid)"
echo "=== Hosts Enforcer Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Canonical: $HOSTS_CANONICAL"
echo "Target: $HOSTS_TARGET"
if grep -qE "[[:space:]]${HOSTS_TARGET}[[:space:]]" /proc/self/mounts 2>/dev/null; then
# A mount exists on the target path, but on Android the OEM sometimes
# already mounts its own hosts file here. Trust the sha check below.
echo "Mount: present (integrity check below tells us if ours)"
else
echo "Mount: NOT mounted (unprotected)"
fi
if [ -f "$HOSTS_CANONICAL" ]; then
local expected actual
expected="$(cat "$HOSTS_SHA_FILE" 2>/dev/null)"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$HOSTS_TARGET" 2>/dev/null | awk '{print $1}')"
else
actual="$(md5sum "$HOSTS_TARGET" 2>/dev/null | awk '{print $1}')"
fi
echo "Expected: ${expected:-<none>}"
echo "Actual: ${actual:-<unreadable>}"
if [ -n "$expected" ] && [ "$expected" = "$actual" ]; then
echo "Integrity: OK"
else
echo "Integrity: MISMATCH"
fi
else
echo "Canonical hosts file missing - run deploy.sh"
fi
}
cmd_hosts_start() {
local pid
pid="$(hosts_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Hosts enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/hosts_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(hosts_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Hosts enforcer started (PID $pid)"
else
echo "ERROR: hosts enforcer failed to start. Check log: $HOSTS_LOG"
fi
}
cmd_hosts_stop() {
local pid
pid="$(hosts_enforcer_pid)"
if [ -z "$pid" ]; then
echo "Hosts enforcer not running"
rm -f "$HOSTS_PIDFILE"
return
fi
kill -TERM "$pid"
echo "Hosts enforcer stopped (sent SIGTERM to PID $pid)"
}
cmd_hosts_log() {
local lines="${1:-50}"
if [ -f "$HOSTS_LOG" ]; then
tail -n "$lines" "$HOSTS_LOG"
else
echo "Hosts enforcer log not found: $HOSTS_LOG"
fi
}
# ---- DNS enforcer ----
# Hosts file only works for the system resolver. Apps using DoH/DoT bypass
# /etc/hosts entirely. The DNS enforcer forces Private DNS off and blocks
# well-known DoH/DoT endpoints so /etc/hosts is actually consulted.
DNS_PIDFILE="$STATE_DIR/dns_enforcer.pid"
dns_enforcer_pid() {
if [ -f "$DNS_PIDFILE" ]; then
local pid
pid="$(cat "$DNS_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_dns_status() {
local pid
pid="$(dns_enforcer_pid)"
echo "=== DNS Enforcer Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
local mode spec
mode="$(settings get global private_dns_mode 2>/dev/null)"
spec="$(settings get global private_dns_specifier 2>/dev/null)"
echo "private_dns_mode: ${mode:-<unset>}"
echo "private_dns_specifier: ${spec:-<unset>}"
if iptables -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
local v4rules
v4rules="$(iptables -S "$DNS_IPT_CHAIN" 2>/dev/null | wc -l)"
echo "iptables $DNS_IPT_CHAIN: $v4rules rules"
else
echo "iptables $DNS_IPT_CHAIN: MISSING"
fi
if ip6tables -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
local v6rules
v6rules="$(ip6tables -S "$DNS_IPT_CHAIN" 2>/dev/null | wc -l)"
echo "ip6tables $DNS_IPT_CHAIN: $v6rules rules"
else
echo "ip6tables $DNS_IPT_CHAIN: MISSING"
fi
}
cmd_dns_start() {
local pid
pid="$(dns_enforcer_pid)"
if [ -n "$pid" ]; then
echo "DNS enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/dns_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(dns_enforcer_pid)"
if [ -n "$pid" ]; then
echo "DNS enforcer started (PID $pid)"
else
echo "ERROR: DNS enforcer failed to start. Check log: $DNS_LOG"
fi
}
cmd_dns_stop() {
local pid
pid="$(dns_enforcer_pid)"
if [ -z "$pid" ]; then
echo "DNS enforcer not running"
rm -f "$DNS_PIDFILE"
else
kill -TERM "$pid"
echo "DNS enforcer stopped (sent SIGTERM to PID $pid)"
fi
# Explicit teardown of the iptables chain so maintenance work can
# use DoH. The enforcer itself leaves the chain intact on TERM to
# keep the block closed between periodic re-applies.
iptables -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null || true
iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || true
iptables -X "$DNS_IPT_CHAIN" 2>/dev/null || true
ip6tables -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null || true
ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || true
ip6tables -X "$DNS_IPT_CHAIN" 2>/dev/null || true
echo "iptables chain $DNS_IPT_CHAIN removed"
}
cmd_dns_log() {
local lines="${1:-50}"
if [ -f "$DNS_LOG" ]; then
tail -n "$lines" "$DNS_LOG"
else
echo "DNS enforcer log not found: $DNS_LOG"
fi
}
# ---- Launcher enforcer ----
LAUNCHER_PIDFILE="$STATE_DIR/launcher_enforcer.pid"
DISABLED_COMPETITORS_FILE="$STATE_DIR/disabled_competitors.txt"
launcher_enforcer_pid() {
if [ -f "$LAUNCHER_PIDFILE" ]; then
local pid
pid="$(cat "$LAUNCHER_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_launcher_snapshot() {
# Find the APK path for the currently-installed launcher and copy it
# to LAUNCHER_APK. Also capture the current HOME activity component.
local apk_path
apk_path="$(pm path "$LAUNCHER_PACKAGE" 2>/dev/null | head -1 | sed 's/^package://')"
if [ -z "$apk_path" ] || [ ! -f "$apk_path" ]; then
echo "ERROR: $LAUNCHER_PACKAGE is not installed. Install it once via Aurora/Play Store, then rerun this command."
return 1
fi
mkdir -p "$(dirname "$LAUNCHER_APK")"
chattr -i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true
cp "$apk_path" "$LAUNCHER_APK" || return 1
chmod 644 "$LAUNCHER_APK"
sha256sum "$LAUNCHER_APK" | awk '{print $1}' > "$LAUNCHER_SHA_FILE"
chmod 644 "$LAUNCHER_SHA_FILE"
# Resolve the current HOME activity (or the launcher's default activity
# if it isn't yet the default).
local component
component="$(cmd package resolve-activity --brief \
-c android.intent.category.HOME \
-a android.intent.action.MAIN 2>/dev/null | awk 'NR==2{print}')"
if [ -z "$component" ] || [ "${component%%/*}" != "$LAUNCHER_PACKAGE" ]; then
# Fall back to the launcher's MAIN/LAUNCHER activity
component="$(cmd package resolve-activity --brief \
-c android.intent.category.LAUNCHER \
-a android.intent.action.MAIN "$LAUNCHER_PACKAGE" 2>/dev/null \
| awk 'NR==2{print}')"
fi
if [ -z "$component" ]; then
echo "ERROR: could not resolve HOME activity for $LAUNCHER_PACKAGE"
return 1
fi
echo "$component" > "$LAUNCHER_ACTIVITY_FILE"
chmod 644 "$LAUNCHER_ACTIVITY_FILE"
# Make snapshot immutable so even root-in-a-terminal can't overwrite
# it without first running `chattr -i`.
chattr +i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true
echo "Snapshot saved:"
echo " APK: $LAUNCHER_APK ($(wc -c < "$LAUNCHER_APK") bytes)"
echo " SHA256: $(cat "$LAUNCHER_SHA_FILE")"
echo " Activity: $component"
}
cmd_launcher_status() {
local pid
pid="$(launcher_enforcer_pid)"
echo "=== Launcher Enforcer Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Package: $LAUNCHER_PACKAGE"
if pm path "$LAUNCHER_PACKAGE" >/dev/null 2>&1; then
echo "Installed: YES ($(pm path "$LAUNCHER_PACKAGE" | head -1))"
else
echo "Installed: NO"
fi
local desired actual
desired="$(cat "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null)"
actual="$(cmd package resolve-activity --brief \
-c android.intent.category.HOME -a android.intent.action.MAIN \
2>/dev/null | awk 'NR==2{print}')"
echo "Expected: ${desired:-<not armed - run launcher-snapshot>}"
echo "Actual: ${actual:-<unresolved>}"
if [ -n "$desired" ] && [ "$desired" = "$actual" ]; then
echo "Default: OK (pinned)"
else
echo "Default: MISMATCH"
fi
echo "Snapshot: $LAUNCHER_APK"
if [ -f "$LAUNCHER_APK" ]; then
echo "Snapshot size: $(wc -c < "$LAUNCHER_APK") bytes"
fi
if [ -s "$DISABLED_COMPETITORS_FILE" ]; then
echo "Disabled competitors:"
sed 's/^/ - /' "$DISABLED_COMPETITORS_FILE"
fi
}
cmd_launcher_start() {
local pid
pid="$(launcher_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Launcher enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/launcher_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(launcher_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Launcher enforcer started (PID $pid)"
else
echo "ERROR: launcher enforcer failed to start. Check log: $LAUNCHER_LOG"
fi
}
cmd_launcher_stop() {
local pid
pid="$(launcher_enforcer_pid)"
if [ -z "$pid" ]; then
echo "Launcher enforcer not running"
rm -f "$LAUNCHER_PIDFILE"
else
kill -TERM "$pid"
echo "Launcher enforcer stopped (sent SIGTERM to PID $pid)"
fi
# Re-enable any competitors we disabled so the device is usable if the
# enforcer is intentionally stopped (e.g. during maintenance).
if [ -s "$DISABLED_COMPETITORS_FILE" ]; then
while read -r pkg; do
[ -z "$pkg" ] && continue
pm enable --user 0 "$pkg" >/dev/null 2>&1 && \
echo "Re-enabled competing launcher: $pkg"
done < "$DISABLED_COMPETITORS_FILE"
: > "$DISABLED_COMPETITORS_FILE"
fi
}
cmd_launcher_log() {
local lines="${1:-50}"
if [ -f "$LAUNCHER_LOG" ]; then
tail -n "$lines" "$LAUNCHER_LOG"
else
echo "Launcher enforcer log not found: $LAUNCHER_LOG"
fi
}
case "$1" in case "$1" in
start) cmd_start ;; start) cmd_start ;;
stop) cmd_stop ;; stop) cmd_stop ;;
@ -237,5 +611,20 @@ case "$1" in
list-apps) cmd_list_apps ;; list-apps) cmd_list_apps ;;
whitelist) cmd_whitelist ;; whitelist) cmd_whitelist ;;
restart) cmd_stop; sleep 2; cmd_start ;; 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 ;; *) usage ;;
esac esac

View File

@ -90,6 +90,10 @@ init() {
touch "$DISABLED_APPS_FILE" touch "$DISABLED_APPS_FILE"
# Ensure state files are writable (survives reboot / permission drift) # Ensure state files are writable (survives reboot / permission drift)
chmod 666 "$LOG_FILE" "$DISABLED_APPS_FILE" "$PIDFILE" 2>/dev/null 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 if [ "$HOME_LAT" = "0.000000" ] && [ "$HOME_LON" = "0.000000" ]; then
log "ERROR: Home coordinates not set! Edit config.sh first." log "ERROR: Home coordinates not set! Edit config.sh first."
@ -160,45 +164,67 @@ is_allowed() {
# ---- Focus Mode Control ---- # ---- Focus Mode Control ----
enable_focus_mode() { enable_focus_mode() {
if [ "$CURRENT_MODE" = "focus" ]; then local first_entry=0
reconcile_disabled_apps if [ "$CURRENT_MODE" != "focus" ]; then
return first_entry=1
log "ENABLING focus mode - restricting non-whitelisted apps"
: > "$DISABLED_APPS_FILE"
fi 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" 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 while IFS= read -r pkg; do
[ -z "$pkg" ] && continue [ -z "$pkg" ] && continue
is_allowed "$pkg" && continue is_allowed "$pkg" && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then 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 fi
done < "$tmp_pkgs" done < "$tmp_pkgs"
rm -f "$tmp_pkgs" rm -f "$tmp_pkgs"
# Also remove explicitly blocked system apps (e.g. browsers) # Uninstall-for-user-0 any blocked system apps (Play Store, browsers,
# Uses pm uninstall --user 0 so they vanish from Settings entirely # package installer UI, terminal apps). pm uninstall is idempotent:
local blocked_sys="$STATE_DIR/blocked_sys.txt" # re-running it on already-uninstalled-for-user-0 packages is a no-op.
local uninstalled_sys="$STATE_DIR/uninstalled_sys.txt" local uninstalled_sys="$STATE_DIR/uninstalled_sys.txt"
echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \ [ "$first_entry" -eq 1 ] && : > "$uninstalled_sys"
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' > "$blocked_sys" # List of packages installed for user 0 (one per line, "package:" prefix).
: > "$uninstalled_sys" 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 while IFS= read -r pkg; do
[ -z "$pkg" ] && continue [ -z "$pkg" ] && continue
# Try uninstall; even if already uninstalled, record it for re-install later if grep -qxF "$pkg" "$user0_pkgs" 2>/dev/null; then
pm uninstall -k --user 0 "$pkg" >/dev/null 2>&1 if pm uninstall -k --user 0 "$pkg" >/dev/null 2>&1; then
echo "$pkg" >> "$uninstalled_sys" grep -qxF "$pkg" "$uninstalled_sys" 2>/dev/null \
echo "$pkg" >> "$DISABLED_APPS_FILE" || 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" 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" CURRENT_MODE="focus"
echo "focus" > "$MODE_FILE" 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 reconcile_disabled_apps
} }
@ -230,6 +256,54 @@ disable_focus_mode() {
log "Focus mode disabled - re-enabled $count apps" 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 ---- # ---- Signal handlers ----
cleanup() { cleanup() {
log "Daemon shutting down - re-enabling all apps" log "Daemon shutting down - re-enabling all apps"
@ -268,16 +342,19 @@ main() {
fi fi
log "Location: $lat,$lon | Distance: ${distance}m | Threshold: ${threshold}m | Mode: $CURRENT_MODE" log "Location: $lat,$lon | Distance: ${distance}m | Threshold: ${threshold}m | Mode: $CURRENT_MODE"
write_status_snapshot "$CURRENT_MODE" "$lat" "$lon" "$distance" "$threshold"
else else
log "Location unavailable - defaulting to focus mode (restrictions ON)" log "Location unavailable - defaulting to focus mode (restrictions ON)"
enable_focus_mode enable_focus_mode
write_status_snapshot "$CURRENT_MODE" "" "" "null" "null"
fi 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 if [ "$CURRENT_MODE" = "focus" ]; then
sleep "$CHECK_INTERVAL_FOCUS" sleep_with_recheck "$CHECK_INTERVAL_FOCUS"
else else
sleep "$CHECK_INTERVAL_NORMAL" sleep_with_recheck "$CHECK_INTERVAL_NORMAL"
fi fi
rotate_log rotate_log

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.kuhy.focusstatus">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:label="Focus Status"
android:icon="@android:drawable/ic_menu_compass"
android:allowBackup="false"
android:hasFragileUserData="false"
android:usesCleartextTraffic="false">
<activity
android:name=".LaunchActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:excludeFromRecents="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".StatusService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Persistent status of focus/home-mode daemon on rooted device" />
</service>
<receiver
android:name=".RecheckReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.kuhy.focusstatus.RECHECK" />
</intent-filter>
</receiver>
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -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"

View File

@ -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));
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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 "$@"

View File

@ -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 "$@"

View File

@ -6,7 +6,7 @@
# Magisk executes everything in service.d on boot with root. # 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 sleep 120
SCRIPT_DIR="/data/local/tmp/focus_mode" SCRIPT_DIR="/data/local/tmp/focus_mode"
@ -14,6 +14,23 @@ SCRIPT_DIR="/data/local/tmp/focus_mode"
# Ensure scripts are executable # Ensure scripts are executable
chmod +x "$SCRIPT_DIR/focus_daemon.sh" chmod +x "$SCRIPT_DIR/focus_daemon.sh"
chmod +x "$SCRIPT_DIR/focus_ctl.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 >/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 >/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 >/dev/null 2>&1 &
# Start focus daemon in a new session (detached from any controlling terminal) # Start focus daemon in a new session (detached from any controlling terminal)
setsid sh "$SCRIPT_DIR/focus_daemon.sh" </dev/null >/dev/null 2>&1 & setsid sh "$SCRIPT_DIR/focus_daemon.sh" </dev/null >/dev/null 2>&1 &