mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 13:23:15 +02:00
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:
parent
2efb81a497
commit
135ef0c62d
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||
|
||||
108
linux_configuration/hosts/generate_hosts_file.sh
Executable file
108
linux_configuration/hosts/generate_hosts_file.sh
Executable 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
|
||||
@ -96,20 +96,60 @@ 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 |
|
||||
| `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 <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
|
||||
|
||||
After editing `config.sh` (e.g. changing whitelist):
|
||||
|
||||
@ -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 <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
|
||||
@ -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
|
||||
|
||||
@ -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,28 +110,50 @@ 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..."
|
||||
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
|
||||
echo " config_secrets.sh already exists on phone - skipping (preserving real coords)"
|
||||
@ -137,24 +167,87 @@ do_deploy() {
|
||||
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
|
||||
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 >/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 >/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
|
||||
|
||||
# ---- 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 >/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
|
||||
|
||||
199
phone_focus_mode/dns_enforcer.sh
Executable file
199
phone_focus_mode/dns_enforcer.sh
Executable 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 "$@"
|
||||
@ -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 <command>
|
||||
# Or from PC via: adb shell 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 --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"
|
||||
@ -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:-<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
|
||||
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
|
||||
|
||||
@ -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
|
||||
fi
|
||||
local first_entry=0
|
||||
if [ "$CURRENT_MODE" != "focus" ]; then
|
||||
first_entry=1
|
||||
log "ENABLING focus mode - restricting non-whitelisted apps"
|
||||
|
||||
: > "$DISABLED_APPS_FILE"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
|
||||
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
|
||||
|
||||
56
phone_focus_mode/focus_status_app/AndroidManifest.xml
Normal file
56
phone_focus_mode/focus_status_app/AndroidManifest.xml
Normal 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>
|
||||
98
phone_focus_mode/focus_status_app/build.sh
Executable file
98
phone_focus_mode/focus_status_app/build.sh
Executable 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"
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
219
phone_focus_mode/hosts_enforcer.sh
Executable file
219
phone_focus_mode/hosts_enforcer.sh
Executable 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 "$@"
|
||||
179
phone_focus_mode/launcher_enforcer.sh
Executable file
179
phone_focus_mode/launcher_enforcer.sh
Executable 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 "$@"
|
||||
@ -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 >/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)
|
||||
setsid sh "$SCRIPT_DIR/focus_daemon.sh" </dev/null >/dev/null 2>&1 &
|
||||
|
||||
Loading…
Reference in New Issue
Block a user