diff --git a/.github/skills/phone-focus-mode/SKILL.md b/.github/skills/phone-focus-mode/SKILL.md new file mode 100644 index 0000000..cdda4e6 --- /dev/null +++ b/.github/skills/phone-focus-mode/SKILL.md @@ -0,0 +1,155 @@ +# Phone Focus Mode Skill + +## Overview + +Focus mode is a geofence-based attention tool for a Magisk-rooted Blackview BL9000 (MTK MT6891). +When the phone is at home it enters focus mode: distracting apps are disabled and domain blocking +is enforced. Outside home, the phone returns to normal. + +Scripts live in `phone_focus_mode/`. Deployment: `deploy.sh ` or +`ADB_SERIAL= deploy.sh`. + +--- + +## Critical: Things That Will Brick / Factory-Wipe the Phone + +### DO NOT use `pm uninstall -k --user 0` or `pm disable-user` on SYSTEM apps + +MediaTek ROMs (and many others) trigger Android recovery / factory wipe on next boot when their +package manager scan finds system packages missing or disabled. This happened multiple times. + +- `pm uninstall -k --user 0` → removes package from user-0 registry → survives reboot → wipe +- `pm disable-user --user 0` on system packages → package state persists → wipe + +**Safe approaches:** + +- `pm disable-user --user 0` is safe for 3rd-party (non-system) apps only +- For system apps (YouTube, Chrome, Play Store) use **UID firewall rules** or **DNS/hosts blocking** +- Never put system package names in `BLOCKED_SYSTEM_APPS` unless you have a tested recovery path + +`BLOCKED_SYSTEM_APPS` in `config.sh` should remain **empty string** (`""`). + +--- + +## Hosts File Blocking + +### The Problem: ROM's /system partition is truly read-only + +This device's system partition cannot be remounted rw — even with Magisk root, `mount -o remount,rw /system` +silently fails. `/system/etc/hosts` does not exist (no inode). + +### The Solution: Magisk Systemless Hosts module + +Magisk ships a "Systemless Hosts" built-in module. When enabled in the Magisk app, it magic-mounts +any file under `/data/adb/modules/hosts/system/etc/hosts` as `/system/etc/hosts` at boot. + +**Required one-time setup (user must do in Magisk app):** + +1. Open Magisk app → Modules tab +2. Enable "Systemless Hosts" module (toggle) +3. Reboot + +This must be done manually the first time (or after a factory reset). There is no way to enable it +programmatically via ADB without user interaction on some firmware versions — `magisk --sqlite` +approach exists but is unreliable across versions. + +After enabling the module, `hosts_enforcer.sh` automatically keeps it in sync by copying the +canonical hosts file to `/data/adb/modules/hosts/system/etc/hosts` every `HOSTS_CHECK_INTERVAL` +seconds. This survives reboots. + +### What hosts_enforcer.sh does + +1. Watches `$HOSTS_CANONICAL` (pushed by `deploy.sh` from `linux_configuration/hosts/`) +2. Copies it to `/data/adb/modules/hosts/system/etc/hosts` (Magisk module path) +3. Falls back to bind-mount / direct overwrite of `/system/etc/hosts` if it exists +4. Verifies integrity and re-syncs if tampered + +### Domains blocked + +Uses StevenBlack hosts with fakenews/gambling/porn/social extensions (~171k domains). +Custom YouTube/social entries in `DNS_BLOCK_HOSTS` config var. All apps including browsers +are blocked from resolving these domains — not just the YouTube app. + +--- + +## App Blocking Strategy + +### UID-based firewall (dns_enforcer.sh) + +For system apps that cannot be safely disabled via `pm`, UID-based iptables rules block +web access (ports 80/443 only — DNS port 53 is deliberately NOT blocked). + +**CRITICAL: Only block ports 80/443, NEVER all TCP/UDP for an UID.** +Blocking all TCP/UDP for a UID also kills DNS for that process and (on some Android versions) +breaks the system DNS cache for other apps too, making the entire phone unable to load any website. + +### Focus-mode-only vs always-blocked + +- `DNS_BLOCK_PACKAGES_ALWAYS`: YouTube, YouTube Music, Chrome — always blocked +- `DNS_BLOCK_PACKAGES_FOCUS_ONLY`: Play Store — blocked only during focus mode + +`dns_enforcer.sh` reads `$MODE_FILE` (current_mode.txt) every `DNS_CHECK_INTERVAL` seconds +and adds/removes focus-only UIDs accordingly. + +### Play Store + Aurora Store + +Play Store (com.android.vending) is blocked during focus mode via UID rules. +Outside focus mode it's accessible normally. + +**Aurora Store** (`com.aurora.store`) is an open-source Play Store client that works without a +Google account. Install it via: `deploy.sh --install-aurora`. It lets you install any +app from the Play Store catalog without needing Play Store's network access during focus mode +(though Play Store is accessible outside focus mode anyway). + +--- + +## Boot Autostart + +`FOCUS_BOOT_AUTOSTART=1` in `config.sh` installs `/data/adb/service.d/99-focus-mode.sh`. + +`magisk_service.sh` (the service.d entry point): + +- Polls `sys.boot_completed` (max 180 seconds) +- Waits `FOCUS_BOOT_DELAY_SECONDS` (max 10 seconds) +- Checks for emergency disable marker: `$STATE_DIR/disable_boot_autostart` +- Starts hosts_enforcer, dns_enforcer, focus_daemon in order + +**Known issue**: `dirname "$0"` is wrong from service.d context (points to service.d, not the scripts). +`magisk_service.sh` exports `FOCUS_MODE_SCRIPT_DIR=/data/local/tmp/focus_mode` before sourcing +`config.sh` to work around this. All scripts use `${FOCUS_MODE_SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}`. + +--- + +## MTK-Specific Notes + +- `pm disable-user` on system packages persists across reboots and can trigger factory wipe +- `mount -o remount,rw /system` silently fails (hardware read-only), use Magisk module approach +- ip6tables `--uid-owner` may fail with permission error from non-service.d su contexts; use `|| true` +- Boot sequence is slow on MTK; 180s wait for `sys.boot_completed` is appropriate + +--- + +## ADB Root + +Use `su --mount-master -c 'sh -s'` so that bind mounts propagate to the global namespace: + +```bash +printf '%s\n' 'your commands here' | adb shell su --mount-master -c 'sh -s' +``` + +Without `--mount-master`, bind mounts are invisible to other processes (they only exist in the +su session's mount namespace). + +--- + +## Testing + +Unit tests: `phone_focus_mode/lib/tests/` + +```bash +bash phone_focus_mode/lib/tests/test_magisk_service.sh # 11 tests +bash phone_focus_mode/lib/tests/test_dns_enforcer.sh # 5 tests +bash phone_focus_mode/lib/tests/test_hosts_enforcer.sh # (create if needed) +``` + +Pre-commit: `pre-commit run --files phone_focus_mode/...` diff --git a/docs/superpowers/plans/2026-05-01-phone-focus-recovery.md b/docs/superpowers/plans/2026-05-01-phone-focus-recovery.md new file mode 100644 index 0000000..088d177 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-phone-focus-recovery.md @@ -0,0 +1,2053 @@ +# Phone Focus Recovery Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a one-command phone management workflow (`scripts/run_all/run_phone.sh`) that detects formatting, takes incremental backups, monitors security drift, and fully restores a freshly formatted rooted Android phone to its hardened state. + +**Architecture:** A thin visible wrapper (`scripts/run_all/run_phone.sh`) forwards to the project-local orchestrator (`phone_focus_mode/run_phone.sh`), which delegates to focused library modules in `phone_focus_mode/lib/`. The existing `deploy.sh` remains the deployment primitive; the new code wraps it rather than replacing it. + +**Tech Stack:** Bash 5 (`set -euo pipefail`), ADB, Magisk/root, ShellCheck, pre-commit. + +--- + +## Chunk 1: Foundation — `lib/adb_common.sh` + +### Files + +- Create: `phone_focus_mode/lib/adb_common.sh` +- Create: `phone_focus_mode/lib/tests/test_adb_common.sh` + +### Background + +`adb_common.sh` is the lowest layer. Every other module sources it. It handles: + +- locating exactly one target device (USB or wireless) +- verifying root access +- providing a single `adb_root_shell` function used everywhere instead of duplicating `su --mount-master -c` calls +- saving and loading the trusted-device identity record + +The trusted-device record lives at: + +``` +${XDG_STATE_HOME:-$HOME/.local/state}/phone_focus_mode/trusted_device.sh +``` + +It is a shell-native sourceable file (no JSON, no jq). + +State and lock directories: + +``` +${XDG_STATE_HOME:-$HOME/.local/state}/phone_focus_mode/locks/ +${XDG_STATE_HOME:-$HOME/.local/state}/phone_focus_mode/last_run/ +``` + +--- + +- [ ] **Step 1.1: Create the library file skeleton** + +Create `phone_focus_mode/lib/adb_common.sh`: + +```bash +#!/usr/bin/env bash +# lib/adb_common.sh — ADB device selection, identity, and root helpers. +# Source this file; do not execute directly. +set -euo pipefail + +# --------------------------------------------------------------------------- +# State paths +# --------------------------------------------------------------------------- +_PHONE_STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/phone_focus_mode" +TRUSTED_DEVICE_FILE="${_PHONE_STATE_DIR}/trusted_device.sh" +LOCK_DIR="${_PHONE_STATE_DIR}/locks" +LAST_RUN_DIR="${_PHONE_STATE_DIR}/last_run" + +# --------------------------------------------------------------------------- +# Logging helpers +# --------------------------------------------------------------------------- +_info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*" >&2; } +_warn() { printf '\033[0;33m[WARN]\033[0m %s\n' "$*" >&2; } +_error() { printf '\033[0;31m[ERROR]\033[0m %s\n' "$*" >&2; } +_fatal() { printf '\033[0;31m[FATAL]\033[0m %s\n' "$*" >&2; exit 1; } + +_box() { + local title="$1"; shift + printf '\n\033[1;33m╔══════════════════════════════════════════╗\033[0m\n' >&2 + printf '\033[1;33m║ %-42s║\033[0m\n' "${title}" >&2 + printf '\033[1;33m╚══════════════════════════════════════════╝\033[0m\n' >&2 + for line in "$@"; do + printf ' %s\n' "${line}" >&2 + done +} + +# --------------------------------------------------------------------------- +# adb_list_serials — print one serial per line for all connected devices +# --------------------------------------------------------------------------- +adb_list_serials() { + adb devices 2>/dev/null \ + | awk 'NR>1 && $2~/^(device|offline|unauthorized)$/ { print $1 }' +} + +# --------------------------------------------------------------------------- +# adb_select_device [serial] +# Sets and exports ADB_SERIAL to the resolved device serial. +# Fails when no device, multiple devices and no serial supplied, or when the +# supplied serial does not appear in the connected device list. +# --------------------------------------------------------------------------- +adb_select_device() { + local requested="${1:-${ADB_SERIAL:-}}" + local -a serials + mapfile -t serials < <(adb_list_serials) + + if [[ ${#serials[@]} -eq 0 ]]; then + _fatal "No ADB device found. Connect via USB or pair wireless ADB first." + fi + + if [[ -n "${requested}" ]]; then + local found=0 + for s in "${serials[@]}"; do + [[ "${s}" == "${requested}" ]] && found=1 && break + done + [[ "${found}" -eq 1 ]] \ + || _fatal "Requested device '${requested}' not found. Connected: ${serials[*]}" + export ADB_SERIAL="${requested}" + return 0 + fi + + if [[ ${#serials[@]} -eq 1 ]]; then + export ADB_SERIAL="${serials[0]}" + _info "Auto-selected device: ${ADB_SERIAL}" + return 0 + fi + + # Multiple devices — try saved trusted device + if [[ -f "${TRUSTED_DEVICE_FILE}" ]]; then + local saved_serial="" + # shellcheck source=/dev/null + source "${TRUSTED_DEVICE_FILE}" + saved_serial="${TRUSTED_SERIAL:-}" + if [[ -n "${saved_serial}" ]]; then + for s in "${serials[@]}"; do + if [[ "${s}" == "${saved_serial}" ]]; then + export ADB_SERIAL="${saved_serial}" + _info "Selected saved trusted device: ${ADB_SERIAL}" + return 0 + fi + done + fi + fi + + _fatal "Multiple ADB devices found (${serials[*]}) and no target specified. Use --serial or set ADB_SERIAL." +} + +# --------------------------------------------------------------------------- +# adb_cmd — run an adb command targeting the selected device +# --------------------------------------------------------------------------- +adb_cmd() { + adb -s "${ADB_SERIAL:?adb_select_device must be called first}" "$@" +} + +# --------------------------------------------------------------------------- +# adb_verify_root — confirm su --mount-master -c works on the device +# --------------------------------------------------------------------------- +adb_verify_root() { + local result + result=$(adb_cmd shell su --mount-master -c "echo ok" 2>/dev/null || true) + [[ "${result}" == "ok" ]] \ + || _fatal "Root check failed on ${ADB_SERIAL}. Ensure Magisk is installed and ADB root is authorized." + _info "Root verified on ${ADB_SERIAL}" +} + +# --------------------------------------------------------------------------- +# adb_root_shell CMD — run CMD on device under su --mount-master +# --------------------------------------------------------------------------- +adb_root_shell() { + adb_cmd shell su --mount-master -c "$*" +} + +# --------------------------------------------------------------------------- +# _sanitize_device_string — strip chars that would be dangerous in a sourced .sh file +# --------------------------------------------------------------------------- +_sanitize_device_string() { + # Allow only printable ASCII: alphanumeric, space, dash, dot, colon, slash, underscore. + # Strips quotes, $, backtick, semicolon, newline, and anything else. + printf '%s' "$1" | tr -cd 'A-Za-z0-9 ._:/\-' +} + +# --------------------------------------------------------------------------- +# adb_collect_identity — populate DEVICE_MODEL, DEVICE_FINGERPRINT, DEVICE_SERIAL +# --------------------------------------------------------------------------- +adb_collect_identity() { + local raw_model raw_fp + raw_model=$(adb_cmd shell getprop ro.product.model 2>/dev/null | tr -d '\r') + raw_fp=$(adb_cmd shell getprop ro.build.fingerprint 2>/dev/null | tr -d '\r') + DEVICE_MODEL=$(_sanitize_device_string "${raw_model}") + DEVICE_FINGERPRINT=$(_sanitize_device_string "${raw_fp}") + DEVICE_SERIAL=$(_sanitize_device_string "${ADB_SERIAL}") + export DEVICE_MODEL DEVICE_FINGERPRINT DEVICE_SERIAL +} + +# --------------------------------------------------------------------------- +# adb_save_trusted_device — write trusted_device.sh +# --------------------------------------------------------------------------- +adb_save_trusted_device() { + adb_collect_identity + mkdir -p "${_PHONE_STATE_DIR}" + # Values have been sanitized by _sanitize_device_string. + # printf %q provides an extra layer of quoting safety. + { + printf '# Auto-generated trusted device record — do not edit manually.\n' + printf 'TRUSTED_SERIAL=%q\n' "${DEVICE_SERIAL}" + printf 'TRUSTED_MODEL=%q\n' "${DEVICE_MODEL}" + printf 'TRUSTED_FINGERPRINT=%q\n' "${DEVICE_FINGERPRINT}" + } > "${TRUSTED_DEVICE_FILE}" + chmod 600 "${TRUSTED_DEVICE_FILE}" + _info "Saved trusted device record: model='${DEVICE_MODEL}' serial='${DEVICE_SERIAL}'" +} + +# --------------------------------------------------------------------------- +# adb_verify_trusted_identity — compare current device to saved record +# Exits non-zero with an error message if identity does not match. +# --------------------------------------------------------------------------- +adb_verify_trusted_identity() { + if [[ ! -f "${TRUSTED_DEVICE_FILE}" ]]; then + _warn "No trusted device record found. Run 'fresh-phone' to enroll this device." + return 0 # First run — allow with a warning, not a hard failure + fi + local saved_serial="" saved_fp="" + # shellcheck source=/dev/null + source "${TRUSTED_DEVICE_FILE}" + saved_serial="${TRUSTED_SERIAL:-}" + saved_fp="${TRUSTED_FINGERPRINT:-}" + + adb_collect_identity + + if [[ -n "${saved_serial}" && "${DEVICE_SERIAL}" != "${saved_serial}" ]]; then + _fatal "Device identity mismatch: expected serial '${saved_serial}', got '${DEVICE_SERIAL}'. Refusing to proceed automatically." + fi + if [[ -n "${saved_fp}" && "${DEVICE_FINGERPRINT}" != "${saved_fp}" ]]; then + _warn "Build fingerprint changed: expected '${saved_fp}', got '${DEVICE_FINGERPRINT}'. Verify the device is the correct one before continuing." + fi + _info "Device identity verified: ${DEVICE_SERIAL} (${DEVICE_MODEL})" +} + +# --------------------------------------------------------------------------- +# adb_acquire_lock — single-instance lock to prevent overlapping runs +# --------------------------------------------------------------------------- +LOCK_FILE="" +adb_acquire_lock() { + mkdir -p "${LOCK_DIR}" + LOCK_FILE="${LOCK_DIR}/run_phone.lock" + if [[ -e "${LOCK_FILE}" ]]; then + local old_pid + old_pid=$(cat "${LOCK_FILE}" 2>/dev/null || echo "") + if [[ -n "${old_pid}" ]] && kill -0 "${old_pid}" 2>/dev/null; then + _fatal "Another run_phone.sh instance is already running (PID ${old_pid}). Aborting." + else + _warn "Stale lock file found (PID ${old_pid} no longer running). Removing." + rm -f "${LOCK_FILE}" + fi + fi + echo $$ > "${LOCK_FILE}" + trap '_adb_release_lock' EXIT INT TERM + _info "Acquired run lock (PID $$)" +} + +_adb_release_lock() { + [[ -n "${LOCK_FILE}" && -f "${LOCK_FILE}" ]] && rm -f "${LOCK_FILE}" +} + +# --------------------------------------------------------------------------- +# adb_check_cooldown COOLDOWN_SECONDS MARKER_NAME +# Exits 0 (ok to proceed) or 1 (too soon, caller should skip). +# --------------------------------------------------------------------------- +adb_check_cooldown() { + local cooldown_secs="${1:-300}" + local marker_name="${2:-default}" + local marker="${LAST_RUN_DIR}/${marker_name}" + mkdir -p "${LAST_RUN_DIR}" + if [[ -f "${marker}" ]]; then + local last_run now elapsed + last_run=$(cat "${marker}") + now=$(date +%s) + elapsed=$(( now - last_run )) + if (( elapsed < cooldown_secs )); then + _info "Cooldown active: last run ${elapsed}s ago, cooldown is ${cooldown_secs}s. Skipping." + return 1 + fi + fi + return 0 +} + +adb_mark_last_run() { + local marker_name="${1:-default}" + mkdir -p "${LAST_RUN_DIR}" + date +%s > "${LAST_RUN_DIR}/${marker_name}" +} +``` + +- [ ] **Step 1.2: Create the test file** + +Create `phone_focus_mode/lib/tests/test_adb_common.sh`: + +```bash +#!/usr/bin/env bash +# Unit tests for adb_common.sh helper functions (no real device needed). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/../adb_common.sh" + +PASS=0 +FAIL=0 +_t_pass() { PASS=$(( PASS + 1 )); printf ' OK: %s\n' "$1"; } +_t_fail() { FAIL=$(( FAIL + 1 )); printf ' FAIL: %s\n' "$1"; } + +# --- test _box does not crash --- +_box "Test title" "line 1" "line 2" 2>/dev/null +_t_pass "_box output without crash" + +# --- test adb_check_cooldown with zero cooldown always proceeds --- +LAST_RUN_DIR="$(mktemp -d)" +trap 'rm -rf "${LAST_RUN_DIR}"' EXIT +if adb_check_cooldown 0 "test_marker"; then + _t_pass "adb_check_cooldown 0 returns 0 (proceed)" +else + _t_fail "adb_check_cooldown 0 should return 0" +fi + +# --- test cooldown blocks when marker is fresh --- +echo "$(date +%s)" > "${LAST_RUN_DIR}/fresh_marker" +if adb_check_cooldown 9999 "fresh_marker"; then + _t_fail "adb_check_cooldown 9999 should return 1 (blocked)" +else + _t_pass "adb_check_cooldown 9999 returns 1 (blocked) with fresh marker" +fi + +# --- test adb_mark_last_run creates marker --- +adb_mark_last_run "run_test" +[[ -f "${LAST_RUN_DIR}/run_test" ]] \ + && _t_pass "adb_mark_last_run creates marker file" \ + || _t_fail "adb_mark_last_run did not create marker" + +printf '\nResults: %d passed, %d failed\n' "${PASS}" "${FAIL}" +[[ "${FAIL}" -eq 0 ]] +``` + +- [ ] **Step 1.3: Run the test to verify it passes** + +```bash +cd /home/kuhy/testsAndMisc +bash phone_focus_mode/lib/tests/test_adb_common.sh +``` + +Expected: all tests pass, `Results: N passed, 0 failed`. + +- [ ] **Step 1.4: Run ShellCheck** + +```bash +shellcheck --severity=warning phone_focus_mode/lib/adb_common.sh +shellcheck --severity=warning phone_focus_mode/lib/tests/test_adb_common.sh +``` + +Expected: no warnings or errors. + +- [ ] **Step 1.5: Commit** + +```bash +git add phone_focus_mode/lib/adb_common.sh phone_focus_mode/lib/tests/test_adb_common.sh +git commit -m "feat(phone): add lib/adb_common.sh — device selection, identity, root, locking" +``` + +--- + +## Chunk 2: Declarative config — `backup_manifest.sh` + +### Files + +- Create: `phone_focus_mode/backup_manifest.sh` + +### Background + +`backup_manifest.sh` is the user-editable scope definition. It declares: + +- which APKs to snapshot +- which app-data locations to capture (all `manual_only` by default for v1) +- which media directories to sync +- health check thresholds + +All records use **bash arrays** (`APK_ITEMS=(...)`) where each element is a +pipe-delimited string. This requires bash (not POSIX sh) — all scripts target +bash 5 via `#!/usr/bin/env bash`. Consuming scripts iterate with +`for entry in "${APK_ITEMS[@]}"` and extract fields with `cut -d'|' -f`. + +Default backup root (override via `PHONE_BACKUP_ROOT` env var): + +``` +~/phone_backups +``` + +Full path per device: `${PHONE_BACKUP_ROOT}//` + +--- + +- [ ] **Step 2.1: Create `backup_manifest.sh`** + +Create `phone_focus_mode/backup_manifest.sh`: + +```bash +#!/usr/bin/env bash +# backup_manifest.sh — Declarative backup and restore scope definition. +# Source this file; do not execute directly. +# Fields: name|source_path|restore_policy|requires_root|integrity_check +# +# restore_policy values: +# safe_restore — may be restored automatically +# manual_only — backed up but never restored without operator action +# backup_only — backed up but restore is not yet implemented +# +# Format-detection thresholds (edit as needed): +FORMAT_DETECTION_MIN_MISSING=2 # ≥ this many missing indicators → alert + +# --------------------------------------------------------------------------- +# Backup root (override with PHONE_BACKUP_ROOT env var) +# --------------------------------------------------------------------------- +PHONE_BACKUP_ROOT="${PHONE_BACKUP_ROOT:-${HOME}/phone_backups}" + +# --------------------------------------------------------------------------- +# APKs to snapshot and reinstall +# Fields: package_id|restore_policy|requires_root|integrity_check +# --------------------------------------------------------------------------- +APK_ITEMS=( + "com.qqlabs.minimalistlauncher|safe_restore|no|yes" + "com.kuhy.focusstatus|safe_restore|no|yes" +) + +# --------------------------------------------------------------------------- +# App data (v1: all manual_only — see spec §Conservative v1 restore policy) +# Fields: package_id|data_path|restore_policy|requires_root|integrity_check +# --------------------------------------------------------------------------- +APP_DATA_ITEMS=( + "com.beemdevelopment.aegis|/data/data/com.beemdevelopment.aegis|manual_only|yes|yes" +) + +# --------------------------------------------------------------------------- +# Media and user files +# Fields: name|on_device_path|restore_policy|requires_root|integrity_check +# --------------------------------------------------------------------------- +MEDIA_ITEMS=( + "photos|/sdcard/DCIM|safe_restore|no|no" + "downloads|/sdcard/Download|safe_restore|no|no" + "documents|/sdcard/Documents|safe_restore|no|no" +) + +# --------------------------------------------------------------------------- +# Security state files to capture (relative to known on-device paths) +# --------------------------------------------------------------------------- +SECURITY_STATE_FILES=( + "/data/adb/service.d/99-focus-mode.sh" + "/data/adb/focus_mode/hosts.canonical" + "/data/local/tmp/focus_mode/focus_state" +) + +# --------------------------------------------------------------------------- +# Format-detection indicators +# Each entry: description|adb_test_command +# adb_test_command should exit 0 when the indicator IS present (not wiped). +# --------------------------------------------------------------------------- +FORMAT_INDICATORS=( + "Magisk boot script|test -f /data/adb/service.d/99-focus-mode.sh" + "Focus mode data dir|test -d /data/adb/focus_mode" + "Focus mode state dir|test -d /data/local/tmp/focus_mode" + "Minimalist launcher installed|pm list packages -e com.qqlabs.minimalistlauncher | grep -q com.qqlabs.minimalistlauncher" + "Focus companion app installed|pm list packages -e com.kuhy.focusstatus | grep -q com.kuhy.focusstatus" +) + +# --------------------------------------------------------------------------- +# Monitoring thresholds +# --------------------------------------------------------------------------- +BATTERY_WARN_BELOW=20 # % — warn when battery below this level +STORAGE_WARN_BELOW_MB=500 # MB free — warn when main storage below this +COOLDOWN_AUTO_SECS=300 # 5 min between auto runs (prevent backup storms) +HISTORY_KEEP_DAYS=30 # prune history snapshots older than this +``` + +- [ ] **Step 2.2: Run ShellCheck** + +```bash +shellcheck --severity=warning phone_focus_mode/backup_manifest.sh +``` + +Expected: no warnings. + +- [ ] **Step 2.3: Commit** + +```bash +git add phone_focus_mode/backup_manifest.sh +git commit -m "feat(phone): add backup_manifest.sh — declarative APK/data/media/format-detection config" +``` + +--- + +## Chunk 3: Monitoring library — `lib/monitor.sh` + +### Files + +- Create: `phone_focus_mode/lib/monitor.sh` +- Create: `phone_focus_mode/lib/tests/test_monitor.sh` + +### Background + +`monitor.sh` provides: + +- `monitor_collect_snapshot SNAPSHOT_DIR` — runs all checks, writes JSON report +- `monitor_check_format_indicators` — checks FORMAT_INDICATORS from manifest, returns missing count +- `monitor_print_summary SNAPSHOT_DIR` — human-readable summary from JSON report +- `monitor_severity_exit SNAPSHOT_DIR` — exit 1 if any `fatal` or `error` severity items exist + +The JSON report is written to `${SNAPSHOT_DIR}/report.json`. +The report uses severity: `ok`, `warn`, `error`, `fatal`. + +Each check record: + +```json +{ + "check": "name", + "status": "ok|warn|error|fatal", + "source": "command", + "message": "...", + "repairable": true +} +``` + +--- + +- [ ] **Step 3.1: Create `lib/monitor.sh`** + +Create `phone_focus_mode/lib/monitor.sh`: + +```bash +#!/usr/bin/env bash +# lib/monitor.sh — Security and health monitoring for the managed phone. +# Requires: adb_common.sh sourced, ADB_SERIAL set, backup_manifest.sh sourced. +set -euo pipefail + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- +_mon_check() { + # _mon_check CHECK_NAME STATUS SOURCE MESSAGE REPAIRABLE + local name="$1" status="$2" source="$3" message="$4" repairable="${5:-false}" + printf '{"check":"%s","status":"%s","source":"%s","message":"%s","repairable":%s}\n' \ + "${name}" "${status}" "${source}" "${message}" "${repairable}" +} + +_safe_adb_root() { + # Run adb root shell command; return empty string on failure rather than exit. + adb_root_shell "$@" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# monitor_check_format_indicators +# Prints each missing indicator name to stdout (one per line). +# Returns the count of missing indicators via exit code 0 always; +# caller should count output lines. +# --------------------------------------------------------------------------- +monitor_check_format_indicators() { + local indicator desc cmd result + for indicator in "${FORMAT_INDICATORS[@]}"; do + desc="${indicator%%|*}" + cmd="${indicator#*|}" + result=$(_safe_adb_root "${cmd}" 2>/dev/null || echo "MISSING") + # Command exits non-zero → indicator is absent + if ! adb_root_shell "${cmd}" >/dev/null 2>&1; then + printf '%s\n' "${desc}" + fi + done +} + +# --------------------------------------------------------------------------- +# monitor_is_formatted +# Returns 0 if phone appears freshly formatted (≥ FORMAT_DETECTION_MIN_MISSING +# indicators missing), 1 otherwise. +# --------------------------------------------------------------------------- +monitor_is_formatted() { + local -a missing + mapfile -t missing < <(monitor_check_format_indicators) + local count="${#missing[@]}" + _info "Format-detection: ${count}/${#FORMAT_INDICATORS[@]} indicators missing (threshold: ${FORMAT_DETECTION_MIN_MISSING})" + (( count >= FORMAT_DETECTION_MIN_MISSING )) +} + +# --------------------------------------------------------------------------- +# monitor_print_format_warning MISSING_ARRAY +# --------------------------------------------------------------------------- +monitor_print_format_warning() { + local -a missing=("$@") + _box "PHONE APPEARS TO HAVE BEEN WIPED" \ + "" \ + "The following expected components were NOT found:" \ + "${missing[@]/#/ ✗ }" \ + "" \ + "This strongly suggests the phone was factory-reset or formatted." \ + "" \ + "Next step: run the full recovery workflow:" \ + " ./scripts/run_all/run_phone.sh fresh-phone" \ + "" \ + "Do NOT run 'auto' mode — it will not restore anything." >&2 +} + +# --------------------------------------------------------------------------- +# _check_battery OUTFILE +# --------------------------------------------------------------------------- +_check_battery() { + local outfile="$1" + local level health temp status + level=$(_safe_adb_root "dumpsys battery | grep level | awk '{print \$2}'" | tr -d '\r') + health=$(_safe_adb_root "dumpsys battery | grep health | head -1 | awk '{print \$2}'" | tr -d '\r') + temp=$(_safe_adb_root "dumpsys battery | grep temperature | awk '{print \$2}'" | tr -d '\r') + status=$(_safe_adb_root "dumpsys battery | grep status | head -1 | awk '{print \$2}'" | tr -d '\r') + + local sev="ok" msg="Battery level ${level}%, health ${health}, temp ${temp}" + if [[ -n "${level}" ]] && (( level < BATTERY_WARN_BELOW )); then + sev="warn" + msg="Battery low: ${level}% (threshold ${BATTERY_WARN_BELOW}%)" + fi + _mon_check "battery" "${sev}" "dumpsys battery" "${msg}" "false" >> "${outfile}" +} + +# --------------------------------------------------------------------------- +# _check_storage OUTFILE +# --------------------------------------------------------------------------- +_check_storage() { + local outfile="$1" + local free_kb free_mb + free_kb=$(_safe_adb_root "df /sdcard | awk 'NR==2{print \$4}'" | tr -d '\r') + free_mb=$(( ${free_kb:-0} / 1024 )) + + local sev="ok" msg="Free storage: ${free_mb} MB" + if (( free_mb < STORAGE_WARN_BELOW_MB )); then + sev="warn" + msg="Low storage: ${free_mb} MB free (threshold ${STORAGE_WARN_BELOW_MB} MB)" + fi + _mon_check "storage" "${sev}" "df /sdcard" "${msg}" "false" >> "${outfile}" +} + +# --------------------------------------------------------------------------- +# _check_daemon NAME PID_CMD OUTFILE +# --------------------------------------------------------------------------- +_check_daemon() { + local name="$1" pid_cmd="$2" outfile="$3" + local pid + pid=$(_safe_adb_root "${pid_cmd}" | tr -d '\r ' || true) + if [[ -n "${pid}" && "${pid}" =~ ^[0-9]+$ ]]; then + _mon_check "${name}" "ok" "${pid_cmd}" "${name} running (PID ${pid})" "false" >> "${outfile}" + else + _mon_check "${name}" "error" "${pid_cmd}" "${name} is NOT running" "true" >> "${outfile}" + fi +} + +# --------------------------------------------------------------------------- +# _check_hosts_integrity OUTFILE +# --------------------------------------------------------------------------- +_check_hosts_integrity() { + local outfile="$1" + local canonical="/data/adb/focus_mode/hosts.canonical" + local active="/system/etc/hosts" + + if ! adb_root_shell "test -f ${canonical}" >/dev/null 2>&1; then + _mon_check "hosts_canonical" "fatal" "test -f ${canonical}" \ + "Canonical hosts file missing at ${canonical}" "true" >> "${outfile}" + return + fi + + local canon_hash active_hash + canon_hash=$(_safe_adb_root "sha256sum ${canonical} | awk '{print \$1}'" | tr -d '\r') + active_hash=$(_safe_adb_root "sha256sum ${active} | awk '{print \$1}'" | tr -d '\r') + + if [[ "${canon_hash}" == "${active_hash}" ]]; then + _mon_check "hosts_integrity" "ok" "sha256sum ${active}" \ + "Hosts file matches canonical (${active_hash:0:12}…)" "false" >> "${outfile}" + else + _mon_check "hosts_integrity" "error" "sha256sum ${active}" \ + "Hosts mismatch: active ${active_hash:0:12}… ≠ canonical ${canon_hash:0:12}…" \ + "true" >> "${outfile}" + fi +} + +# --------------------------------------------------------------------------- +# _check_dns OUTFILE +# --------------------------------------------------------------------------- +_check_dns() { + local outfile="$1" + local private_dns + private_dns=$(_safe_adb_root "settings get global private_dns_mode" | tr -d '\r') + if [[ "${private_dns}" == "off" || "${private_dns}" == "null" ]]; then + _mon_check "dns_private_dns" "ok" "settings get global private_dns_mode" \ + "Private DNS is off (expected)" "false" >> "${outfile}" + else + _mon_check "dns_private_dns" "error" "settings get global private_dns_mode" \ + "Private DNS is ON (mode=${private_dns}) — DNS enforcement may be bypassed" \ + "true" >> "${outfile}" + fi +} + +# --------------------------------------------------------------------------- +# _check_launcher OUTFILE +# --------------------------------------------------------------------------- +_check_launcher() { + local outfile="$1" + local pkg="com.qqlabs.minimalistlauncher" + if adb_root_shell "pm list packages -e ${pkg}" 2>/dev/null | grep -q "${pkg}"; then + _mon_check "launcher_installed" "ok" "pm list packages -e ${pkg}" \ + "Minimalist launcher is installed and enabled" "false" >> "${outfile}" + else + _mon_check "launcher_installed" "fatal" "pm list packages -e ${pkg}" \ + "Minimalist launcher is NOT installed" "true" >> "${outfile}" + fi +} + +# --------------------------------------------------------------------------- +# _check_boot_persistence OUTFILE +# --------------------------------------------------------------------------- +_check_boot_persistence() { + local outfile="$1" + local boot_script="/data/adb/service.d/99-focus-mode.sh" + if adb_root_shell "test -x ${boot_script}" >/dev/null 2>&1; then + _mon_check "boot_persistence" "ok" "test -x ${boot_script}" \ + "Magisk boot script present and executable" "false" >> "${outfile}" + else + _mon_check "boot_persistence" "fatal" "test -x ${boot_script}" \ + "Magisk boot script missing or not executable at ${boot_script}" "true" >> "${outfile}" + fi +} + +# --------------------------------------------------------------------------- +# monitor_collect_snapshot SNAPSHOT_DIR +# Runs all checks and writes SNAPSHOT_DIR/report.json. +# --------------------------------------------------------------------------- +monitor_collect_snapshot() { + local snapshot_dir="$1" + mkdir -p "${snapshot_dir}" + local tmp_checks + tmp_checks=$(mktemp) + trap 'rm -f "${tmp_checks}"' RETURN + + _info "Collecting monitoring snapshot → ${snapshot_dir}" + + _check_battery "${tmp_checks}" + _check_storage "${tmp_checks}" + _check_daemon "focus_daemon" "pgrep -f focus_daemon.sh" "${tmp_checks}" + _check_daemon "hosts_enforcer" "pgrep -f hosts_enforcer.sh" "${tmp_checks}" + _check_daemon "dns_enforcer" "pgrep -f dns_enforcer.sh" "${tmp_checks}" + _check_daemon "launcher_enforcer" "pgrep -f launcher_enforcer.sh" "${tmp_checks}" + _check_hosts_integrity "${tmp_checks}" + _check_dns "${tmp_checks}" + _check_launcher "${tmp_checks}" + _check_boot_persistence "${tmp_checks}" + + # Wrap lines into a JSON array + { + printf '{"timestamp":"%s","device":"%s","checks":[\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${ADB_SERIAL}" + paste -sd ',' "${tmp_checks}" + printf '\n]}\n' + } > "${snapshot_dir}/report.json" + + cp "${snapshot_dir}/report.json" \ + "$(dirname "${snapshot_dir}")/latest.json" 2>/dev/null || true + + _info "Snapshot saved: ${snapshot_dir}/report.json" +} + +# --------------------------------------------------------------------------- +# monitor_print_summary SNAPSHOT_DIR +# --------------------------------------------------------------------------- +monitor_print_summary() { + local snapshot_dir="$1" + local report="${snapshot_dir}/report.json" + [[ -f "${report}" ]] || { _warn "No report found at ${report}"; return; } + + printf '\n=== Monitoring Summary ===\n' + # Simple grep-based extraction (no jq required) + local ok warn err fatal + ok=$(grep -o '"status":"ok"' "${report}" | wc -l) + warn=$(grep -o '"status":"warn"' "${report}" | wc -l) + err=$(grep -o '"status":"error"' "${report}" | wc -l) + fatal=$(grep -o '"status":"fatal"' "${report}" | wc -l) + printf ' ok=%-3d warn=%-3d error=%-3d fatal=%-3d\n' \ + "${ok}" "${warn}" "${err}" "${fatal}" + + if (( warn + err + fatal > 0 )); then + printf '\nIssues found:\n' + grep -o '"check":"[^"]*","status":"[^ok][^"]*","[^}]*"message":"[^"]*"' \ + "${report}" | sed 's/.*"check":"\([^"]*\)".*"status":"\([^"]*\)".*"message":"\([^"]*\)".*/ [\2] \1: \3/' \ + || grep '"status":"' "${report}" || true + fi + printf '==========================\n\n' +} + +# --------------------------------------------------------------------------- +# monitor_severity_exit SNAPSHOT_DIR +# Returns 1 when any check has status fatal or error; 0 otherwise. +# Uses a precise regex matching only the "status" JSON key to avoid +# false positives from message text that contains the words "error" or "fatal". +# --------------------------------------------------------------------------- +monitor_severity_exit() { + local snapshot_dir="$1" + local report="${snapshot_dir}/report.json" + [[ -f "${report}" ]] || return 0 + # "status":"error" and "status":"fatal" are emitted by _mon_check only for + # the status field. Message values live under "message":"..." so this + # pattern cannot collide with free-text content. + if grep -qE '"status"\s*:\s*"(fatal|error)"' "${report}"; then + return 1 + fi + return 0 +} +``` + +- [ ] **Step 3.2: Create the test file** + +Create `phone_focus_mode/lib/tests/test_monitor.sh`: + +```bash +#!/usr/bin/env bash +# Unit tests for monitor.sh helpers that do not require a real device. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Minimal stubs so sourcing monitor.sh does not blow up without ADB +adb_root_shell() { return 1; } +_info() { :; } +_warn() { :; } +_error() { :; } +_fatal() { printf 'FATAL: %s\n' "$*" >&2; exit 1; } +_box() { :; } +FORMAT_INDICATORS=() +FORMAT_DETECTION_MIN_MISSING=2 +BATTERY_WARN_BELOW=20 +STORAGE_WARN_BELOW_MB=500 +ADB_SERIAL="test-serial" + +source "${SCRIPT_DIR}/../monitor.sh" + +PASS=0; FAIL=0 +_t_pass() { PASS=$(( PASS + 1 )); printf ' OK: %s\n' "$1"; } +_t_fail() { FAIL=$(( FAIL + 1 )); printf ' FAIL: %s\n' "$1"; } + +TMPDIR_TEST=$(mktemp -d) +trap 'rm -rf "${TMPDIR_TEST}"' EXIT + +# --- _mon_check produces valid JSON-like line --- +line=$(_mon_check "test_check" "ok" "some_cmd" "all good" "false") +[[ "${line}" == *'"check":"test_check"'* ]] \ + && _t_pass "_mon_check outputs check name" \ + || _t_fail "_mon_check missing check name" +[[ "${line}" == *'"status":"ok"'* ]] \ + && _t_pass "_mon_check outputs status" \ + || _t_fail "_mon_check missing status" + +# --- monitor_is_formatted returns 1 when no indicators are missing --- +# (FORMAT_INDICATORS is empty, so 0 missing < threshold 2) +if ! monitor_is_formatted 2>/dev/null; then + _t_pass "monitor_is_formatted returns 1 when no indicators are missing" +else + _t_fail "monitor_is_formatted should return 1 with empty FORMAT_INDICATORS" +fi + +# --- monitor_severity_exit returns 0 on empty/no report --- +monitor_severity_exit "${TMPDIR_TEST}/nonexistent" \ + && _t_pass "monitor_severity_exit returns 0 when no report" \ + || _t_fail "monitor_severity_exit should return 0 for missing report" + +# --- monitor_severity_exit returns 1 when fatal present --- +echo '{"checks":[{"check":"x","status":"fatal","source":"s","message":"m","repairable":false}]}' \ + > "${TMPDIR_TEST}/report.json" +if ! monitor_severity_exit "${TMPDIR_TEST}"; then + _t_pass "monitor_severity_exit returns 1 on fatal" +else + _t_fail "monitor_severity_exit should return 1 on fatal" +fi + +# --- monitor_severity_exit returns 0 when all ok --- +echo '{"checks":[{"check":"x","status":"ok","source":"s","message":"m","repairable":false}]}' \ + > "${TMPDIR_TEST}/report.json" +monitor_severity_exit "${TMPDIR_TEST}" \ + && _t_pass "monitor_severity_exit returns 0 on all-ok" \ + || _t_fail "monitor_severity_exit should return 0 on all-ok" + +printf '\nResults: %d passed, %d failed\n' "${PASS}" "${FAIL}" +[[ "${FAIL}" -eq 0 ]] +``` + +- [ ] **Step 3.3: Run the tests** + +```bash +bash phone_focus_mode/lib/tests/test_monitor.sh +``` + +Expected: all tests pass. + +- [ ] **Step 3.4: Run ShellCheck** + +```bash +shellcheck --severity=warning phone_focus_mode/lib/monitor.sh +shellcheck --severity=warning phone_focus_mode/lib/tests/test_monitor.sh +``` + +- [ ] **Step 3.5: Commit** + +```bash +git add phone_focus_mode/lib/monitor.sh phone_focus_mode/lib/tests/test_monitor.sh +git commit -m "feat(phone): add lib/monitor.sh — security and health monitoring" +``` + +--- + +## Chunk 4: Backup library — `lib/backup.sh` + +### Files + +- Create: `phone_focus_mode/lib/backup.sh` + +### Background + +`backup.sh` provides: + +- `backup_make_snapshot_dir DEVICE_ID` → prints path of new timestamped snapshot dir +- `backup_device_info SNAPSHOT_DIR` +- `backup_security_state SNAPSHOT_DIR` +- `backup_apks SNAPSHOT_DIR` +- `backup_app_data SNAPSHOT_DIR` +- `backup_media SNAPSHOT_DIR` +- `backup_prune_history DEVICE_BACKUP_ROOT` +- `backup_run_incremental DEVICE_ID` — calls all steps above + +All entries respect `restore_policy`. Items marked `manual_only` are backed up normally. `backup_only` are backed up but flagged with a note in the snapshot manifest. + +--- + +- [ ] **Step 4.1: Create `lib/backup.sh`** + +Create `phone_focus_mode/lib/backup.sh`: + +```bash +#!/usr/bin/env bash +# lib/backup.sh — Incremental backup of APKs, app data, media, and security state. +# Requires: adb_common.sh and backup_manifest.sh sourced, ADB_SERIAL set. +set -euo pipefail + +# --------------------------------------------------------------------------- +# backup_make_snapshot_dir DEVICE_ID → prints snapshot path +# --------------------------------------------------------------------------- +backup_make_snapshot_dir() { + local device_id="$1" + local ts + ts=$(date -u +%Y%m%dT%H%M%SZ) + local snapshot_dir="${PHONE_BACKUP_ROOT}/${device_id}/history/${ts}" + mkdir -p \ + "${snapshot_dir}/device_info" \ + "${snapshot_dir}/security_state" \ + "${snapshot_dir}/apks" \ + "${snapshot_dir}/app_data" \ + "${snapshot_dir}/media" \ + "${snapshot_dir}/monitoring" + printf '%s' "${snapshot_dir}" +} + +# --------------------------------------------------------------------------- +# _backup_update_latest SNAPSHOT_DIR DEVICE_BACKUP_ROOT +# --------------------------------------------------------------------------- +_backup_update_latest() { + local snapshot_dir="$1" + local device_root="$2" + local latest="${device_root}/latest" + rm -f "${latest}" + ln -s "${snapshot_dir}" "${latest}" 2>/dev/null \ + || cp -a "${snapshot_dir}/." "${latest}/" +} + +# --------------------------------------------------------------------------- +# backup_device_info SNAPSHOT_DIR +# --------------------------------------------------------------------------- +backup_device_info() { + local out="$1/device_info" + _info "Backing up device info → ${out}" + adb_cmd shell getprop > "${out}/getprop.txt" 2>/dev/null || true + adb_cmd shell pm list packages -f > "${out}/packages_full.txt" 2>/dev/null || true + adb_cmd shell pm list packages > "${out}/packages.txt" 2>/dev/null || true + adb_cmd shell df > "${out}/df.txt" 2>/dev/null || true + printf '%s\n' "${ADB_SERIAL}" > "${out}/serial.txt" + adb_cmd shell getprop ro.product.model > "${out}/model.txt" 2>/dev/null || true + adb_cmd shell getprop ro.build.fingerprint > "${out}/fingerprint.txt" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# backup_security_state SNAPSHOT_DIR +# --------------------------------------------------------------------------- +backup_security_state() { + local out="$1/security_state" + _info "Backing up security state → ${out}" + for f in "${SECURITY_STATE_FILES[@]}"; do + local dest="${out}$(dirname "${f}")" + mkdir -p "${dest}" + adb_root_shell "cat '${f}'" > "${dest}/$(basename "${f}")" 2>/dev/null || { + _warn "Could not back up ${f} (may not exist yet)" + } + done + # Daemon PIDs and status + adb_root_shell "pgrep -a -f '(focus_daemon|hosts_enforcer|dns_enforcer|launcher_enforcer)'" \ + > "${out}/daemon_pids.txt" 2>/dev/null || true + adb_root_shell "settings get global private_dns_mode" \ + > "${out}/private_dns_mode.txt" 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# backup_apks SNAPSHOT_DIR +# --------------------------------------------------------------------------- +backup_apks() { + local out="$1/apks" + _info "Backing up APKs → ${out}" + local entry pkg policy + for entry in "${APK_ITEMS[@]}"; do + pkg="${entry%%|*}" + policy=$(printf '%s' "${entry}" | cut -d'|' -f2) + + local apk_path + apk_path=$(adb_root_shell "pm path ${pkg} | head -1 | sed 's/package://'" \ + 2>/dev/null | tr -d '\r' || true) + + if [[ -z "${apk_path}" ]]; then + _warn "APK not found on device: ${pkg} (skipping)" + continue + fi + + local dest_dir="${out}/${pkg}" + mkdir -p "${dest_dir}" + adb_cmd pull "${apk_path}" "${dest_dir}/base.apk" 2>/dev/null \ + && _info " Backed up ${pkg} (policy: ${policy})" \ + || _warn " Failed to pull APK for ${pkg}" + + # Record policy alongside the APK + printf '%s\n' "${policy}" > "${dest_dir}/restore_policy.txt" + done +} + +# --------------------------------------------------------------------------- +# _validate_pkg_name PKG — abort if package name contains unsafe characters +# --------------------------------------------------------------------------- +_validate_pkg_name() { + local pkg="$1" + [[ "${pkg}" =~ ^[A-Za-z0-9._-]+$ ]] \ + || _fatal "Unsafe package name rejected: '${pkg}'. Only [A-Za-z0-9._-] allowed." +} + +# --------------------------------------------------------------------------- +# backup_app_data SNAPSHOT_DIR +# --------------------------------------------------------------------------- +backup_app_data() { + local out="$1/app_data" + _info "Backing up app data → ${out}" + local entry pkg path policy parent_dir base_dir + for entry in "${APP_DATA_ITEMS[@]}"; do + pkg="${entry%%|*}" + path=$(printf '%s' "${entry}" | cut -d'|' -f2) + policy=$(printf '%s' "${entry}" | cut -d'|' -f3) + + # Validate package name before use in a root shell command + _validate_pkg_name "${pkg}" + + local dest_dir="${out}/${pkg}" + mkdir -p "${dest_dir}" + + if ! adb_root_shell "test -d '${path}'" >/dev/null 2>&1; then + _warn "App data path not found: ${path} for ${pkg} (skipping)" + continue + fi + + parent_dir=$(dirname "${path}") + base_dir=$(basename "${path}") + # Build the tar command so the remote shell sees single-quoted path arguments, + # preventing any on-device word-splitting or glob expansion. + local tar_cmd="tar -czf - -C '${parent_dir}' '${base_dir}'" + adb_root_shell "${tar_cmd}" \ + > "${dest_dir}/data.tar.gz" 2>/dev/null \ + && _info " Backed up app data for ${pkg} (policy: ${policy})" \ + || _warn " Failed to back up app data for ${pkg}" + + printf '%s\n' "${policy}" > "${dest_dir}/restore_policy.txt" + [[ "${policy}" == "manual_only" ]] \ + && printf 'NOTE: manual_only — restore requires explicit operator action\n' \ + >> "${dest_dir}/restore_policy.txt" + done +} + +# --------------------------------------------------------------------------- +# backup_media SNAPSHOT_DIR +# --------------------------------------------------------------------------- +backup_media() { + local out="$1/media" + _info "Backing up media → ${out}" + local entry name path policy + for entry in "${MEDIA_ITEMS[@]}"; do + name="${entry%%|*}" + path=$(printf '%s' "${entry}" | cut -d'|' -f2) + policy=$(printf '%s' "${entry}" | cut -d'|' -f3) + + local dest_dir="${out}/${name}" + mkdir -p "${dest_dir}" + + adb_cmd pull "${path}" "${dest_dir}/" 2>/dev/null \ + && _info " Backed up media/${name} from ${path} (policy: ${policy})" \ + || _warn " Could not pull media/${name} from ${path} (may not exist)" + printf '%s\n' "${policy}" > "${dest_dir}/restore_policy.txt" + done +} + +# --------------------------------------------------------------------------- +# backup_prune_history DEVICE_BACKUP_ROOT +# Removes history snapshots older than HISTORY_KEEP_DAYS. +# --------------------------------------------------------------------------- +backup_prune_history() { + local device_root="$1" + local history_dir="${device_root}/history" + [[ -d "${history_dir}" ]] || return 0 + _info "Pruning history older than ${HISTORY_KEEP_DAYS} days in ${history_dir}" + find "${history_dir}" -maxdepth 1 -mindepth 1 -type d \ + -mtime "+${HISTORY_KEEP_DAYS}" -print -exec rm -rf '{}' + || true +} + +# --------------------------------------------------------------------------- +# backup_run_incremental DEVICE_ID +# Runs all backup steps and updates the latest/ pointer. +# --------------------------------------------------------------------------- +backup_run_incremental() { + local device_id="$1" + local device_root="${PHONE_BACKUP_ROOT}/${device_id}" + local snapshot_dir + snapshot_dir=$(backup_make_snapshot_dir "${device_id}") + + _info "Starting incremental backup to ${snapshot_dir}" + backup_device_info "${snapshot_dir}" + backup_security_state "${snapshot_dir}" + backup_apks "${snapshot_dir}" + backup_app_data "${snapshot_dir}" + backup_media "${snapshot_dir}" + _backup_update_latest "${snapshot_dir}" "${device_root}" + backup_prune_history "${device_root}" + _info "Backup complete: ${snapshot_dir}" + printf '%s' "${snapshot_dir}" +} +``` + +- [ ] **Step 4.2: Run ShellCheck** + +```bash +shellcheck --severity=warning phone_focus_mode/lib/backup.sh +``` + +- [ ] **Step 4.3: Commit** + +```bash +git add phone_focus_mode/lib/backup.sh +git commit -m "feat(phone): add lib/backup.sh — incremental APK/data/media/security backup" +``` + +--- + +## Chunk 5: Restore library — `lib/restore.sh` + +### Files + +- Create: `phone_focus_mode/lib/restore.sh` + +### Background + +`restore.sh` provides the restore actions used by `fresh-phone` mode. Each function respects the manifest's `restore_policy`. + +Key functions: + +- `restore_verify_prerequisites` — checks root, Magisk, ADB auth; fails with checklist if missing +- `restore_security_stack` — calls `deploy.sh` (the deployment primitive) +- `restore_apks BACKUP_DIR` — reinstalls APKs with `safe_restore` policy +- `restore_media BACKUP_DIR` — pushes media items back to device +- `restore_print_manual_steps BACKUP_DIR` — lists `manual_only` items the operator must handle + +`restore.sh` never restores `manual_only` app data automatically. It prints the list of manual steps. + +--- + +- [ ] **Step 5.1: Create `lib/restore.sh`** + +Create `phone_focus_mode/lib/restore.sh`: + +```bash +#!/usr/bin/env bash +# lib/restore.sh — Restore security stack, APKs, and media after format. +# Requires: adb_common.sh, backup_manifest.sh sourced, ADB_SERIAL set. +# deploy.sh path resolved relative to this library's parent directory. +set -euo pipefail + +_PHONE_PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# --------------------------------------------------------------------------- +# restore_verify_prerequisites +# Checks all prerequisites and prints a specific checklist if any are missing. +# Exits non-zero if prerequisites are not met. +# --------------------------------------------------------------------------- +restore_verify_prerequisites() { + local -a problems=() + + # ADB connectivity + if ! adb_cmd get-state >/dev/null 2>&1; then + problems+=("ADB device not reachable. Ensure USB debugging is authorized or wireless ADB is paired.") + fi + + # Root access + if ! adb_root_shell "echo root_ok" 2>/dev/null | grep -q "root_ok"; then + problems+=("Root shell failed. Ensure Magisk is installed and ADB root is authorized in Magisk settings.") + fi + + if [[ ${#problems[@]} -gt 0 ]]; then + _box "PREREQUISITES NOT MET — CANNOT CONTINUE" \ + "" \ + "Please resolve the following before running fresh-phone:" \ + "${problems[@]/#/ ✗ }" \ + "" \ + "Manual steps for a freshly formatted phone:" \ + " 1. Install Magisk from https://github.com/topjohnwu/Magisk" \ + " 2. In Magisk → Settings → enable 'Rooted Debugging'" \ + " 3. On phone: Settings → Developer options → enable 'USB Debugging'" \ + " 4. Authorize this PC on the phone when prompted" \ + " 5. Optionally pair wireless ADB: Settings → Developer options → Wireless debugging" + return 1 + fi + + _info "All prerequisites verified" +} + +# --------------------------------------------------------------------------- +# restore_security_stack +# Delegates entirely to deploy.sh — the deployment primitive. +# --------------------------------------------------------------------------- +restore_security_stack() { + _info "Restoring security stack via deploy.sh" + local deploy_script="${_PHONE_PROJECT_DIR}/deploy.sh" + [[ -x "${deploy_script}" ]] \ + || _fatal "deploy.sh not found or not executable at ${deploy_script}" + + # deploy.sh uses ADB_SERIAL when set; existing PHONE_IP fallback preserved. + env ADB_SERIAL="${ADB_SERIAL}" bash "${deploy_script}" +} + +# --------------------------------------------------------------------------- +# restore_apks BACKUP_DIR +# Reinstalls APKs that have restore_policy=safe_restore. +# --------------------------------------------------------------------------- +restore_apks() { + local backup_dir="$1" + local apk_dir="${backup_dir}/apks" + [[ -d "${apk_dir}" ]] || { _warn "No APK backup found at ${apk_dir}"; return 0; } + + _info "Restoring APKs from ${apk_dir}" + local entry pkg policy apk + for entry in "${APK_ITEMS[@]}"; do + pkg="${entry%%|*}" + policy=$(printf '%s' "${entry}" | cut -d'|' -f2) + + [[ "${policy}" == "safe_restore" ]] || { + _info " Skipping ${pkg} (policy: ${policy})" + continue + } + + apk="${apk_dir}/${pkg}/base.apk" + if [[ ! -f "${apk}" ]]; then + _warn " APK not found in backup: ${pkg} (${apk})" + continue + fi + + _info " Installing ${pkg}" + adb_cmd install -r "${apk}" \ + && _info " Installed ${pkg}" \ + || _warn " Failed to install ${pkg}" + done +} + +# --------------------------------------------------------------------------- +# restore_media BACKUP_DIR +# Pushes back media items with restore_policy=safe_restore. +# --------------------------------------------------------------------------- +restore_media() { + local backup_dir="$1" + local media_dir="${backup_dir}/media" + [[ -d "${media_dir}" ]] || { _warn "No media backup at ${media_dir}"; return 0; } + + _info "Restoring media from ${media_dir}" + local entry name path policy + for entry in "${MEDIA_ITEMS[@]}"; do + name="${entry%%|*}" + path=$(printf '%s' "${entry}" | cut -d'|' -f2) + policy=$(printf '%s' "${entry}" | cut -d'|' -f3) + + [[ "${policy}" == "safe_restore" ]] || { + _info " Skipping media/${name} (policy: ${policy})" + continue + } + + local src="${media_dir}/${name}" + [[ -d "${src}" ]] || { + _warn " Media backup not found: ${src}" + continue + } + + _info " Pushing media/${name} → ${path}" + adb_cmd push "${src}/." "${path}/" \ + && _info " Restored media/${name}" \ + || _warn " Failed to restore media/${name}" + done +} + +# --------------------------------------------------------------------------- +# restore_print_manual_steps BACKUP_DIR +# Lists all manual_only items the operator must handle. +# --------------------------------------------------------------------------- +restore_print_manual_steps() { + local backup_dir="$1" + local -a steps=() + + # App data: all manual_only in v1 + local entry pkg policy path + for entry in "${APP_DATA_ITEMS[@]}"; do + pkg="${entry%%|*}" + path=$(printf '%s' "${entry}" | cut -d'|' -f2) + policy=$(printf '%s' "${entry}" | cut -d'|' -f3) + [[ "${policy}" == "manual_only" ]] \ + && steps+=("App data for ${pkg}: backup at ${backup_dir}/app_data/${pkg}/data.tar.gz") + done + + # Secrets reminder + steps+=("Re-enter coordinates in phone_focus_mode/config_secrets.sh if needed") + steps+=("Re-authorize wireless ADB pairing if using it") + steps+=("Verify Magisk modules are re-installed if any were in use") + + if [[ ${#steps[@]} -gt 0 ]]; then + _box "MANUAL FOLLOW-UP STEPS REQUIRED" \ + "" \ + "The automated restore is complete. You must handle the following manually:" \ + "${steps[@]/#/ ▶ }" + fi +} +``` + +- [ ] **Step 5.2: Run ShellCheck** + +```bash +shellcheck --severity=warning phone_focus_mode/lib/restore.sh +``` + +- [ ] **Step 5.3: Commit** + +```bash +git add phone_focus_mode/lib/restore.sh +git commit -m "feat(phone): add lib/restore.sh — safe APK/media restore and manual-step printer" +``` + +--- + +## Chunk 6: Orchestrator — `run_phone.sh` + `deploy.sh` refactor + +### Files + +- Create: `phone_focus_mode/run_phone.sh` +- Modify: `phone_focus_mode/deploy.sh` (add `ADB_SERIAL` support) + +### Background + +`run_phone.sh` is the main orchestrator. It: + +1. Sources all library modules +2. Acquires the single-instance lock +3. Checks cooldown (for `auto` mode) +4. Calls the appropriate flow based on the subcommand argument +5. Exports `ADB_SERIAL` for all child processes including `deploy.sh` + +`deploy.sh` currently uses `PHONE_IP` unconditionally. Adding `ADB_SERIAL` support means: if `ADB_SERIAL` is set, use `-s "${ADB_SERIAL}"` in `adb` calls instead of assuming the IP-based connection. The existing `PHONE_IP` path should still work as before. + +--- + +- [ ] **Step 6.1: Read the current `deploy.sh` to understand its structure** + +```bash +head -60 phone_focus_mode/deploy.sh +``` + +- [ ] **Step 6.2: Add `ADB_SERIAL` support to `deploy.sh`** + +Find the section in `deploy.sh` that sets up the `adb` command (likely where `PHONE_IP` is used or where `adb connect` is called). Add the following early in the script, after the existing variable declarations: + +```bash +# Support device targeting via ADB_SERIAL (set by run_phone.sh orchestrator). +# When ADB_SERIAL is set, skip the IP-based connect and use -s directly. +if [[ -n "${ADB_SERIAL:-}" ]]; then + ADB_TARGET=(-s "${ADB_SERIAL}") + _info "Using device serial from ADB_SERIAL: ${ADB_SERIAL}" +else + ADB_TARGET=() + # existing IP-based connection logic remains unchanged below +fi +``` + +Then ensure all `adb` invocations in `deploy.sh` use `adb "${ADB_TARGET[@]}" ...` instead of bare `adb ...`. Preserve existing behavior when `ADB_SERIAL` is not set. + +> **Note:** Read `deploy.sh` in full (Step 6.1) before making this edit to understand exactly where the IP connection and adb calls are. Apply the minimum change needed. + +- [ ] **Step 6.3: Run ShellCheck on modified `deploy.sh`** + +```bash +shellcheck --severity=warning phone_focus_mode/deploy.sh +``` + +- [ ] **Step 6.4: Create `phone_focus_mode/run_phone.sh`** + +Create `phone_focus_mode/run_phone.sh`: + +```bash +#!/usr/bin/env bash +# run_phone.sh — Phone focus mode orchestrator. +# +# Usage: +# run_phone.sh [auto] Everyday: backup, monitor, repair minor drift. +# If the phone looks formatted, shows a warning +# and exits — does nothing else. +# run_phone.sh fresh-phone Full recovery after a factory reset. +# run_phone.sh backup Incremental backup only. +# run_phone.sh monitor Health and security snapshot only. +# run_phone.sh doctor Diagnose and repair drift without data restore. +# run_phone.sh --help | -h Show this help. +# +# Environment variables: +# ADB_SERIAL Target a specific device by serial. +# PHONE_BACKUP_ROOT Override backup root (default: ~/phone_backups). +# PHONE_IP Wireless ADB host (used by deploy.sh when ADB_SERIAL unset). +set -euo pipefail + +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# --------------------------------------------------------------------------- +# Source libraries +# --------------------------------------------------------------------------- +# shellcheck source=lib/adb_common.sh +source "${_SCRIPT_DIR}/lib/adb_common.sh" +# shellcheck source=backup_manifest.sh +source "${_SCRIPT_DIR}/backup_manifest.sh" +# shellcheck source=lib/monitor.sh +source "${_SCRIPT_DIR}/lib/monitor.sh" +# shellcheck source=lib/backup.sh +source "${_SCRIPT_DIR}/lib/backup.sh" +# shellcheck source=lib/restore.sh +source "${_SCRIPT_DIR}/lib/restore.sh" + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +SUBCOMMAND="${1:-auto}" +shift 2>/dev/null || true + +case "${SUBCOMMAND}" in + --help|-h|help) + grep '^#' "${BASH_SOURCE[0]}" | grep -v '^#!/' | sed 's/^# \?//' + exit 0 + ;; + auto|fresh-phone|backup|monitor|doctor) ;; + *) + _fatal "Unknown subcommand: '${SUBCOMMAND}'. Run with --help for usage." + ;; +esac + +# --------------------------------------------------------------------------- +# Shared setup: device selection, lock, identity +# --------------------------------------------------------------------------- +_setup_common() { + adb_select_device "${ADB_SERIAL:-}" + adb_verify_root + adb_verify_trusted_identity + adb_collect_identity +} + +# --------------------------------------------------------------------------- +# cmd_auto — everyday maintenance +# --------------------------------------------------------------------------- +cmd_auto() { + adb_acquire_lock + + if ! adb_check_cooldown "${COOLDOWN_AUTO_SECS}" "auto"; then + _info "auto: cooldown active, nothing to do." + exit 0 + fi + + _setup_common + + # Step 1: Format detection + local -a missing + mapfile -t missing < <(monitor_check_format_indicators) + local missing_count="${#missing[@]}" + if (( missing_count >= FORMAT_DETECTION_MIN_MISSING )); then + monitor_print_format_warning "${missing[@]}" + exit 2 + fi + + # Step 2: Monitoring snapshot + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + local snapshot_dir="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/$(date -u +%Y%m%dT%H%M%SZ)" + monitor_collect_snapshot "${snapshot_dir}" + + # Step 3: Incremental backup + backup_run_incremental "${device_id}" + + # Step 4: Minor repair (allowed repairs only) + _auto_repair_minor_drift "${snapshot_dir}" + + # Step 5: Summary + monitor_print_summary "${snapshot_dir}" + adb_mark_last_run "auto" + monitor_severity_exit "${snapshot_dir}" || exit 1 +} + +# --------------------------------------------------------------------------- +# _auto_repair_minor_drift SNAPSHOT_DIR +# Only restarts daemons when scripts already exist on-device. Never deploys. +# --------------------------------------------------------------------------- +_auto_repair_minor_drift() { + local snapshot_dir="$1" + local report="${snapshot_dir}/report.json" + [[ -f "${report}" ]] || return 0 + + local repaired=0 + # If daemons are missing but boot script exists — re-source the boot script + if grep -q '"check":"focus_daemon","status":"error"' "${report}" 2>/dev/null; then + if adb_root_shell "test -x /data/adb/service.d/99-focus-mode.sh" >/dev/null 2>&1; then + _info "Repair: restarting focus daemons via boot script" + adb_root_shell "sh /data/adb/service.d/99-focus-mode.sh" >/dev/null 2>&1 || true + repaired=$(( repaired + 1 )) + else + _warn "Cannot restart daemons: boot script missing. Run 'fresh-phone' or 'doctor'." + fi + fi + [[ ${repaired} -gt 0 ]] && _info "Minor repairs applied: ${repaired}" || true +} + +# --------------------------------------------------------------------------- +# cmd_fresh_phone — full recovery after format +# --------------------------------------------------------------------------- +cmd_fresh_phone() { + _info "=== fresh-phone: Full recovery mode ===" + adb_select_device "${ADB_SERIAL:-}" + + # Prerequisites must be met before we touch anything + restore_verify_prerequisites + + adb_collect_identity + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + local backup_root="${PHONE_BACKUP_ROOT}/${device_id}/latest" + + if [[ ! -d "${backup_root}" ]]; then + _fatal "No backup found at ${backup_root}. Cannot restore without a prior backup." + fi + + # Pre-change snapshot + local pre_snapshot="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/pre_restore_$(date -u +%Y%m%dT%H%M%SZ)" + monitor_collect_snapshot "${pre_snapshot}" || true + + # Restore in priority order (spec §Restore priority order) + restore_security_stack # delegates to deploy.sh + restore_apks "${backup_root}" + restore_media "${backup_root}" + + # Post-restore monitoring snapshot + local post_snapshot="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/post_restore_$(date -u +%Y%m%dT%H%M%SZ)" + monitor_collect_snapshot "${post_snapshot}" || true + monitor_print_summary "${post_snapshot}" + + # Save trusted device record now that setup is complete + adb_save_trusted_device + + # Manual steps + restore_print_manual_steps "${backup_root}" +} + +# --------------------------------------------------------------------------- +# cmd_backup — incremental backup only +# --------------------------------------------------------------------------- +cmd_backup() { + _setup_common + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + backup_run_incremental "${device_id}" +} + +# --------------------------------------------------------------------------- +# cmd_monitor — health snapshot only +# --------------------------------------------------------------------------- +cmd_monitor() { + _setup_common + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + local snapshot_dir="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/$(date -u +%Y%m%dT%H%M%SZ)" + monitor_collect_snapshot "${snapshot_dir}" + monitor_print_summary "${snapshot_dir}" + monitor_severity_exit "${snapshot_dir}" || exit 1 +} + +# --------------------------------------------------------------------------- +# cmd_doctor — diagnose and repair drift +# --------------------------------------------------------------------------- +cmd_doctor() { + _setup_common + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + local snapshot_dir="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/doctor_$(date -u +%Y%m%dT%H%M%SZ)" + monitor_collect_snapshot "${snapshot_dir}" + + local report="${snapshot_dir}/report.json" + local repaired=0 + + # Restart daemons + for daemon in focus_daemon hosts_enforcer dns_enforcer launcher_enforcer; do + if grep -q "\"check\":\"${daemon}\",\"status\":\"error\"" "${report}" 2>/dev/null; then + _info "Doctor: restarting ${daemon}" + adb_root_shell "pgrep -f ${daemon}.sh | xargs kill -9 2>/dev/null || true" >/dev/null 2>&1 || true + adb_root_shell "nohup sh /data/adb/focus_mode/${daemon}.sh /dev/null 2>&1 &" \ + >/dev/null 2>&1 || _warn "Could not restart ${daemon}" + repaired=$(( repaired + 1 )) + fi + done + + # Re-deploy security stack when boot script is missing + if grep -q '"check":"boot_persistence","status":"fatal"' "${report}" 2>/dev/null; then + _info "Doctor: boot script missing — re-running deploy.sh" + restore_security_stack + repaired=$(( repaired + 1 )) + fi + + # Re-push hosts/DNS if integrity checks failed and backing files exist + if grep -q '"check":"hosts_integrity","status":"error"' "${report}" 2>/dev/null; then + if [[ -f "${PHONE_BACKUP_ROOT}/${device_id}/latest/security_state/data/adb/focus_mode/hosts.canonical" ]]; then + _info "Doctor: restoring canonical hosts from backup" + adb_cmd push \ + "${PHONE_BACKUP_ROOT}/${device_id}/latest/security_state/data/adb/focus_mode/hosts.canonical" \ + "/data/adb/focus_mode/hosts.canonical" + repaired=$(( repaired + 1 )) + else + _warn "Doctor: hosts integrity failed but no backup copy available. Run fresh-phone." + fi + fi + + # Post-repair snapshot + monitor_collect_snapshot "${snapshot_dir}_after" + monitor_print_summary "${snapshot_dir}_after" + + _info "Doctor complete. Repairs applied: ${repaired}" + monitor_severity_exit "${snapshot_dir}_after" || { + _warn "Unresolved issues remain after doctor run." + exit 1 + } +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- +case "${SUBCOMMAND}" in + auto) cmd_auto ;; + fresh-phone) cmd_fresh_phone ;; + backup) cmd_backup ;; + monitor) cmd_monitor ;; + doctor) cmd_doctor ;; +esac +``` + +- [ ] **Step 6.5: Make it executable** + +```bash +chmod +x phone_focus_mode/run_phone.sh +``` + +- [ ] **Step 6.6: Run ShellCheck** + +```bash +shellcheck --severity=warning phone_focus_mode/run_phone.sh +``` + +- [ ] **Step 6.7: Smoke test (no device required)** + +```bash +bash phone_focus_mode/run_phone.sh --help +``` + +Expected: prints usage text without errors. + +- [ ] **Step 6.8: Commit** + +```bash +git add phone_focus_mode/run_phone.sh phone_focus_mode/deploy.sh +git commit -m "feat(phone): add run_phone.sh orchestrator; add ADB_SERIAL support to deploy.sh" +``` + +--- + +## Chunk 7: Visible entrypoint + systemd + README + +### Files + +- Create: `scripts/run_all/run_phone.sh` +- Create: `phone_focus_mode/systemd/phone-auto-sync.service` +- Create: `phone_focus_mode/systemd/phone-auto-sync.timer` +- Create: `phone_focus_mode/systemd/install_pc_phone_automation.sh` +- Modify (create if absent): `phone_focus_mode/README.md` + +--- + +- [ ] **Step 7.1: Create `scripts/run_all/` and the visible wrapper** + +```bash +mkdir -p scripts/run_all +``` + +Create `scripts/run_all/run_phone.sh`: + +```bash +#!/usr/bin/env bash +# run_phone.sh — Visible entrypoint for the phone focus mode workflow. +# +# Quick reference: +# ./scripts/run_all/run_phone.sh Everyday: backup + monitor + minor repair. +# Shows a warning if the phone was wiped. +# ./scripts/run_all/run_phone.sh fresh-phone Full recovery after a factory reset. +# ./scripts/run_all/run_phone.sh doctor Diagnose and repair security drift. +# ./scripts/run_all/run_phone.sh backup Incremental backup only. +# ./scripts/run_all/run_phone.sh monitor Health snapshot only. +# ./scripts/run_all/run_phone.sh --help Show full usage. +set -euo pipefail + +_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +_IMPL="${_REPO_ROOT}/phone_focus_mode/run_phone.sh" + +if [[ ! -x "${_IMPL}" ]]; then + printf 'ERROR: implementation script not found or not executable: %s\n' "${_IMPL}" >&2 + exit 1 +fi + +exec bash "${_IMPL}" "$@" +``` + +- [ ] **Step 7.2: Make it executable** + +```bash +chmod +x scripts/run_all/run_phone.sh +``` + +- [ ] **Step 7.3: Smoke test the wrapper** + +```bash +./scripts/run_all/run_phone.sh --help +``` + +Expected: prints the usage block from `phone_focus_mode/run_phone.sh`. + +- [ ] **Step 7.4: Create systemd user service** + +Create `phone_focus_mode/systemd/phone-auto-sync.service`: + +```ini +[Unit] +Description=Phone focus mode auto sync +After=network.target + +[Service] +Type=oneshot +ExecStart=%h/testsAndMisc/scripts/run_all/run_phone.sh auto +StandardOutput=journal +StandardError=journal +``` + +- [ ] **Step 7.5: Create systemd user timer** + +Create `phone_focus_mode/systemd/phone-auto-sync.timer`: + +```ini +[Unit] +Description=Run phone focus mode auto sync periodically + +[Timer] +OnBootSec=5min +OnUnitActiveSec=30min +Persistent=true + +[Install] +WantedBy=timers.target +``` + +- [ ] **Step 7.6: Create the installer script** + +Create `phone_focus_mode/systemd/install_pc_phone_automation.sh`: + +```bash +#!/usr/bin/env bash +# install_pc_phone_automation.sh — Install user-level systemd automation for +# periodic phone sync. Runs as the current user (no sudo required). +set -euo pipefail + +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_SYSTEMD_USER_DIR="${HOME}/.config/systemd/user" + +mkdir -p "${_SYSTEMD_USER_DIR}" + +cp "${_SCRIPT_DIR}/phone-auto-sync.service" "${_SYSTEMD_USER_DIR}/" +cp "${_SCRIPT_DIR}/phone-auto-sync.timer" "${_SYSTEMD_USER_DIR}/" + +systemctl --user daemon-reload +systemctl --user enable --now phone-auto-sync.timer + +printf 'Installed and enabled phone-auto-sync.timer\n' +printf 'Next run: ' +systemctl --user list-timers phone-auto-sync.timer --no-legend \ + | awk '{print $1, $2}' || printf '(check with: systemctl --user list-timers)\n' +``` + +- [ ] **Step 7.7: Make installer executable** + +```bash +chmod +x phone_focus_mode/systemd/install_pc_phone_automation.sh +``` + +- [ ] **Step 7.8: Run ShellCheck on all new scripts** + +```bash +shellcheck --severity=warning scripts/run_all/run_phone.sh +shellcheck --severity=warning phone_focus_mode/systemd/install_pc_phone_automation.sh +``` + +- [ ] **Step 7.9: Create/update `phone_focus_mode/README.md`** + +Create `phone_focus_mode/README.md`: + +````markdown +--- +post_title: "Phone focus mode" +author1: "kuhy" +post_slug: "phone-focus-mode" +microsoft_alias: "" +featured_image: "" +categories: + - "Documentation" +tags: + - "android" + - "adb" + - "shell" +ai_note: "AI-assisted documentation" +summary: "One-command Android hardening management: backup, monitoring, and full recovery after a factory reset." +post_date: "2026-05-01" +--- + +## What is this? + +A rooted-Android management system that enforces focus-mode restrictions, +monitors security and health, takes incremental backups, and can fully +restore a freshly formatted phone to its hardened state. + +## Quick reference + +Run the visible entrypoint from the repository root: + +```bash +./scripts/run_all/run_phone.sh +``` +```` + +### Normal day + +```bash +./scripts/run_all/run_phone.sh +``` + +Takes an incremental backup, collects health and security status, repairs +minor drift, and prints a summary. If the phone looks wiped, shows a warning +and suggests `fresh-phone` instead — it never automatically restores data. + +### After a factory reset + +```bash +./scripts/run_all/run_phone.sh fresh-phone +``` + +Full recovery: verifies root and prerequisites, restores the security stack +via `deploy.sh`, reinstalls APKs, restores media, and lists remaining manual +steps (such as app data that requires manual restore). + +### If something feels wrong + +```bash +./scripts/run_all/run_phone.sh doctor +``` + +Inspects all security and health checks, attempts low-risk repairs (restarting +daemons, re-pushing hosts, re-deploying the security stack if the boot script +is missing), and clearly lists repaired vs unresolved issues. + +### Other modes + +```bash +./scripts/run_all/run_phone.sh backup # Incremental backup only +./scripts/run_all/run_phone.sh monitor # Health snapshot only +./scripts/run_all/run_phone.sh --help # Full usage +``` + +## Backup storage + +Backups are stored outside the repository: + +``` +~/phone_backups// + latest/ → symlink to newest snapshot + history// → full snapshots + device_info/ + security_state/ + apks/ + app_data/ (all manual_only in v1) + media/ + monitoring/ +``` + +Override the root with: `export PHONE_BACKUP_ROOT=/path/to/backups` + +## Security stack components + +| Script | Purpose | +| ---------------------- | -------------------------------------------------------- | +| `deploy.sh` | Deployment primitive: pushes all scripts, starts daemons | +| `focus_daemon.sh` | On-device: GPS-based focus enforcement | +| `hosts_enforcer.sh` | On-device: protects `/system/etc/hosts` | +| `dns_enforcer.sh` | On-device: forces Private DNS off, blocks DoH/DoT | +| `launcher_enforcer.sh` | On-device: keeps approved launcher pinned | +| `magisk_service.sh` | Boot persistence via Magisk `service.d` | + +## PC automation + +Install periodic auto-sync (runs every 30 minutes when phone is available): + +```bash +bash phone_focus_mode/systemd/install_pc_phone_automation.sh +``` + +## Configuration + +Edit `phone_focus_mode/backup_manifest.sh` to change: + +- which APKs are snapshotted and restored +- which app-data directories are captured +- which media directories are synced +- format-detection thresholds and cooldown settings + +Secrets (GPS coordinates) live in `phone_focus_mode/config_secrets.sh` +(gitignored, must be created manually on each machine). + +```` + +- [ ] **Step 7.10: Run pre-commit on all new/modified files** + +```bash +pre-commit run --files \ + scripts/run_all/run_phone.sh \ + phone_focus_mode/run_phone.sh \ + phone_focus_mode/lib/adb_common.sh \ + phone_focus_mode/lib/monitor.sh \ + phone_focus_mode/lib/backup.sh \ + phone_focus_mode/lib/restore.sh \ + phone_focus_mode/backup_manifest.sh \ + phone_focus_mode/systemd/install_pc_phone_automation.sh \ + phone_focus_mode/README.md +```` + +Fix any issues found before proceeding. + +- [ ] **Step 7.11: Commit** + +```bash +git add \ + scripts/run_all/run_phone.sh \ + phone_focus_mode/systemd/ \ + phone_focus_mode/README.md +git commit -m "feat(phone): add visible wrapper, systemd automation, and README" +``` + +--- + +## Chunk 8: Live verification on the phone + +> **Prerequisite:** Phone connected via USB or wireless ADB. + +- [ ] **Step 8.1: Verify `--help` works end-to-end** + +```bash +./scripts/run_all/run_phone.sh --help +``` + +- [ ] **Step 8.2: Run `monitor` to confirm ADB reaches the device** + +```bash +./scripts/run_all/run_phone.sh monitor +``` + +Inspect output. Fix any shellcheck or runtime issues found. + +- [ ] **Step 8.3: Run `backup` to confirm backup writes to `~/phone_backups/`** + +```bash +./scripts/run_all/run_phone.sh backup +ls ~/phone_backups/ +``` + +- [ ] **Step 8.4: Run `auto` to confirm format-detection and normal flow both work** + +```bash +./scripts/run_all/run_phone.sh auto +``` + +- [ ] **Step 8.5: Simulate a format-detected scenario (dry run)** + +Temporarily add a non-existent indicator to `FORMAT_INDICATORS` in +`backup_manifest.sh`, run `auto`, confirm the warning box appears and the +script exits without doing anything else. Revert the temporary change after. + +- [ ] **Step 8.6: Final pre-commit pass** + +```bash +pre-commit run --all-files +``` + +Fix any issues. + +- [ ] **Step 8.7: Final commit if needed** + +```bash +git add -A +git commit -m "chore(phone): final pre-commit fixes and live-verification adjustments" +``` + +--- + +## Summary of all files created/modified + +| Path | Action | +| --------------------------------------------------------- | --------------------------- | +| `phone_focus_mode/lib/adb_common.sh` | Create | +| `phone_focus_mode/lib/monitor.sh` | Create | +| `phone_focus_mode/lib/backup.sh` | Create | +| `phone_focus_mode/lib/restore.sh` | Create | +| `phone_focus_mode/lib/tests/test_adb_common.sh` | Create | +| `phone_focus_mode/lib/tests/test_monitor.sh` | Create | +| `phone_focus_mode/backup_manifest.sh` | Create | +| `phone_focus_mode/run_phone.sh` | Create | +| `phone_focus_mode/deploy.sh` | Modify (ADB_SERIAL support) | +| `phone_focus_mode/systemd/phone-auto-sync.service` | Create | +| `phone_focus_mode/systemd/phone-auto-sync.timer` | Create | +| `phone_focus_mode/systemd/install_pc_phone_automation.sh` | Create | +| `phone_focus_mode/README.md` | Create | +| `scripts/run_all/run_phone.sh` | Create | diff --git a/docs/superpowers/specs/2026-05-01-phone-focus-recovery-design.md b/docs/superpowers/specs/2026-05-01-phone-focus-recovery-design.md new file mode 100644 index 0000000..14da956 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-phone-focus-recovery-design.md @@ -0,0 +1,709 @@ +--- +post_title: "Phone focus recovery design" +author1: "GitHub Copilot" +post_slug: "phone-focus-recovery-design" +microsoft_alias: "copilot" +featured_image: "" +categories: + - "Documentation" +tags: + - "android" + - "adb" + - "backup" + - "shell" +ai_note: "AI-assisted design document" +summary: "Design for a rooted-Android recovery, backup, monitoring, and one-command orchestration workflow built on phone_focus_mode." +post_date: "2026-05-01" +--- + +## Goal + +Create a repeatable rooted-Android management workflow that can: + +- restore a freshly formatted phone to the previously hardened state +- back up important phone state whenever the phone appears on this PC +- monitor security and device-health drift over time +- expose one highly visible entrypoint at `scripts/run_all/run_phone.sh` + +The design must build on the existing `phone_focus_mode/` deployment system +instead of replacing it with a second parallel toolchain. + +## Existing foundation + +The existing `phone_focus_mode/` implementation already provides the core +security stack: + +- `deploy.sh` deploys the focus scripts and companion app over ADB +- `focus_daemon.sh` enforces location-based focus restrictions +- `hosts_enforcer.sh` protects `/system/etc/hosts` +- `dns_enforcer.sh` forces DNS behavior that respects the hosts file +- `launcher_enforcer.sh` keeps the approved launcher installed and pinned +- `magisk_service.sh` restores the protections automatically on boot + +The new workflow should reuse these assets rather than re-implement them. + +## Approved user experience + +The workflow must support three main operator experiences. + +### Normal day + +Running: + +```bash +./scripts/run_all/run_phone.sh +``` + +must: + +- detect the phone over USB or paired wireless ADB +- take or update a backup snapshot +- collect health and security status +- repair minor drift when safe to do so +- print a concise summary of what changed and any remaining warnings + +### After a format + +Running: + +```bash +./scripts/run_all/run_phone.sh fresh-phone +``` + +must: + +- reconnect to the rooted phone +- restore the security stack first +- restore launcher, APKs, selected app data, and configured user files +- validate that the hardening is active again +- list any unavoidable manual follow-up actions + +### If something feels wrong + +Running: + +```bash +./scripts/run_all/run_phone.sh doctor +``` + +must: + +- inspect the same security and health checks as monitoring mode +- attempt repair of common drift +- stop short of broad destructive restore operations +- clearly distinguish between repaired issues and unresolved issues + +This “what do I run and when?” guidance must appear in both: + +- the future `phone_focus_mode/README.md` updates +- the help/usage text inside the visible wrapper script and the underlying + implementation script + +## File layout + +The visible entrypoint should live at the top level, while the implementation +stays with the phone project. + +### Visible entrypoint + +- `scripts/run_all/run_phone.sh` + +Responsibilities: + +- be easy to find and remember +- locate the repository root reliably +- forward arguments to the project-local implementation +- provide brief usage/help output for common flows + +This script should stay thin and stable. + +### Project-local implementation + +- `phone_focus_mode/run_phone.sh` + +Responsibilities: + +- orchestrate detection, backup, monitoring, restore, and repair flows +- call or wrap `deploy.sh` rather than replacing it +- serve as the canonical implementation home for phone-specific logic + +### Supporting libraries + +- `phone_focus_mode/lib/adb_common.sh` +- `phone_focus_mode/lib/backup.sh` +- `phone_focus_mode/lib/restore.sh` +- `phone_focus_mode/lib/monitor.sh` + +Responsibilities: + +- isolate common shell helpers into focused modules +- keep `run_phone.sh` readable and testable +- avoid duplicating fragile ADB, path, and parsing logic + +### Declarative configuration + +- `phone_focus_mode/backup_manifest.sh` + +Responsibilities: + +- define which packages should have APK snapshots +- define which app data locations should be captured +- define which media/user directories should be synced +- define health thresholds and alerting policy +- classify each restore target as safe, manual-only, or backup-only + +This file should be the user-editable scope definition rather than burying +every backup decision in shell code. The manifest should be shell-native so +the implementation does not need a separate JSON parser dependency just to +load backup scope. + +### PC automation assets + +- `phone_focus_mode/systemd/install_pc_phone_automation.sh` +- `phone_focus_mode/systemd/phone-auto-sync.service` +- `phone_focus_mode/systemd/phone-auto-sync.timer` + +Responsibilities: + +- install user-level automation on the PC +- periodically call the visible wrapper in safe `auto` mode +- serve as a fallback when hotplug or live discovery is imperfect + +## Backup storage layout + +Backups must be stored outside the Git workspace in a configurable local host +path. This avoids polluting the repository with large APKs, app data, media, +and other binary artifacts that violate the workspace’s normal storage rules. + +Recommended structure: + +- `../testsAndMisc_binaries/phone_focus_backups//latest/` +- `../testsAndMisc_binaries/phone_focus_backups//history//` + +Only small text manifests or reports may live in-repo when helpful. APKs, +media, databases, and app-data payloads must stay in the external backup root. + +Each snapshot should contain the following subdirectories. + +### `device_info/` + +- device properties +- Android version and build fingerprint +- installed package inventory +- partition and storage information +- serial and connection metadata + +### `security_state/` + +- generated canonical hosts file +- launcher APK snapshot and pinned activity metadata +- focus-mode logs and status files +- daemon and enforcer health snapshots +- DNS and firewall status outputs + +### `apks/` + +- selected APK exports for reinstallable apps + +### `app_data/` + +- configured rooted data pulls for selected packages + +### `media/` + +- configured user-facing storage such as photos, downloads, and documents + +### `monitoring/` + +- device-health snapshots over time +- summarized alert reports +- JSON snapshots suitable for later tooling or diffing + +## Command modes + +The implementation should support explicit subcommands plus a safe default. + +### Default mode: `auto` + +Invoked by: + +```bash +./scripts/run_all/run_phone.sh +``` + +Flow: + +1. discover or select exactly one device +2. verify root and repository prerequisites +3. **check for fresh-format indicators** (absence of focus scripts at expected + paths, missing daemon PIDs, absent magisk module, empty/missing `STATE_DIR`, + known app whitelist not installed) + - **If format detected:** print a clearly formatted warning block naming each + missing indicator, explain that the phone appears to have been wiped, and + suggest running `fresh-phone` mode. **Exit immediately. Do nothing else.** +4. collect a quick monitoring snapshot +5. run incremental backup steps +6. inspect the security stack for drift +7. repair minor drift when the repair is low risk +8. print a summary with warnings and any skipped actions + +`auto` mode must never perform any restore or re-deployment action. It is +read-and-report only when the phone looks healthy, and detect-and-warn only +when the phone looks wiped. + +### `fresh-phone` + +Invoked by: + +```bash +./scripts/run_all/run_phone.sh fresh-phone +``` + +Flow: + +1. connect to the target phone +2. verify root and backup availability +3. record a pre-change snapshot +4. restore security assets first +5. restore launcher snapshot and home activity +6. restore selected APKs +7. restore selected app data +8. restore configured user files +9. run full verification +10. print manual follow-up steps, if any + +If the phone is not yet in the minimum expected state, the workflow must stop +with a precise checklist rather than performing a partial restore. + +### `backup` + +Flow: + +1. detect or connect device +2. create timestamped snapshot directory +3. collect metadata, APKs, app data, media, and security state +4. update the `latest/` snapshot pointer or mirror +5. prune history according to retention policy + +### `monitor` + +Flow: + +1. detect or connect device +2. collect health and security state +3. compare against thresholds and prior snapshots +4. emit human-readable and machine-readable reports +5. return nonzero exit status on severe drift + +### `doctor` + +Flow: + +1. run the monitoring checks +2. attempt low-risk repairs +3. restart missing daemons or re-push missing security assets if needed +4. stop before broad data restore actions +5. print repaired vs unresolved issues clearly + +## Repair policy by mode + +The implementation must use an explicit repair allowlist instead of treating +“minor drift” as an open-ended concept. + +### Repairs allowed in `auto` + +- restart managed daemons when scripts and state already exist +- restart the companion status app when it is already part of the setup +- reassert hosts, DNS, or launcher enforcement when the required backing files + already exist locally and on-device +- re-run deployment of the security stack when the drift is clearly limited to + managed `phone_focus_mode` assets + +### Repairs forbidden in `auto` + +- broad APK restore +- app-data restore +- media restore +- any action that changes user data outside the managed security stack +- any destructive cleanup of backup history + +### Repairs allowed in `doctor` + +- everything allowed in `auto` +- reinstall the companion app +- restore launcher snapshot and HOME activity when launcher backup metadata is + present +- re-push missing managed security assets from the local project state + +### Repairs forbidden in `doctor` + +- broad app-data restore +- media restore +- destructive reset of on-device state outside the managed security stack + +### Actions allowed only in `fresh-phone` + +- APK reinstall from backup +- selected app-data restore according to manifest policy +- configured media and user-file restore + +Any action outside these allowlists must require explicit future design or +manual operator intent. + +## Device detection and connection policy + +The workflow must support both USB and wireless ADB. + +Selection order: + +1. use an explicitly supplied serial if present +2. use the only already-connected device if there is exactly one +3. use a saved wireless endpoint when available +4. try controlled wireless discovery fallback +5. fail with a clear message when multiple candidate devices exist + +The workflow must avoid acting on the wrong device silently. + +### Trusted identity requirements + +The implementation should persist and verify a trusted identity record for the +managed phone, including: + +- preferred ADB serial or wireless endpoint +- device model +- Android build fingerprint +- a stable property such as serial or hardware identifier when available + +The script must refuse to proceed automatically when: + +- more than one viable device is connected +- the connected device identity no longer matches the trusted record +- both USB and wireless sessions point to ambiguous or conflicting targets + +### First-run and post-format prerequisites + +`fresh-phone` cannot assume that all prerequisites already exist. Before any +restore work, the script must verify: + +- USB debugging is authorized or wireless debugging is paired +- ADB can reach the device reliably +- Magisk and root are available +- root shell commands succeed in the expected mount namespace + +If any prerequisite is missing, the command must stop and print the manual +steps required to continue, such as USB authorization, Magisk installation, or +first-time wireless pairing. + +## Architecture boundary with existing deployment code + +The implementation must not duplicate the core deployment logic already present +in `phone_focus_mode/deploy.sh`. + +Rules: + +- `deploy.sh` remains the deployment primitive for pushing security assets and + bringing up the phone hardening stack +- `run_phone.sh` may wrap or call `deploy.sh`, but must not reimplement its + file-push, daemon-start, or root-verification logic in parallel +- shared ADB and device-selection helpers may be extracted into common library + functions when that reduces duplication across both scripts + +### Concrete integration path + +The implementation plan should follow this sequence: + +1. extract transport-agnostic ADB targeting helpers into + `phone_focus_mode/lib/adb_common.sh` +2. refactor `deploy.sh` so it can operate on a resolved target serial or + selected device abstraction rather than assuming a raw phone IP only +3. make `phone_focus_mode/run_phone.sh` the orchestration layer that performs + selection, backup, monitoring, and then delegates deployment work to + `deploy.sh` + +This path preserves the proven deployment behavior while making it compatible +with USB and wireless device selection. + +This keeps the new orchestration layer from drifting away from the proven +deployment flow. + +## Monitoring scope + +Monitoring should cover all user-requested areas. + +### Battery wear and thermal state + +- battery level +- charge status +- health and temperature if exposed +- evidence of abnormal thermal throttling or overheating + +### Storage pressure and filesystem issues + +- free space on major storage locations +- install/update failures caused by storage exhaustion +- signs of partition or package-management problems + +### Performance and resource drift + +- memory pressure indicators +- unusually heavy processes +- persistent crash or restart loops in the managed daemons + +### Security drift + +- focus daemon running or not +- hosts enforcer running or not +- DNS enforcer running or not +- launcher enforcer running or not +- companion app installed or missing + +### Network and DNS bypass drift + +- Private DNS re-enabled +- expected firewall chain missing +- hosts target hash or mount mismatch +- launcher default changed away from the protected launcher + +### Boot persistence drift + +- Magisk `service.d` script missing or no longer executable +- expected on-device files missing from `focus_mode` +- companion app missing when it is required for status visibility + +## Monitoring report contract + +Every monitoring run should produce two outputs: + +- a concise human-readable summary +- a machine-readable report file suitable for later diffing and automation + +Recommended report path pattern: + +- `//monitoring/.json` +- `//monitoring/latest.json` + +Recommended trusted-device record path: + +- `${XDG_STATE_HOME:-$HOME/.local/state}/phone_focus_mode/trusted_device.sh` + +Recommended runtime-automation state paths: + +- `${XDG_STATE_HOME:-$HOME/.local/state}/phone_focus_mode/locks/` +- `${XDG_STATE_HOME:-$HOME/.local/state}/phone_focus_mode/last_run/` + +Daily automation must not dirty the repository working tree merely by being +used. Trusted-device metadata, lock files, cooldown markers, and last-run +timestamps should therefore live in machine-local state outside the repo. + +Recommended latest-backup pointer behavior: + +- `latest/` should be a symlink to the newest history snapshot when the host + filesystem supports symlinks +- otherwise `latest/` may be refreshed as a copied mirror of the newest + snapshot + +The machine-readable report should distinguish at least these severities: + +- `ok` +- `warn` +- `error` +- `fatal` + +Each reported check should record: + +- check name +- status/severity +- evidence source +- short message +- whether the issue is repairable automatically + +Important checks should verify more than just PID existence. For example: + +- hosts protection should confirm both the canonical hash and the active + target mount/content state +- DNS protection should confirm Private DNS settings and firewall chain + presence +- launcher protection should confirm installation, stored snapshot metadata, + and current default HOME activity +- boot persistence should confirm the expected Magisk boot script is present + +`monitor` should exit nonzero on severe drift. `doctor` should use the same +report format while additionally recording what was repaired. + +For the initial implementation, “severe drift” should mean any of: + +- target device identity mismatch +- no root access when root-dependent checks are required +- hosts enforcement missing or failing integrity checks +- DNS enforcement missing when it was previously configured +- launcher protection missing when launcher protection was previously + configured +- missing boot persistence for the managed stack + +History retention and pruning policy should be locked down in the +implementation plan, but the initial default should favor safety over +aggressive deletion. + +## Restore priority order + +When the phone is freshly formatted or in a degraded state, restore in this +priority order: + +1. connection and root verification +2. security scripts and boot persistence +3. canonical hosts and DNS protections +4. launcher enforcement and companion app +5. APK reinstall support +6. selected app data +7. media and user files + +## Backup and restore policy classification + +Each manifest entry should specify how it may be restored. + +Suggested fields: + +- `name` +- `kind` (`apk`, `app_data`, `media`, `security_state`) +- `backup_paths` +- `restore_policy` (`safe_restore`, `manual_only`, `backup_only`) +- `requires_root` +- `requires_version_match` +- `integrity_check` +- `contains_secrets` + +The implementation must never silently restore unsupported or risky payloads. +When restore safety is uncertain, it should back up the data and report it as +manual-only. + +### Conservative v1 restore policy + +For the initial implementation, app-data restore should default to the most +conservative stance: + +- no app-data entries should ship as `safe_restore` by default +- app-data items should default to `manual_only` unless a later design change + explicitly promotes a named package after validation +- APK restore, security-state restore, and configured media/file restore may + proceed according to their own manifest policies without implying that + private app-data restore is equally safe + +This keeps v1 focused on reliable security recovery and host-side backups while +avoiding premature promises about rooted Android app-data portability. + +### Canonical manifest examples + +The manifest should include entries with a concrete shell-native shape. For +example: + +```bash +APK_ITEMS=( + "com.qqlabs.minimalistlauncher|safe_restore|yes|yes" +) + +APP_DATA_ITEMS=( + "com.beemdevelopment.aegis|/data/data/com.beemdevelopment.aegis|manual_only|yes|yes" +) + +MEDIA_ITEMS=( + "photos|/sdcard/DCIM|safe_restore|no|no" +) +``` + +Where each pipe-delimited record maps to: + +- name or package +- source path +- restore policy +- requires root +- requires integrity check + +The implementation may wrap these records with helper functions, but should +keep the manifest format simple enough to read and edit without custom tools. + +This ordering ensures the phone becomes safe again before broader recovery +work continues. + +## Documentation and discoverability requirements + +The final implementation must make the workflow obvious in at least two +places. + +### README requirements + +`phone_focus_mode/README.md` should document: + +- the visible wrapper location +- the three core user flows: + - normal day + - after format + - if something feels wrong +- examples for `auto`, `fresh-phone`, `doctor`, `backup`, and `monitor` +- backup scope and monitoring expectations + +### Script help-text requirements + +Both `scripts/run_all/run_phone.sh` and `phone_focus_mode/run_phone.sh` +should expose help text that includes the memorable usage guidance: + +- run the wrapper with no arguments for everyday backup and minor repair +- run `fresh-phone` after a format +- run `doctor` when the phone seems unhealthy or protections drifted + +This is not just documentation; it is part of the usability contract. + +## Automation safety rules + +Because the phone may appear repeatedly over USB or Wi-Fi, automation must be +conservative. + +Required safeguards: + +- single-instance lock to prevent overlapping runs +- cooldown window so repeated reconnects do not trigger backup storms +- clear separation between lightweight `auto` mode and heavier full-backup + behavior +- retry and backoff rules for transient ADB failures +- no automatic `fresh-phone` restore without explicit user intent + +## Testing and verification expectations + +The implementation phase should follow strict shell hygiene and repository +quality rules. + +- use `set -euo pipefail` +- prefer reusable functions over repeated ADB snippets +- validate parameters and environment clearly +- keep destructive operations explicit and well-logged +- add tests where practical for shell logic or parser behavior +- run `pre-commit run --files ` before claiming completion + +Verification must include: + +- shell syntax validation +- targeted script execution in safe modes +- README/help-text verification against the approved user flows +- evidence that backups and monitoring output are actually produced + +## Constraints and non-goals + +The design deliberately does not promise impossible guarantees. + +- It cannot make a rooted phone impossible to tamper with locally. +- It cannot safely restore every app’s private data without app-specific risk. +- It should prefer explicit warnings over pretending unsupported restores are + safe. + +The goal is a robust, repeatable, operator-friendly recovery and monitoring +system, not an infallible anti-root fortress. + +## Open implementation notes + +- Reuse code patterns already present in `linux_configuration/scripts/utils/` + and `python_pkg/screen_locker/_phone_verification.py` where they help with + ADB detection and wireless reconnection. +- Keep the wrapper stable even if the internal phone implementation evolves. +- Preserve the existing `deploy.sh` value rather than rewriting it from + scratch. +- Make backup scope declarative so expanding or narrowing coverage does not + require editing core shell control flow. diff --git a/phone_focus_mode/README.md b/phone_focus_mode/README.md index 2a4320c..e6cd57d 100644 --- a/phone_focus_mode/README.md +++ b/phone_focus_mode/README.md @@ -1,186 +1,131 @@ -# Phone Focus Mode +## Phone focus mode -Location-based app restriction for a rooted Android phone using wireless ADB. +Rooted-Android hardening + recovery workflow for daily backup/monitoring and +post-format recovery. -When within ~500m of home: only whitelisted productive apps remain usable. -When outside that radius: all apps work normally. +The visible entrypoint is: + +```bash +./scripts/run_all/run_phone.sh +``` + +That wrapper forwards to `phone_focus_mode/run_phone.sh`, which orchestrates +backup, monitoring, drift repair, and full recovery. + +## Quick usage + +### Normal day + +```bash +./scripts/run_all/run_phone.sh +``` + +This runs `auto` mode: + +- verifies and selects one device (USB or paired wireless ADB) +- checks format indicators first +- if phone appears wiped: prints warning + suggests `fresh-phone`, then exits +- otherwise collects monitoring snapshot, runs incremental backup, applies only + low-risk minor repairs, prints summary + +`auto` never restores APK/media and never re-deploys. + +### After a factory reset + +```bash +./scripts/run_all/run_phone.sh fresh-phone +``` + +This mode: + +- verifies prerequisites (ADB auth, root, Magisk runtime) +- takes pre-change snapshot +- restores security stack by delegating to `deploy.sh` +- restores safe APK/media backup items +- takes post-restore snapshot and prints required manual follow-up steps + +### If something looks wrong + +```bash +./scripts/run_all/run_phone.sh doctor +``` + +This mode: + +- runs monitoring checks +- repairs common drift (daemon restarts, hosts file re-push) +- re-runs deployment only when boot persistence is missing +- avoids broad data restore actions + +### Other modes + +```bash +./scripts/run_all/run_phone.sh backup +./scripts/run_all/run_phone.sh monitor +./scripts/run_all/run_phone.sh --help +``` + +## Device targeting + +Both the wrapper and `deploy.sh` support explicit device selection: + +```bash +ADB_SERIAL= ./scripts/run_all/run_phone.sh auto +ADB_SERIAL= bash phone_focus_mode/deploy.sh --status +``` + +`deploy.sh` still supports the existing phone-IP flow: + +```bash +bash phone_focus_mode/deploy.sh 192.168.1.42 --status +``` ## Requirements -- Rooted phone with **Magisk** installed -- Wireless ADB enabled (`Settings → Developer options → Wireless debugging`) -- `adb` installed on your PC (`sudo apt install adb` on Debian/Ubuntu) -- GPS/Location enabled on the phone +- rooted phone with Magisk installed +- USB debugging enabled and authorized (or paired wireless ADB) +- `adb` available on PC (`sudo pacman -S android-tools` on Arch Linux) +- location services enabled on phone -## Setup (first time) +## Setup essentials -### 1. Find your home coordinates +1. Set home coordinates in `phone_focus_mode/config_secrets.sh`. +2. Optionally tune whitelist and behavior in `phone_focus_mode/config.sh`. +3. Perform initial deploy: -Open Google Maps, right-click your apartment → copy the coordinates shown. + ```bash + bash phone_focus_mode/deploy.sh + ``` -### 2. Edit `config_secrets.sh` +## Systemd automation (PC user service) -```sh -HOME_LAT="-48.876667" # your latitude -HOME_LON="-123.393333" # your longitude -``` - -### 3. (Optional) Adjust the whitelist in `config.sh` - -To find the exact package name of any app: +Install timer-based periodic runs: ```bash -./deploy.sh --find-pkg stronglift -./deploy.sh --find-pkg anki -./deploy.sh --find-pkg pomodoro +bash phone_focus_mode/systemd/install_pc_phone_automation.sh ``` -Then add the correct package name to `WHITELIST` in `config.sh`. +This installs user units under `~/.config/systemd/user/`: -### 4. Deploy +- `phone-auto-sync.service` +- `phone-auto-sync.timer` (every 30 minutes, persistent) -```bash -chmod +x deploy.sh -./deploy.sh 192.168.1.42 # replace with your phone's IP -``` +## Relevant files -This: +| File | Purpose | +| ------------------------------------- | ------------------------------------------ | +| `scripts/run_all/run_phone.sh` | Thin, visible wrapper for daily use | +| `phone_focus_mode/run_phone.sh` | Main orchestration logic | +| `phone_focus_mode/lib/adb_common.sh` | ADB selection, locking, identity helpers | +| `phone_focus_mode/lib/backup.sh` | Incremental backup logic | +| `phone_focus_mode/lib/monitor.sh` | Security/health checks and reports | +| `phone_focus_mode/lib/restore.sh` | Safe restore helpers used by `fresh-phone` | +| `phone_focus_mode/deploy.sh` | Security-stack deployment primitive | +| `phone_focus_mode/backup_manifest.sh` | Declarative backup/restore scope | -1. Pushes all scripts to `/data/local/tmp/focus_mode/` on the device -2. Installs a Magisk `service.d` script so the daemon auto-starts on boot -3. Starts the daemon immediately +## Notes -## Usage - -```bash -./deploy.sh --status # Current mode, location, distance from home -./deploy.sh --log # View recent daemon log -./deploy.sh --list # List all apps + whitelist status -./deploy.sh --enable # Force focus mode ON (for testing) -./deploy.sh --disable # Force focus mode OFF -./deploy.sh --stop # Stop daemon entirely (restores all apps) -./deploy.sh --start # Start daemon -./deploy.sh --restart # Restart daemon (picks up config changes) -./deploy.sh --pull-log # Download log file to your PC -``` - -## How it works - -``` -Every 60 seconds: - get_location() ─── dumpsys location ──► lat,lon - │ - ▼ - calc_distance() ─── Haversine formula ──► meters - │ - ├── within radius? ──► enable_focus_mode() - │ pm disable-user all non-whitelisted apps - │ record which apps were disabled - │ - └── outside radius? ──► disable_focus_mode() - pm enable each app in the disabled list -``` - -**Hysteresis:** 50m buffer prevents rapid toggling at the boundary. You must travel -`radius - 50m` inward to trigger lock, and `radius + 50m` outward to unlock. - -**Fail-safe:** If location is unavailable for 5 consecutive checks (~5 minutes), -focus mode is automatically disabled so you can't be locked out. - -**State persistence:** The daemon records exactly which apps _it_ disabled -(in `/data/local/tmp/focus_mode/disabled_by_focus.txt`), so it never accidentally -re-enables apps that were already disabled by the user before focus mode ran. - -## On-device control (without PC) - -From a root terminal app (e.g. Termux + tsu): - -```sh -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 | -| `hosts_enforcer.sh` | Bind-mounts `hosts.canonical` over `/system/etc/hosts` | -| `magisk_service.sh` | Magisk boot hook → auto-starts both daemons | -| `deploy.sh` | PC-side ADB deployment and control script | - -## Hosts hardening - -A second daemon, `hosts_enforcer.sh`, locks the phone's `/system/etc/hosts` -to the same blocklist installed by `linux_configuration/hosts/install.sh` -on the PC. Three layers: - -1. Canonical copy at `/data/adb/focus_mode/hosts.canonical` is `chattr +i`. -2. It is bind-mounted read-only over `/system/etc/hosts` at boot. -3. A watchdog verifies a sha256 every 15 seconds and restores on mismatch. - -This blocks the common `echo > /etc/hosts` one-liner from a terminal app. -It is NOT a guarantee against a determined root user on the device itself — -a real "impossible without USB" gate would require removing `su` access, -which would break the rest of this system. The watchdog at least ensures -tampering is logged and reverted within ~15s. - -Status and logs: - -```bash -./deploy.sh --hosts-status -./deploy.sh --hosts-log -``` - -## Periodic rescan / Play Store - -The focus daemon now **re-scans every tick** (not just on first entry). If -you re-enable an app via Play Store or `pm enable`, it gets re-disabled -within `CHECK_INTERVAL_FOCUS` seconds. `com.android.vending` (Play Store), -`com.*.packageinstaller`, and popular terminal apps are also uninstalled -`--user 0` in focus mode to close the usual bypass paths. Google Play -Services (`com.google.android.gms`) is left alone so banking apps work. - -## Updating - -After editing `config.sh` (e.g. changing whitelist): - -```bash -./deploy.sh # re-pushes all files -# or just the config: -adb push config.sh /data/local/tmp/focus_mode/config.sh -./deploy.sh --restart -``` - -## Troubleshooting - -**Location always unavailable:** - -- Enable GPS and network location on the phone -- Open Google Maps once to warm up the GPS provider -- The daemon logs every attempt; check with `--log` - -**App won't disable:** - -- Some system apps can't be disabled even as root; they're silently skipped -- Check log for "Failed to disable" warnings - -**Daemon not starting on boot:** - -- Verify Magisk is installed and `service.d` is supported -- Check `/data/adb/service.d/99-focus-mode.sh` exists and is executable -- Some Magisk versions use `/data/adb/post-fs-data.d/` instead; try both - -**Wrong package name in whitelist:** - -- Use `./deploy.sh --find-pkg ` to find the exact package name -- Package names are case-sensitive +- Backup scope and restore policies live in `phone_focus_mode/backup_manifest.sh`. +- Sensitive coordinates should stay in `config_secrets.sh` and out of version + control. +- On-device direct control remains available via `focus_ctl.sh`. diff --git a/phone_focus_mode/backup_manifest.sh b/phone_focus_mode/backup_manifest.sh new file mode 100755 index 0000000..4d1c9db --- /dev/null +++ b/phone_focus_mode/backup_manifest.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# backup_manifest.sh — Declarative backup and restore scope definition. +# Source this file; do not execute directly. +# Fields: name|source_path|restore_policy|requires_root|integrity_check +# +# restore_policy values: +# safe_restore — may be restored automatically +# manual_only — backed up but never restored without operator action +# backup_only — backed up but restore is not yet implemented + +set -euo pipefail + +FORMAT_DETECTION_MIN_MISSING=2 + +PHONE_BACKUP_ROOT="${PHONE_BACKUP_ROOT:-${HOME}/phone_backups}" + +APK_ITEMS=( + "com.qqlabs.minimalistlauncher|safe_restore|no|yes" + "com.kuhy.focusstatus|safe_restore|no|yes" +) + +APP_DATA_ITEMS=( + "com.beemdevelopment.aegis|/data/data/com.beemdevelopment.aegis|manual_only|yes|yes" +) + +MEDIA_ITEMS=( + "photos|/sdcard/DCIM|safe_restore|no|no" + "downloads|/sdcard/Download|safe_restore|no|no" + "documents|/sdcard/Documents|safe_restore|no|no" +) + +SECURITY_STATE_FILES=( + "/data/adb/service.d/99-focus-mode.sh" + "/data/local/tmp/focus_mode/hosts.canonical" + "/data/local/tmp/focus_mode/focus_state" +) + +FORMAT_INDICATORS=( + "Magisk boot script|test -f /data/adb/service.d/99-focus-mode.sh" + "Focus mode data dir|test -d /data/local/tmp/focus_mode" + "Focus mode state dir|test -d /data/local/tmp/focus_mode" + "Focus companion app installed|pm list packages -e com.kuhy.focusstatus | grep -q com.kuhy.focusstatus" +) + +BATTERY_WARN_BELOW=20 +STORAGE_WARN_BELOW_MB=500 +COOLDOWN_AUTO_SECS=300 +HISTORY_KEEP_DAYS=30 + +backup_manifest_validate() { + local apk_entry="" + local app_data_entry="" + local indicator_entry="" + local media_entry="" + local security_file="" + + : "${PHONE_BACKUP_ROOT}" + : "${FORMAT_DETECTION_MIN_MISSING}" + : "${BATTERY_WARN_BELOW}" + : "${STORAGE_WARN_BELOW_MB}" + : "${COOLDOWN_AUTO_SECS}" + : "${HISTORY_KEEP_DAYS}" + + for apk_entry in "${APK_ITEMS[@]}"; do + [[ -n "${apk_entry}" ]] || return 1 + done + + for app_data_entry in "${APP_DATA_ITEMS[@]}"; do + [[ -n "${app_data_entry}" ]] || return 1 + done + + for media_entry in "${MEDIA_ITEMS[@]}"; do + [[ -n "${media_entry}" ]] || return 1 + done + + for security_file in "${SECURITY_STATE_FILES[@]}"; do + [[ -n "${security_file}" ]] || return 1 + done + + for indicator_entry in "${FORMAT_INDICATORS[@]}"; do + [[ -n "${indicator_entry}" ]] || return 1 + done +} + +backup_manifest_validate diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index 905cd24..c0d7914 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -8,7 +8,13 @@ # ============================================================ # --- Home location (loaded from config_secrets.sh, not tracked by git) --- -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT_DIR="${FOCUS_MODE_SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}" +# If config.sh is sourced from an external wrapper (e.g. Magisk service.d), +# $0 points to the wrapper path rather than this file's directory. Fall back +# to the canonical runtime location if config_secrets is not alongside $0. +if [ ! -f "$SCRIPT_DIR/config_secrets.sh" ] && [ -f "/data/local/tmp/focus_mode/config_secrets.sh" ]; then + SCRIPT_DIR="/data/local/tmp/focus_mode" +fi . "$SCRIPT_DIR/config_secrets.sh" # --- Radius in meters --- @@ -39,12 +45,27 @@ export STATUS_FILE="$STATE_DIR/status.json" # re-check. focus_daemon.sh polls for it and skips the remainder of its sleep. export RECHECK_TRIGGER="$STATE_DIR/trigger_recheck" +# --- Boot-time autostart safety gate --- +# Critical safety default: do NOT auto-start focus daemons at boot unless +# explicitly enabled. This avoids device instability during early boot on +# vendor ROMs after resets/updates. +# Late-auto mode: boot stack starts only after Android reports boot complete. +export FOCUS_BOOT_AUTOSTART=1 +# Extra grace period after sys.boot_completed. Keep at or below 10 seconds. +export FOCUS_BOOT_DELAY_SECONDS=10 +# Hard timeout while waiting for sys.boot_completed in Magisk service script. +export FOCUS_BOOT_WAIT_MAX_SECONDS=180 +# Emergency kill switch file: if this marker exists on phone, boot autostart +# is skipped entirely for this boot. Create with: +# adb shell su -c 'touch /data/local/tmp/focus_mode/disable_boot_autostart' +export FOCUS_BOOT_EMERGENCY_DISABLE_FILE="$STATE_DIR/disable_boot_autostart" + # --- 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_CANONICAL="$STATE_DIR/hosts.canonical" export HOSTS_TARGET="/system/etc/hosts" -export HOSTS_SHA_FILE="/data/adb/focus_mode/hosts.sha256" +export HOSTS_SHA_FILE="$STATE_DIR/hosts.sha256" export HOSTS_CHECK_INTERVAL=15 export HOSTS_LOG="$STATE_DIR/hosts_enforcer.log" @@ -102,17 +123,54 @@ export DNS_DOH_IPV6=" 2a10:50c0::ad1:ff 2a10:50c0::ad2:ff " +# Additional content hosts to block at the firewall layer. This is a fallback +# for ROMs where /etc/hosts cannot be mounted (read-only partitions with no +# hosts inode). dns_enforcer.sh resolves these hostnames periodically and +# rejects traffic to their current endpoints on ports 80/443. +export DNS_BLOCK_HOSTS=" +youtube.com +www.youtube.com +m.youtube.com +youtu.be +youtubei.googleapis.com +www.youtube-nocookie.com +googlevideo.com +ytimg.com +" + +# Block network for selected distraction/system apps at firewall level by UID. +# This avoids pm disable/uninstall on system packages (which can destabilize +# boot on some vendor ROMs) while still making the apps effectively unusable. +# +# DNS_BLOCK_PACKAGES_ALWAYS: blocked at all times. Use for hard-distraction +# apps that should never have web access (YouTube app, YouTube Music, the +# stock browser). Hosts-file blocking handles their *content*; the UID rule +# keeps them from using DoH/QUIC fallbacks. +# DNS_BLOCK_PACKAGES_FOCUS_ONLY: blocked only while focus mode is active +# (current_mode.txt = focus). Use for apps that have legitimate use outside +# focus mode (Play Store for installing apps you want, package installer). +export DNS_BLOCK_PACKAGES_ALWAYS=" +com.google.android.youtube +com.google.android.apps.youtube.music +com.android.chrome +" +export DNS_BLOCK_PACKAGES_FOCUS_ONLY=" +com.android.vending +" +# Backwards-compat: code paths still referencing DNS_BLOCK_PACKAGES treat it +# as the always-blocked list. +export DNS_BLOCK_PACKAGES="$DNS_BLOCK_PACKAGES_ALWAYS" # --- 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" +export LAUNCHER_APK="$STATE_DIR/minimalist_launcher.apk" +export LAUNCHER_SHA_FILE="$STATE_DIR/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" +export LAUNCHER_ACTIVITY_FILE="$STATE_DIR/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 `. @@ -125,6 +183,11 @@ com.google.android.apps.nexuslauncher " export LAUNCHER_CHECK_INTERVAL=15 export LAUNCHER_LOG="$STATE_DIR/launcher_enforcer.log" +# Boot-time launcher enforcement is intentionally opt-in. Starting it from +# Magisk service.d can strand the phone on a broken HOME configuration if the +# snapshot is stale or the launcher update changed components. Keep this off by +# default and only enable it after verifying the launcher snapshot is healthy. +export LAUNCHER_BOOT_AUTOSTART=0 # ============================================================ # WHITELISTED APPS @@ -183,6 +246,9 @@ com.google.android.gm ch.protonmail.android com.microsoft.teams +# --- App installation alternative (keep visible in focus mode) --- +com.aurora.store + # --- Manga reader --- eu.kanade.tachiyomi.sy @@ -222,38 +288,20 @@ com.xiaomi.smarthome # ============================================================ export BLOCKED_SYSTEM_APPS=" -# --- Browsers --- -com.android.chrome -com.chrome.beta -com.chrome.dev -com.chrome.canary -com.sec.android.app.sbrowser -com.opera.browser -com.opera.mini.native -com.brave.browser -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 +# *** INTENTIONALLY EMPTY *** +# +# pm disable-user state persists across reboots. Android always kills daemon +# processes with SIGKILL during shutdown, bypassing the shell cleanup trap. +# Any system package left disabled across a reboot can trigger MTK bootloop +# protection → recovery → factory wipe (confirmed: caused 3 wipes on BL9000). +# +# System apps (Chrome, YouTube, Play Store, etc.) are enforced via +# DNS+iptables in dns_enforcer.sh instead — that layer is stateless and +# requires no cleanup on reboot. +# +# Only user-installed 3rd-party apps (pm list packages -3) are safe for +# pm disable-user because the MTK bootloop trigger only fires on missing or +# disabled ROM/system components, not on user-installed packages. " # --- System / essential packages that must NEVER be disabled --- diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index 1cba0eb..de5a734 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -18,9 +18,26 @@ PHONE_IP="${1:-}" ACTION="${2:---deploy}" REMOTE_DIR="/data/local/tmp/focus_mode" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ADB_TARGET=() + +# Support orchestrator-driven device targeting via ADB_SERIAL. +# When ADB_SERIAL is set, deploy.sh uses that target directly and preserves +# the existing PHONE_IP workflow when ADB_SERIAL is unset. +if [[ -n "${ADB_SERIAL:-}" ]]; then + ADB_TARGET=(-s "${ADB_SERIAL}") + if [[ -z "${PHONE_IP}" || "${PHONE_IP}" == --* ]]; then + ACTION="${PHONE_IP:---deploy}" + PHONE_IP="" + fi +fi + +adb_cmd() { + adb "${ADB_TARGET[@]}" "$@" +} usage() { echo "Usage: $0 [action]" + echo " or: ADB_SERIAL= $0 [action]" echo "" echo "Actions:" echo " (none) Full deploy" @@ -39,6 +56,7 @@ usage() { 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 " --install-aurora Download & install Aurora Store (open-source Play Store alt)" echo "" echo "Examples:" echo " $0 192.168.1.42" @@ -74,6 +92,10 @@ check_coords() { } check_ip() { + if [[ -n "${ADB_SERIAL:-}" ]]; then + return 0 + fi + if [ -z "$PHONE_IP" ]; then echo "ERROR: Phone IP not provided." echo "" @@ -82,6 +104,17 @@ check_ip() { } connect_adb() { + if [[ -n "${ADB_SERIAL:-}" ]]; then + if ! adb devices | awk 'NR>1 && $2=="device"{print $1}' | grep -Fxq "${ADB_SERIAL}"; then + echo "ERROR: ADB_SERIAL '${ADB_SERIAL}' is not connected." + echo "Connect device via USB or pair wireless ADB first." + exit 1 + fi + ADB_TARGET=(-s "${ADB_SERIAL}") + echo "Using ADB_SERIAL target: ${ADB_SERIAL}" + return 0 + fi + echo "Connecting to $PHONE_IP:5555 ..." adb connect "$PHONE_IP:5555" sleep 1 @@ -90,6 +123,7 @@ connect_adb() { echo "Make sure wireless ADB is enabled and the phone is reachable." exit 1 fi + ADB_TARGET=(-s "$PHONE_IP:5555") echo "Connected." } @@ -98,7 +132,64 @@ connect_adb() { # 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 --mount-master -c "$1" + local command_text="$1" + + printf '%s\n' "$command_text" | adb_cmd shell su --mount-master -c "sh -s" +} + +compute_file_hash() { + local path="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + return 0 + fi + + md5sum "$path" | awk '{print $1}' +} + +# ============================================================ +# AURORA STORE +# ============================================================ +# Aurora Store is a free, open-source Play Store client that lets you +# install apps anonymously without a Google account. We use it so that +# Play Store (com.android.vending) can be network-blocked during focus +# mode without preventing legitimate app installs at other times. +# +# Official release APK is hosted on the Aurora OSS GitLab. We pin a +# known version tag and verify the hash on every install. +AURORA_VERSION="4.8.1" +AURORA_APK_URL="https://gitlab.com/-/project/6922885/uploads/2ee95ec85244b45cc860b63ec7a10ad6/AuroraStore-4.8.1.apk" +AURORA_PACKAGE="com.aurora.store" + +do_install_aurora() { + connect_adb + + # Check if already installed. + if adb_cmd shell pm list packages 2>/dev/null | grep -qx "package:${AURORA_PACKAGE}"; then + echo "Aurora Store is already installed (${AURORA_PACKAGE})." + return 0 + fi + + echo "Downloading Aurora Store ${AURORA_VERSION}..." + local tmp_apk + tmp_apk="$(mktemp --suffix=.apk)" + if ! curl -fsSL --retry 3 -o "$tmp_apk" "$AURORA_APK_URL"; then + rm -f "$tmp_apk" + echo "ERROR: Failed to download Aurora Store from $AURORA_APK_URL" + echo "Manual download: https://auroraoss.com/" + return 1 + fi + + echo "Installing Aurora Store..." + if adb_cmd install -r "$tmp_apk"; then + echo "Aurora Store ${AURORA_VERSION} installed successfully." + echo "Open Aurora Store on the phone, choose 'Anonymous' login, then install apps normally." + else + echo "ERROR: adb install failed. You can side-load manually:" + echo " adb install ${tmp_apk}" + fi + rm -f "$tmp_apk" } # ============================================================ @@ -122,18 +213,18 @@ do_deploy() { 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 /data/adb/focus_mode" + adb_cmd shell "mkdir -p /data/local/tmp/focus_stage" + adb_root "mkdir -p $REMOTE_DIR /data/adb/service.d" adb_root "chmod 777 /data/local/tmp/focus_stage" 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" + adb_cmd push "$SCRIPT_DIR/config.sh" "/data/local/tmp/focus_stage/config.sh" + adb_cmd push "$SCRIPT_DIR/focus_daemon.sh" "/data/local/tmp/focus_stage/focus_daemon.sh" + adb_cmd push "$SCRIPT_DIR/focus_ctl.sh" "/data/local/tmp/focus_stage/focus_ctl.sh" + adb_cmd push "$SCRIPT_DIR/hosts_enforcer.sh" "/data/local/tmp/focus_stage/hosts_enforcer.sh" + adb_cmd push "$SCRIPT_DIR/dns_enforcer.sh" "/data/local/tmp/focus_stage/dns_enforcer.sh" + adb_cmd push "$SCRIPT_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh" + adb_cmd 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. @@ -142,12 +233,18 @@ do_deploy() { chmod +x "$HOSTS_GENERATOR" 2>/dev/null || true echo " Generating canonical hosts file..." HOSTS_TMP="$(mktemp)" + HOSTS_SHA_TMP="$(mktemp)" if bash "$HOSTS_GENERATOR" "$HOSTS_TMP"; then + hosts_hash="$(compute_file_hash "$HOSTS_TMP")" + printf '%s\n' "$hosts_hash" > "$HOSTS_SHA_TMP" echo " Uploading canonical hosts ($(wc -l < "$HOSTS_TMP") lines)..." - adb -s "$PHONE_IP:5555" push "$HOSTS_TMP" "/data/local/tmp/focus_stage/hosts.canonical" + adb_cmd push "$HOSTS_TMP" "/data/local/tmp/focus_stage/hosts.canonical" + adb_cmd push "$HOSTS_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256" rm -f "$HOSTS_TMP" + rm -f "$HOSTS_SHA_TMP" else rm -f "$HOSTS_TMP" + rm -f "$HOSTS_SHA_TMP" echo " WARNING: failed to generate hosts file - skipping hosts enforcement" fi else @@ -159,7 +256,7 @@ do_deploy() { echo " config_secrets.sh already exists on phone - skipping (preserving real coords)" else echo " Pushing config_secrets.sh (first install)..." - adb -s "$PHONE_IP:5555" push "$SCRIPT_DIR/config_secrets.sh" "/data/local/tmp/focus_stage/config_secrets.sh" + adb_cmd push "$SCRIPT_DIR/config_secrets.sh" "/data/local/tmp/focus_stage/config_secrets.sh" adb_root "cp /data/local/tmp/focus_stage/config_secrets.sh $REMOTE_DIR/config_secrets.sh" fi @@ -170,55 +267,111 @@ do_deploy() { 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" + if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$SCRIPT_DIR/config.sh"; then + adb_root "cp /data/local/tmp/focus_stage/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh" + else + adb_root "rm -f /data/adb/service.d/99-focus-mode.sh /data/adb/service.d/99-focus-mode.sh.disabled" + fi # 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 + if adb_cmd 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" + adb_root "chattr -i $REMOTE_DIR/hosts.canonical 2>/dev/null; true" + adb_root "cp /data/local/tmp/focus_stage/hosts.canonical $REMOTE_DIR/hosts.canonical" + adb_root "chmod 644 $REMOTE_DIR/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" + adb_root "chattr -i $REMOTE_DIR/hosts.sha256 2>/dev/null; true" + adb_root "cp /data/local/tmp/focus_stage/hosts.sha256 $REMOTE_DIR/hosts.sha256" + adb_root "chmod 644 $REMOTE_DIR/hosts.sha256" + adb_root "chattr +i $REMOTE_DIR/hosts.canonical 2>/dev/null; true" + adb_root "chattr +i $REMOTE_DIR/hosts.sha256 2>/dev/null; true" + + # ---- Magisk Systemless Hosts module (REQUIRED) ---- + # This module magic-mounts /data/adb/modules/hosts/system/etc/hosts + # as /system/etc/hosts at boot — the only way to create that file on + # this ROM's hardware-read-only system partition. + # + # The module must be ENABLED in the Magisk app by the user (one-time, + # after each factory reset). We CANNOT enable it programmatically. + # Without it, no app-level hosts blocking is possible, so we STOP here + # and require user action before the deploy can proceed. + local magisk_hosts_ok=0 + if adb_root "test -d /data/adb/modules/hosts" 2>/dev/null; then + if adb_root "test ! -f /data/adb/modules/hosts/disable -a ! -f /data/adb/modules/hosts/remove" 2>/dev/null; then + magisk_hosts_ok=1 + fi + fi + + if [[ "$magisk_hosts_ok" -eq 0 ]]; then + echo "" + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ ACTION REQUIRED — Deploy cannot continue ║" + echo "╠══════════════════════════════════════════════════════════════════╣" + echo "║ The Magisk 'Systemless Hosts' module is not enabled. ║" + echo "║ Without it, hosts-file blocking is impossible on this device ║" + echo "║ (the system partition is hardware read-only even with root). ║" + echo "║ ║" + echo "║ Steps to fix: ║" + echo "║ 1. Open the Magisk app on the phone ║" + echo "║ 2. Tap the Modules tab (puzzle-piece icon) ║" + echo "║ 3. Find 'Systemless Hosts' and toggle it ON ║" + echo "║ 4. Reboot the phone when prompted ║" + echo "║ 5. Re-run this deploy command ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo "" + exit 1 + fi + + adb_root "mkdir -p /data/adb/modules/hosts/system/etc" + adb_root "cp $REMOTE_DIR/hosts.canonical /data/adb/modules/hosts/system/etc/hosts" + adb_root "chmod 644 /data/adb/modules/hosts/system/etc/hosts" + echo " Magisk hosts module populated ($(adb_root "wc -l < /data/adb/modules/hosts/system/etc/hosts" 2>/dev/null | tr -d ' ') lines). Reboot to activate /system/etc/hosts." fi adb_root "rm -rf /data/local/tmp/focus_stage" 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" + if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$SCRIPT_DIR/config.sh"; then + adb_root "chmod 755 /data/adb/service.d/99-focus-mode.sh" + fi 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/7] Starting daemons..." # Stop existing daemons, then start fresh + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/focus_daemon.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/hosts_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/dns_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/launcher_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" 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 "for p in \$(pgrep -f '/data/local/tmp/focus_mode/focus_daemon.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/hosts_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/dns_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/launcher_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" + sleep 1 adb_root "rm -f $REMOTE_DIR/daemon.pid $REMOTE_DIR/hosts_enforcer.pid $REMOTE_DIR/dns_enforcer.pid $REMOTE_DIR/launcher_enforcer.pid" # Start hosts enforcer first so hosts are locked before user can react. # Use --mount-master so bind mounts propagate to the global namespace # (where app processes live). Without this, only our isolated `su` session # would see the bind-mounted hosts file. - if adb_root "test -f /data/adb/focus_mode/hosts.canonical" 2>/dev/null; then - adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/hosts_enforcer.sh /dev/null 2>/dev/null &' + if adb_root "test -f $REMOTE_DIR/hosts.canonical" 2>/dev/null; then + adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/hosts_enforcer.sh /dev/null 2>/dev/null &' fi # Start DNS enforcer (forces Private DNS off, blocks DoH/DoT). Always on. - adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/dns_enforcer.sh /dev/null 2>/dev/null &' + adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/dns_enforcer.sh /dev/null 2>/dev/null &' # Start launcher enforcer only if a snapshot APK exists. If not, warn the # user to install Minimalist Phone + run --snapshot-launcher first. - if adb_root "test -f /data/adb/focus_mode/minimalist_launcher.apk" 2>/dev/null; then - adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/launcher_enforcer.sh /dev/null 2>/dev/null &' + if adb_root "test -f $REMOTE_DIR/minimalist_launcher.apk" 2>/dev/null; then + adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/launcher_enforcer.sh /dev/null 2>/dev/null &' else echo " NOTE: launcher snapshot missing. Install Minimalist Phone via Aurora Store, then run:" echo " $0 $PHONE_IP --snapshot-launcher" fi - adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/focus_daemon.sh /dev/null 2>/dev/null &' + adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/focus_daemon.sh /dev/null 2>/dev/null &' sleep 4 # ---- Companion status notification app ---- @@ -232,16 +385,16 @@ do_deploy() { fi if [ -f "$APK" ]; then echo " Installing APK..." - adb -s "$PHONE_IP:5555" install -r "$APK" >/dev/null || true + adb_cmd 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 + adb_cmd 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)" + APP_UID="$(adb_cmd 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 + adb_cmd 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 + adb_cmd 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" @@ -254,7 +407,9 @@ do_deploy() { echo "Checking status..." adb_root "sh $REMOTE_DIR/focus_ctl.sh status" echo "" - echo "The daemon will auto-start on every boot via Magisk service.d." + echo "Boot autostart is disabled by default (FOCUS_BOOT_AUTOSTART=0)." + echo "No Magisk service.d hook is installed unless FOCUS_BOOT_AUTOSTART=1 in config.sh." + echo "Launcher enforcement does not auto-start on boot unless LAUNCHER_BOOT_AUTOSTART=1 is set in config.sh." echo "" echo "Useful commands:" echo " $0 $PHONE_IP --status # Check mode and location" @@ -262,6 +417,7 @@ do_deploy() { echo " $0 $PHONE_IP --list # See all apps and whitelist status" echo " $0 $PHONE_IP --enable # Force focus mode on for testing" echo " $0 $PHONE_IP --disable # Force focus mode off" + echo " $0 $PHONE_IP --install-aurora # Install Aurora Store (Play Store alternative)" } # ============================================================ @@ -276,7 +432,7 @@ do_control() { do_pull_log() { connect_adb echo "Downloading log..." - adb -s "$PHONE_IP:5555" pull "$REMOTE_DIR/focus_mode.log" "./focus_mode_$(date +%Y%m%d_%H%M%S).log" + adb_cmd pull "$REMOTE_DIR/focus_mode.log" "./focus_mode_$(date +%Y%m%d_%H%M%S).log" echo "Done." } @@ -288,7 +444,7 @@ do_find_pkg() { fi connect_adb echo "Packages matching '$filter':" - adb -s "$PHONE_IP:5555" shell pm list packages | grep -i "$filter" | sed 's/^package:/ /' + adb_cmd shell pm list packages | grep -i "$filter" | sed 's/^package:/ /' } do_snapshot_launcher() { @@ -305,7 +461,7 @@ do_snapshot_launcher() { # Kill any previous enforcer so it picks up the new snapshot. adb_root "kill \$(cat $REMOTE_DIR/launcher_enforcer.pid 2>/dev/null) 2>/dev/null; true" adb_root "rm -f $REMOTE_DIR/launcher_enforcer.pid" - adb -s "$PHONE_IP:5555" shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/launcher_enforcer.sh /dev/null 2>/dev/null &' + adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/launcher_enforcer.sh /dev/null 2>/dev/null &' sleep 3 adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-status" } @@ -333,5 +489,6 @@ case "$ACTION" in --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 ;; + --install-aurora) do_install_aurora ;; *) echo "Unknown action: $ACTION"; usage ;; esac diff --git a/phone_focus_mode/dns_enforcer.sh b/phone_focus_mode/dns_enforcer.sh index 4d6494b..1235bf0 100755 --- a/phone_focus_mode/dns_enforcer.sh +++ b/phone_focus_mode/dns_enforcer.sh @@ -25,16 +25,119 @@ # leaves tamper logs. # ============================================================ -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT_DIR="${FOCUS_MODE_SCRIPT_DIR:-$(cd "$(dirname "$0")" && pwd)}" # shellcheck source=config.sh . "$SCRIPT_DIR/config.sh" PIDFILE="$STATE_DIR/dns_enforcer.pid" +DNS_BLOCK_IPV4_FILE="$STATE_DIR/dns_block_ipv4.txt" +DNS_BLOCK_IPV6_FILE="$STATE_DIR/dns_block_ipv6.txt" +DNS_BLOCK_UID_FILE="$STATE_DIR/dns_block_uids.txt" mkdir -p "$STATE_DIR" touch "$DNS_LOG" chmod 666 "$DNS_LOG" 2>/dev/null || true +append_unique_line() { + local file="$1" + local value="$2" + + [ -z "$value" ] && return 0 + [ -f "$file" ] || : > "$file" + + if ! grep -qxF "$value" "$file" 2>/dev/null; then + echo "$value" >> "$file" + fi +} + +extract_ping_ip() { + # Extract the host IP from the first line of ping output: + # Ping example.com (1.2.3.4): ... + # Ping example.com (2a00:...): ... + printf '%s\n' "$1" | sed -n 's/^[^(]*(\([^)]*\)).*/\1/p' | head -1 +} + +extract_package_uid() { + # Parse one line from `cmd package list packages -U`, e.g.: + # package:com.android.chrome uid:10153 + printf '%s\n' "$1" | sed -n 's/.* uid:\([0-9][0-9]*\).*/\1/p' | head -1 +} + +resolve_package_uid() { + local pkg="$1" + local line uid + + line="$(cmd package list packages -U 2>/dev/null | grep -E "^package:${pkg}( |$)" | head -1 || true)" + uid="$(extract_package_uid "$line")" + if echo "$uid" | grep -Eq '^[0-9]+$'; then + echo "$uid" + fi +} + +resolve_ipv4() { + local host="$1" + local line ip + + line="$(toybox ping -4 -c 1 -W 1 "$host" 2>/dev/null | head -1 || true)" + ip="$(extract_ping_ip "$line")" + if echo "$ip" | grep -Eq '^[0-9]+(\.[0-9]+){3}$'; then + echo "$ip" + fi +} + +resolve_ipv6() { + local host="$1" + local line ip + + line="$(toybox ping -6 -c 1 -W 1 "$host" 2>/dev/null | head -1 || true)" + ip="$(extract_ping_ip "$line")" + if echo "$ip" | grep -Eq '^[0-9A-Fa-f:]+$'; then + echo "$ip" + fi +} + +refresh_blocked_content_ips() { + : > "$DNS_BLOCK_IPV4_FILE" + : > "$DNS_BLOCK_IPV6_FILE" + + local host ip4 ip6 + for host in $DNS_BLOCK_HOSTS; do + [ -z "$host" ] && continue + [ "${host#\#}" != "$host" ] && continue + + ip4="$(resolve_ipv4 "$host")" + ip6="$(resolve_ipv6 "$host")" + + append_unique_line "$DNS_BLOCK_IPV4_FILE" "$ip4" + append_unique_line "$DNS_BLOCK_IPV6_FILE" "$ip6" + done +} + +refresh_blocked_app_uids() { + : > "$DNS_BLOCK_UID_FILE" + + local pkg uid + # Always-blocked packages (hard distractions: YouTube, Chrome, ...). + for pkg in $DNS_BLOCK_PACKAGES_ALWAYS; do + [ -z "$pkg" ] && continue + [ "${pkg#\#}" != "$pkg" ] && continue + + uid="$(resolve_package_uid "$pkg")" + append_unique_line "$DNS_BLOCK_UID_FILE" "$uid" + done + + # Focus-mode-only packages (Play Store etc. - usable outside focus mode). + if [ "$(cat "$MODE_FILE" 2>/dev/null)" = "focus" ]; then + for pkg in $DNS_BLOCK_PACKAGES_FOCUS_ONLY; do + [ -z "$pkg" ] && continue + [ "${pkg#\#}" != "$pkg" ] && continue + + uid="$(resolve_package_uid "$pkg")" + append_unique_line "$DNS_BLOCK_UID_FILE" "$uid" + done + fi +} + log() { local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" @@ -136,6 +239,36 @@ fill_chain_v4() { iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \ --reject-with tcp-reset 2>/dev/null || true done + + # Content-block fallback: reject HTTP/HTTPS to resolved endpoints of + # DNS_BLOCK_HOSTS. This is used on ROMs where hosts-file enforcement is + # impossible (no writable hosts inode on read-only partitions). + if [ -f "$DNS_BLOCK_IPV4_FILE" ]; then + while IFS= read -r ip; do + [ -z "$ip" ] && continue + iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 80 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + 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 + done < "$DNS_BLOCK_IPV4_FILE" + fi + + # App-level web block: block HTTP/HTTPS for selected package UIDs. + # Only ports 80 and 443 are blocked so DNS (port 53) and system services + # still work — the apps just can't load web content or stream video. + if [ -f "$DNS_BLOCK_UID_FILE" ]; then + while IFS= read -r uid; do + [ -z "$uid" ] && continue + iptables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p tcp --dport 80 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + iptables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p tcp --dport 443 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + iptables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p udp --dport 443 -j REJECT \ + --reject-with icmp-port-unreachable 2>/dev/null || true + done < "$DNS_BLOCK_UID_FILE" + fi } fill_chain_v6() { @@ -158,9 +291,36 @@ fill_chain_v6() { ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \ --reject-with tcp-reset 2>/dev/null || true done + + if [ -f "$DNS_BLOCK_IPV6_FILE" ]; then + while IFS= read -r ip; do + [ -z "$ip" ] && continue + ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 80 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + 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 + done < "$DNS_BLOCK_IPV6_FILE" + fi + + if [ -f "$DNS_BLOCK_UID_FILE" ]; then + while IFS= read -r uid; do + [ -z "$uid" ] && continue + ip6tables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p tcp --dport 80 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + ip6tables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p tcp --dport 443 -j REJECT \ + --reject-with tcp-reset 2>/dev/null || true + ip6tables -A "$DNS_IPT_CHAIN" -m owner --uid-owner "$uid" -p udp --dport 443 -j REJECT \ + --reject-with icmp6-port-unreachable 2>/dev/null || true + done < "$DNS_BLOCK_UID_FILE" + fi } enforce_iptables() { + refresh_blocked_content_ips + refresh_blocked_app_uids + if command -v iptables >/dev/null 2>&1; then ensure_chain iptables && fill_chain_v4 fi @@ -196,4 +356,6 @@ main() { done } -main "$@" +if [ "${FOCUS_MODE_DNS_ENFORCER_TESTING:-0}" != "1" ]; then + main "$@" +fi diff --git a/phone_focus_mode/focus_ctl.sh b/phone_focus_mode/focus_ctl.sh index 7105d71..a5357ff 100755 --- a/phone_focus_mode/focus_ctl.sh +++ b/phone_focus_mode/focus_ctl.sh @@ -14,6 +14,30 @@ SCRIPT_DIR="/data/local/tmp/focus_mode" PIDFILE="$STATE_DIR/daemon.pid" +recover_pidfile() { + local pidfile="$1" + local script_name="$2" + local pid + + if [ -f "$pidfile" ]; then + pid="$(cat "$pidfile" 2>/dev/null)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "$pid" + return 0 + fi + fi + + pid="$(pgrep -f "$script_name" 2>/dev/null | head -1)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "$pid" > "$pidfile" 2>/dev/null || true + chmod 666 "$pidfile" 2>/dev/null || true + echo "$pid" + return 0 + fi + + return 1 +} + # ---- Logging ---- log() { local ts @@ -54,13 +78,7 @@ usage() { # Helper to check if daemon is running daemon_pid() { - if [ -f "$PIDFILE" ]; then - local pid - pid="$(cat "$PIDFILE")" - if kill -0 "$pid" 2>/dev/null; then - echo "$pid" - fi - fi + recover_pidfile "$PIDFILE" "focus_daemon.sh" } cmd_start() { @@ -275,13 +293,7 @@ cmd_whitelist() { 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 + recover_pidfile "$HOSTS_PIDFILE" "hosts_enforcer.sh" } cmd_hosts_status() { @@ -368,13 +380,7 @@ cmd_hosts_log() { 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 + recover_pidfile "$DNS_PIDFILE" "dns_enforcer.sh" } cmd_dns_status() { @@ -461,13 +467,7 @@ 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 + recover_pidfile "$LAUNCHER_PIDFILE" "launcher_enforcer.sh" } cmd_launcher_snapshot() { diff --git a/phone_focus_mode/focus_daemon.sh b/phone_focus_mode/focus_daemon.sh index 1b81862..75e6ed9 100755 --- a/phone_focus_mode/focus_daemon.sh +++ b/phone_focus_mode/focus_daemon.sh @@ -96,7 +96,17 @@ init() { 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." + log "ERROR: Home coordinates not set! Edit config_secrets.sh first." + exit 1 + fi + + if ! echo "$HOME_LAT" | grep -Eq '^[-]?[0-9]+(\.[0-9]+)?$'; then + log "ERROR: HOME_LAT is invalid ('$HOME_LAT'). Expected decimal degrees in config_secrets.sh" + exit 1 + fi + + if ! echo "$HOME_LON" | grep -Eq '^[-]?[0-9]+(\.[0-9]+)?$'; then + log "ERROR: HOME_LON is invalid ('$HOME_LON'). Expected decimal degrees in config_secrets.sh" exit 1 fi @@ -193,27 +203,24 @@ enable_focus_mode() { done < "$tmp_pkgs" rm -f "$tmp_pkgs" - # 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" - [ "$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" + # Disable-for-user-0 any blocked system apps (Play Store, browsers, + # package installer UI, terminal apps). + # IMPORTANT: We intentionally use pm disable-user (NOT pm uninstall) here. + # pm uninstall -k --user 0 removes the package from Android's user-0 + # package registry. If the daemon is killed with SIGKILL during a reboot + # (bypassing the cleanup trap), those packages stay uninstalled across the + # reboot. Android's bootloop-protection (MTK and others) then detects + # missing critical system packages and triggers recovery / factory wipe. + # pm disable-user leaves the package registered but inactive, so the + # PackageManager scan at next boot succeeds and no wipe occurs. while IFS= read -r pkg; do [ -z "$pkg" ] && continue - 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 + if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then + grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null \ + || echo "$pkg" >> "$DISABLED_APPS_FILE" + newly_disabled=$((newly_disabled + 1)) fi done < "$blocked_sys" - rm -f "$user0_pkgs" CURRENT_MODE="focus" echo "focus" > "$MODE_FILE" @@ -235,15 +242,9 @@ disable_focus_mode() { local count=0 if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then - # Re-install system apps that were uninstalled for user - if [ -f "$STATE_DIR/uninstalled_sys.txt" ] && [ -s "$STATE_DIR/uninstalled_sys.txt" ]; then - while IFS= read -r pkg; do - [ -z "$pkg" ] && continue - pm install-existing --user 0 "$pkg" >/dev/null 2>&1 - done < "$STATE_DIR/uninstalled_sys.txt" - : > "$STATE_DIR/uninstalled_sys.txt" - fi - # Re-enable all disabled apps + # Re-enable all disabled apps (both 3rd-party and system apps). + # Both paths now use pm disable-user, so pm enable is the only + # restore command needed. while IFS= read -r pkg; do [ -z "$pkg" ] && continue pm enable "$pkg" >/dev/null 2>&1 && count=$((count + 1)) diff --git a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java index 21bddad..9233d1c 100644 --- a/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java +++ b/phone_focus_mode/focus_status_app/java/com/kuhy/focusstatus/StatusService.java @@ -19,7 +19,7 @@ import android.os.Looper; */ public final class StatusService extends Service { - private static final String CHANNEL_ID = "focus_status"; + private static final String CHANNEL_ID = "focus_status_persistent"; private static final int NOTIF_ID = 1042; private static final long REFRESH_MS = 5_000L; @@ -87,8 +87,8 @@ public final class StatusService extends Service { return; } NotificationChannel ch = new NotificationChannel( - CHANNEL_ID, "Focus Mode Status", - NotificationManager.IMPORTANCE_LOW); + CHANNEL_ID, "Focus Mode Status", + NotificationManager.IMPORTANCE_DEFAULT); ch.setDescription("Persistent status of the focus-mode daemon"); ch.setShowBadge(false); ch.setSound(null, null); diff --git a/phone_focus_mode/hosts_enforcer.sh b/phone_focus_mode/hosts_enforcer.sh index 5d4c331..67e2a1d 100755 --- a/phone_focus_mode/hosts_enforcer.sh +++ b/phone_focus_mode/hosts_enforcer.sh @@ -28,6 +28,14 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPT_DIR/config.sh" PIDFILE="$STATE_DIR/hosts_enforcer.pid" +MISSING_TARGET_LOGGED=0 +MAGISK_HOSTS_LOGGED=0 +# Magisk "Systemless Hosts" module path. When this module is enabled, +# Magisk magic-mounts files placed under its system/ tree onto the live +# /system at boot. Copying our canonical hosts there makes Magisk overlay +# /system/etc/hosts on next boot, even on read-only system partitions. +MAGISK_HOSTS_MODULE_DIR="/data/adb/modules/hosts" +MAGISK_HOSTS_TARGET="$MAGISK_HOSTS_MODULE_DIR/system/etc/hosts" mkdir -p "$STATE_DIR" "$(dirname "$HOSTS_CANONICAL")" touch "$HOSTS_LOG" @@ -89,6 +97,10 @@ is_bind_mounted_correctly() { [ -n "$target_hash" ] && [ "$target_hash" = "$canonical_hash" ] } +has_hosts_target() { + [ -f "$HOSTS_TARGET" ] +} + 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. @@ -123,13 +135,32 @@ make_target_writable_once() { } assert_bind_mount() { + if ! has_hosts_target; then + # Target file doesn't exist yet - try to create it by directly writing + # /system (remount rw briefly). On Magisk-rooted devices this usually + # works because Magisk intercepts the remount. If it fails we fall back + # to the firewall-only path and log a warning. + log "hosts target missing - attempting to create $HOSTS_TARGET via /system remount" + make_target_writable_once + if ! has_hosts_target; then + if [ "$MISSING_TARGET_LOGGED" -eq 0 ]; then + log "WARN: could not create $HOSTS_TARGET on this ROM (hosts bind enforcement disabled)" + MISSING_TARGET_LOGGED=1 + fi + return 0 + fi + log "Created and populated $HOSTS_TARGET directly" + return 0 + fi + 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 + # Android toybox mount commonly supports "-o bind" but not "--bind". + if mount -o 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" @@ -152,6 +183,44 @@ ensure_canonical_immutable() { chattr +i "$HOSTS_CANONICAL" 2>/dev/null || true } +# Populate the Magisk "Systemless Hosts" module. Magisk's magic mount picks +# up files under /data/adb/modules//system/ at boot and overlays them +# onto the live /system tree. By placing our canonical hosts there we get +# /system/etc/hosts on next boot even on ROMs whose system partition is +# truly read-only (where remount,rw silently fails). +# Returns 0 if module is present and now in sync, 1 otherwise. +populate_magisk_hosts_module() { + if [ ! -d "$MAGISK_HOSTS_MODULE_DIR" ]; then + if [ "$MAGISK_HOSTS_LOGGED" -eq 0 ]; then + log "WARN: Magisk hosts module dir absent ($MAGISK_HOSTS_MODULE_DIR); enable 'Systemless Hosts' in the Magisk app." + MAGISK_HOSTS_LOGGED=1 + fi + return 1 + fi + if [ -f "$MAGISK_HOSTS_MODULE_DIR/disable" ] || [ -f "$MAGISK_HOSTS_MODULE_DIR/remove" ]; then + if [ "$MAGISK_HOSTS_LOGGED" -eq 0 ]; then + log "WARN: Magisk hosts module is disabled or pending removal" + MAGISK_HOSTS_LOGGED=1 + fi + return 1 + fi + mkdir -p "$(dirname "$MAGISK_HOSTS_TARGET")" 2>/dev/null || true + local module_hash canonical_hash + module_hash="$(sha256_of "$MAGISK_HOSTS_TARGET")" + canonical_hash="$(sha256_of "$HOSTS_CANONICAL")" + if [ -n "$module_hash" ] && [ "$module_hash" = "$canonical_hash" ]; then + return 0 + fi + if cp "$HOSTS_CANONICAL" "$MAGISK_HOSTS_TARGET" 2>/dev/null; then + chmod 644 "$MAGISK_HOSTS_TARGET" 2>/dev/null || true + log "Synced canonical hosts -> Magisk module ($MAGISK_HOSTS_TARGET); active after next reboot" + MAGISK_HOSTS_LOGGED=0 + return 0 + fi + log "ERROR: failed to copy canonical hosts to $MAGISK_HOSTS_TARGET" + return 1 +} + verify_and_restore() { if [ ! -f "$HOSTS_CANONICAL" ]; then log "ERROR: canonical hosts missing at $HOSTS_CANONICAL" @@ -177,6 +246,16 @@ verify_and_restore() { return 1 fi + if ! has_hosts_target; then + if [ "$MISSING_TARGET_LOGGED" -eq 0 ]; then + log "WARN: hosts target missing on this ROM: $HOSTS_TARGET (integrity checks skipped)" + MISSING_TARGET_LOGGED=1 + fi + return 0 + fi + + MISSING_TARGET_LOGGED=0 + # Live target integrity check local actual_target actual_target="$(sha256_of "$HOSTS_TARGET")" @@ -199,7 +278,10 @@ main() { log "hosts_enforcer started (PID=$$)" ensure_canonical_immutable - # Initial assertion + # Seed the Magisk systemless hosts module so /system/etc/hosts gets + # magic-mounted on next boot. + populate_magisk_hosts_module || true + # Initial assertion (covers the case where target already exists). assert_bind_mount || true # Seed sha file if missing @@ -210,6 +292,7 @@ main() { fi while true; do + populate_magisk_hosts_module || true verify_and_restore rotate_log sleep "$HOSTS_CHECK_INTERVAL" diff --git a/phone_focus_mode/lib/adb_common.sh b/phone_focus_mode/lib/adb_common.sh new file mode 100755 index 0000000..8a9a995 --- /dev/null +++ b/phone_focus_mode/lib/adb_common.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# lib/adb_common.sh — ADB device selection, identity, and root helpers. +# Source this file; do not execute directly. +set -euo pipefail + +_PHONE_STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/phone_focus_mode" +TRUSTED_DEVICE_FILE="${_PHONE_STATE_DIR}/trusted_device.sh" +LOCK_DIR="${_PHONE_STATE_DIR}/locks" +LAST_RUN_DIR="${_PHONE_STATE_DIR}/last_run" +LOCK_FILE="" + +_info() { + printf '\033[0;34m[INFO]\033[0m %s\n' "$*" >&2 +} + +_warn() { + printf '\033[0;33m[WARN]\033[0m %s\n' "$*" >&2 +} + +_error() { + printf '\033[0;31m[ERROR]\033[0m %s\n' "$*" >&2 +} + +_fatal() { + printf '\033[0;31m[FATAL]\033[0m %s\n' "$*" >&2 + exit 1 +} + +_box() { + local title="$1" + shift + + printf '\n\033[1;33m╔══════════════════════════════════════════╗\033[0m\n' >&2 + printf '\033[1;33m║ %-42s║\033[0m\n' "${title}" >&2 + printf '\033[1;33m╚══════════════════════════════════════════╝\033[0m\n' >&2 + for line in "$@"; do + printf ' %s\n' "${line}" >&2 + done +} + +adb_list_serials() { + adb devices 2>/dev/null | + awk 'NR > 1 && $2 ~ /^(device|offline|unauthorized)$/ { print $1 }' +} + +_load_trusted_device_values() { + local file_path="${1:-${TRUSTED_DEVICE_FILE}}" + local -a loaded_values=() + + [[ -f "${file_path}" ]] || return 1 + + if ! mapfile -t loaded_values < <( + bash -c ' + set -euo pipefail + source "$1" + printf "%s\n" "${TRUSTED_SERIAL:-}" "${TRUSTED_MODEL:-}" "${TRUSTED_FINGERPRINT:-}" + ' bash "${file_path}" + ); then + _warn "Trusted device record is unreadable: ${file_path}" + return 1 + fi + + TRUSTED_SERIAL_LOADED="${loaded_values[0]:-}" + TRUSTED_MODEL_LOADED="${loaded_values[1]:-}" + TRUSTED_FINGERPRINT_LOADED="${loaded_values[2]:-}" + export TRUSTED_SERIAL_LOADED TRUSTED_MODEL_LOADED TRUSTED_FINGERPRINT_LOADED +} + +adb_select_device() { + local requested="${1:-${ADB_SERIAL:-}}" + local found=0 + local serial="" + local -a serials=() + + mapfile -t serials < <(adb_list_serials) + + if [[ ${#serials[@]} -eq 0 ]]; then + _fatal "No ADB device found. Connect via USB or pair wireless ADB first." + fi + + if [[ -n "${requested}" ]]; then + for serial in "${serials[@]}"; do + if [[ "${serial}" == "${requested}" ]]; then + found=1 + break + fi + done + + if [[ "${found}" -ne 1 ]]; then + _fatal "Requested device '${requested}' not found. Connected: ${serials[*]}" + fi + + export ADB_SERIAL="${requested}" + return 0 + fi + + if [[ ${#serials[@]} -eq 1 ]]; then + export ADB_SERIAL="${serials[0]}" + _info "Auto-selected device: ${ADB_SERIAL}" + return 0 + fi + + _fatal "Multiple ADB devices found (${serials[*]}) and no target specified. Use --serial or set ADB_SERIAL." +} + +adb_cmd() { + adb -s "${ADB_SERIAL:?adb_select_device must be called first}" "$@" +} + +adb_verify_root() { + local result="" + + result="$(adb_cmd shell su --mount-master -c "echo ok" 2>/dev/null || true)" + if [[ "${result}" != "ok" ]]; then + _fatal "Root check failed on ${ADB_SERIAL}. Ensure Magisk is installed and ADB root is authorized." + fi + + _info "Root verified on ${ADB_SERIAL}" +} + +adb_root_shell() { + local command_text="$*" + + printf '%s\n' "${command_text}" | adb_cmd shell su --mount-master -c "sh -s" +} + +_sanitize_device_string() { + printf '%s' "$1" | tr -cd 'A-Za-z0-9 ._:/-' +} + +adb_collect_identity() { + local raw_fingerprint="" + local raw_model="" + + raw_model="$(adb_cmd shell getprop ro.product.model 2>/dev/null | tr -d '\r')" + raw_fingerprint="$(adb_cmd shell getprop ro.build.fingerprint 2>/dev/null | tr -d '\r')" + + DEVICE_MODEL="$(_sanitize_device_string "${raw_model}")" + DEVICE_FINGERPRINT="$(_sanitize_device_string "${raw_fingerprint}")" + DEVICE_SERIAL="$(_sanitize_device_string "${ADB_SERIAL}")" + export DEVICE_MODEL DEVICE_FINGERPRINT DEVICE_SERIAL +} + +adb_save_trusted_device() { + adb_collect_identity + mkdir -p "${_PHONE_STATE_DIR}" + + { + printf '# Auto-generated trusted device record — do not edit manually.\n' + printf 'TRUSTED_SERIAL=%q\n' "${DEVICE_SERIAL}" + printf 'TRUSTED_MODEL=%q\n' "${DEVICE_MODEL}" + printf 'TRUSTED_FINGERPRINT=%q\n' "${DEVICE_FINGERPRINT}" + } >"${TRUSTED_DEVICE_FILE}" + + chmod 600 "${TRUSTED_DEVICE_FILE}" + _info "Saved trusted device record: model='${DEVICE_MODEL}' serial='${DEVICE_SERIAL}'" +} + +adb_verify_trusted_identity() { + local saved_model="" + local saved_fingerprint="" + local saved_serial="" + + if [[ ! -f "${TRUSTED_DEVICE_FILE}" ]]; then + _warn "No trusted device record found. Run 'fresh-phone' to enroll this device." + return 0 + fi + + if ! _load_trusted_device_values; then + _fatal "Trusted device record exists but could not be read: ${TRUSTED_DEVICE_FILE}" + fi + + saved_serial="${TRUSTED_SERIAL_LOADED:-}" + saved_model="${TRUSTED_MODEL_LOADED:-}" + saved_fingerprint="${TRUSTED_FINGERPRINT_LOADED:-}" + adb_collect_identity + + if [[ -n "${saved_serial}" && "${DEVICE_SERIAL}" != "${saved_serial}" ]]; then + _fatal "Device identity mismatch: expected serial '${saved_serial}', got '${DEVICE_SERIAL}'. Refusing to proceed automatically." + fi + + if [[ -n "${saved_model}" && "${DEVICE_MODEL}" != "${saved_model}" ]]; then + _fatal "Device identity mismatch: expected model '${saved_model}', got '${DEVICE_MODEL}'. Refusing to proceed automatically." + fi + + if [[ -n "${saved_fingerprint}" && "${DEVICE_FINGERPRINT}" != "${saved_fingerprint}" ]]; then + _fatal "Device identity mismatch: expected fingerprint '${saved_fingerprint}', got '${DEVICE_FINGERPRINT}'. Refusing to proceed automatically." + fi + + _info "Device identity verified: ${DEVICE_SERIAL} (${DEVICE_MODEL})" +} + +_adb_release_lock() { + if [[ -n "${LOCK_FILE}" && -d "${LOCK_FILE}" ]]; then + rm -rf "${LOCK_FILE}" + fi +} + +adb_acquire_lock() { + local lock_pid_file="" + local old_pid="" + + mkdir -p "${LOCK_DIR}" + LOCK_FILE="${LOCK_DIR}/run_phone.lock" + lock_pid_file="${LOCK_FILE}/pid" + + if mkdir "${LOCK_FILE}" 2>/dev/null; then + printf '%s\n' "$$" >"${lock_pid_file}" + trap _adb_release_lock EXIT INT TERM + _info "Acquired run lock (PID $$)" + return 0 + fi + + old_pid="$(cat "${lock_pid_file}" 2>/dev/null || printf '')" + if [[ -n "${old_pid}" ]] && kill -0 "${old_pid}" 2>/dev/null; then + _fatal "Another run_phone.sh instance is already running (PID ${old_pid}). Aborting." + fi + + _warn "Stale lock directory found (PID ${old_pid} no longer running). Removing." + rm -rf "${LOCK_FILE}" + + if ! mkdir "${LOCK_FILE}" 2>/dev/null; then + _fatal "Could not acquire run lock at ${LOCK_FILE}. Another process may have raced us." + fi + + printf '%s\n' "$$" >"${lock_pid_file}" + trap _adb_release_lock EXIT INT TERM + _info "Acquired run lock (PID $$)" +} + +adb_check_cooldown() { + local cooldown_secs="${1:-300}" + local elapsed=0 + local last_run=0 + local marker_name="${2:-default}" + local marker="${LAST_RUN_DIR}/${marker_name}" + local now=0 + + mkdir -p "${LAST_RUN_DIR}" + if [[ -f "${marker}" ]]; then + last_run="$(cat "${marker}")" + if [[ "${last_run}" =~ ^[0-9]+$ ]]; then + now="$(date +%s)" + elapsed=$((now - last_run)) + if ((elapsed < cooldown_secs)); then + _info "Cooldown active: last run ${elapsed}s ago, cooldown is ${cooldown_secs}s. Skipping." + return 1 + fi + else + _warn "Ignoring invalid cooldown marker: ${marker}" + fi + fi + + return 0 +} + +adb_mark_last_run() { + local marker_name="${1:-default}" + + mkdir -p "${LAST_RUN_DIR}" + date +%s >"${LAST_RUN_DIR}/${marker_name}" +} diff --git a/phone_focus_mode/lib/backup.sh b/phone_focus_mode/lib/backup.sh new file mode 100755 index 0000000..df08486 --- /dev/null +++ b/phone_focus_mode/lib/backup.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# lib/backup.sh — Incremental backup of APKs, app data, media, and security state. +# Requires: adb_common.sh and backup_manifest.sh sourced, ADB_SERIAL set. +set -euo pipefail + +readonly _BACKUP_REMOTE_DIR="/data/local/tmp/focus_mode" + +_backup_validate_package_name() { + local package_name="$1" + + [[ "${package_name}" =~ ^[A-Za-z0-9._-]+$ ]] || _fatal "Unsafe package name rejected: ${package_name}" +} + +backup_make_snapshot_dir() { + local device_id="$1" + local timestamp="" + local snapshot_dir="" + + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + snapshot_dir="${PHONE_BACKUP_ROOT}/${device_id}/history/${timestamp}" + mkdir -p \ + "${snapshot_dir}/device_info" \ + "${snapshot_dir}/security_state" \ + "${snapshot_dir}/apks" \ + "${snapshot_dir}/app_data" \ + "${snapshot_dir}/media" \ + "${snapshot_dir}/monitoring" + printf '%s' "${snapshot_dir}" +} + +_backup_update_latest() { + local snapshot_dir="$1" + local device_root="$2" + local latest_dir="${device_root}/latest" + + rm -rf "${latest_dir}" + if ln -s "${snapshot_dir}" "${latest_dir}" 2>/dev/null; then + return 0 + fi + + mkdir -p "${latest_dir}" + cp -a "${snapshot_dir}/." "${latest_dir}/" +} + +backup_device_info() { + local output_dir="$1/device_info" + + _info "Backing up device info → ${output_dir}" + adb_cmd shell getprop >"${output_dir}/getprop.txt" 2>/dev/null || true + adb_cmd shell pm list packages -f >"${output_dir}/packages_full.txt" 2>/dev/null || true + adb_cmd shell pm list packages >"${output_dir}/packages.txt" 2>/dev/null || true + adb_cmd shell df >"${output_dir}/df.txt" 2>/dev/null || true + printf '%s\n' "${ADB_SERIAL}" >"${output_dir}/serial.txt" + adb_cmd shell getprop ro.product.model >"${output_dir}/model.txt" 2>/dev/null || true + adb_cmd shell getprop ro.build.fingerprint >"${output_dir}/fingerprint.txt" 2>/dev/null || true +} + +backup_security_state() { + local output_dir="$1/security_state" + local source_path="" + local destination_dir="" + + _info "Backing up security state → ${output_dir}" + + for source_path in "${SECURITY_STATE_FILES[@]}"; do + destination_dir="${output_dir}$(dirname "${source_path}")" + mkdir -p "${destination_dir}" + adb_root_shell "cat '${source_path}'" >"${destination_dir}/$(basename "${source_path}")" 2>/dev/null || \ + _warn "Could not back up ${source_path} (may not exist yet)" + done + + adb_root_shell "sh '${_BACKUP_REMOTE_DIR}/focus_ctl.sh' status" >"${output_dir}/focus_status.txt" 2>/dev/null || true + adb_root_shell "sh '${_BACKUP_REMOTE_DIR}/focus_ctl.sh' hosts-status" >"${output_dir}/hosts_status.txt" 2>/dev/null || true + adb_root_shell "sh '${_BACKUP_REMOTE_DIR}/focus_ctl.sh' dns-status" >"${output_dir}/dns_status.txt" 2>/dev/null || true + adb_root_shell "sh '${_BACKUP_REMOTE_DIR}/focus_ctl.sh' launcher-status" >"${output_dir}/launcher_status.txt" 2>/dev/null || true + adb_root_shell "sh '${_BACKUP_REMOTE_DIR}/focus_ctl.sh' notif-status" >"${output_dir}/notif_status.txt" 2>/dev/null || true + adb_root_shell "settings get global private_dns_mode" >"${output_dir}/private_dns_mode.txt" 2>/dev/null || true +} + +backup_apks() { + local output_dir="$1/apks" + local entry="" + local package_name="" + local restore_policy="" + local apk_path="" + local destination_dir="" + + _info "Backing up APKs → ${output_dir}" + + for entry in "${APK_ITEMS[@]}"; do + package_name="${entry%%|*}" + restore_policy="$(printf '%s' "${entry}" | cut -d'|' -f2)" + _backup_validate_package_name "${package_name}" + + apk_path="$(adb_cmd shell "pm path ${package_name} | head -1 | sed 's/^package://'" 2>/dev/null | tr -d '\r' || true)" + if [[ -z "${apk_path}" ]]; then + _warn "APK not found on device: ${package_name}" + continue + fi + + destination_dir="${output_dir}/${package_name}" + mkdir -p "${destination_dir}" + if adb_cmd pull "${apk_path}" "${destination_dir}/base.apk" >/dev/null 2>&1; then + _info " Backed up ${package_name} (policy: ${restore_policy})" + else + _warn " Failed to pull APK for ${package_name}" + fi + printf '%s\n' "${restore_policy}" >"${destination_dir}/restore_policy.txt" + if [[ "${restore_policy}" == "manual_only" ]]; then + printf '%s\n' 'NOTE: manual_only — restore requires explicit operator action' >>"${destination_dir}/restore_policy.txt" + elif [[ "${restore_policy}" == "backup_only" ]]; then + printf '%s\n' 'NOTE: backup_only — backed up for safekeeping; automated restore is not implemented' >>"${destination_dir}/restore_policy.txt" + fi + done +} + +backup_app_data() { + local output_dir="$1/app_data" + local entry="" + local package_name="" + local data_path="" + local restore_policy="" + local destination_dir="" + local parent_dir="" + local base_dir="" + local tar_command="" + + _info "Backing up app data → ${output_dir}" + + for entry in "${APP_DATA_ITEMS[@]}"; do + package_name="${entry%%|*}" + data_path="$(printf '%s' "${entry}" | cut -d'|' -f2)" + restore_policy="$(printf '%s' "${entry}" | cut -d'|' -f3)" + _backup_validate_package_name "${package_name}" + + destination_dir="${output_dir}/${package_name}" + mkdir -p "${destination_dir}" + + if ! adb_root_shell "test -d '${data_path}'" >/dev/null 2>&1; then + _warn "App data path not found: ${data_path} for ${package_name}" + continue + fi + + parent_dir="$(dirname "${data_path}")" + base_dir="$(basename "${data_path}")" + tar_command="tar -czf - -C '${parent_dir}' '${base_dir}'" + if adb_root_shell "${tar_command}" >"${destination_dir}/data.tar.gz" 2>/dev/null; then + _info " Backed up app data for ${package_name} (policy: ${restore_policy})" + else + _warn " Failed to back up app data for ${package_name}" + fi + + printf '%s\n' "${restore_policy}" >"${destination_dir}/restore_policy.txt" + if [[ "${restore_policy}" == "manual_only" ]]; then + printf '%s\n' 'NOTE: manual_only — restore requires explicit operator action' >>"${destination_dir}/restore_policy.txt" + elif [[ "${restore_policy}" == "backup_only" ]]; then + printf '%s\n' 'NOTE: backup_only — backed up for safekeeping; automated restore is not implemented' >>"${destination_dir}/restore_policy.txt" + fi + done +} + +backup_media() { + local output_dir="$1/media" + local entry="" + local name="" + local on_device_path="" + local restore_policy="" + local destination_dir="" + + _info "Backing up media → ${output_dir}" + + for entry in "${MEDIA_ITEMS[@]}"; do + name="${entry%%|*}" + on_device_path="$(printf '%s' "${entry}" | cut -d'|' -f2)" + restore_policy="$(printf '%s' "${entry}" | cut -d'|' -f3)" + destination_dir="${output_dir}/${name}" + mkdir -p "${destination_dir}" + + if adb_cmd pull "${on_device_path}" "${destination_dir}/" >/dev/null 2>&1; then + _info " Backed up media/${name} from ${on_device_path} (policy: ${restore_policy})" + else + _warn " Could not pull media/${name} from ${on_device_path}" + fi + printf '%s\n' "${restore_policy}" >"${destination_dir}/restore_policy.txt" + if [[ "${restore_policy}" == "manual_only" ]]; then + printf '%s\n' 'NOTE: manual_only — restore requires explicit operator action' >>"${destination_dir}/restore_policy.txt" + elif [[ "${restore_policy}" == "backup_only" ]]; then + printf '%s\n' 'NOTE: backup_only — backed up for safekeeping; automated restore is not implemented' >>"${destination_dir}/restore_policy.txt" + fi + done +} + +backup_prune_history() { + local device_root="$1" + local history_dir="${device_root}/history" + + [[ -d "${history_dir}" ]] || return 0 + _info "Pruning history older than ${HISTORY_KEEP_DAYS} days in ${history_dir}" + find "${history_dir}" -maxdepth 1 -mindepth 1 -type d -mtime "+${HISTORY_KEEP_DAYS}" -print -exec rm -rf '{}' + || true +} + +backup_run_incremental() { + local device_id="$1" + local device_root="${PHONE_BACKUP_ROOT}/${device_id}" + local snapshot_dir="" + + snapshot_dir="$(backup_make_snapshot_dir "${device_id}")" + _info "Starting incremental backup to ${snapshot_dir}" + + backup_device_info "${snapshot_dir}" + backup_security_state "${snapshot_dir}" + backup_apks "${snapshot_dir}" + backup_app_data "${snapshot_dir}" + backup_media "${snapshot_dir}" + _backup_update_latest "${snapshot_dir}" "${device_root}" + backup_prune_history "${device_root}" + + _info "Backup complete: ${snapshot_dir}" + printf '%s' "${snapshot_dir}" +} diff --git a/phone_focus_mode/lib/monitor.sh b/phone_focus_mode/lib/monitor.sh new file mode 100755 index 0000000..23f8186 --- /dev/null +++ b/phone_focus_mode/lib/monitor.sh @@ -0,0 +1,483 @@ +#!/usr/bin/env bash +# lib/monitor.sh — Security and health monitoring for the managed phone. +# Requires: adb_common.sh sourced, ADB_SERIAL set, backup_manifest.sh sourced. +set -euo pipefail + +readonly _MONITOR_REMOTE_DIR="/data/local/tmp/focus_mode" +readonly _MONITOR_HOSTS_CANONICAL="/data/local/tmp/focus_mode/hosts.canonical" +readonly _MONITOR_HOSTS_SHA_FILE="/data/local/tmp/focus_mode/hosts.sha256" +readonly _MONITOR_HOSTS_TARGET="/system/etc/hosts" +readonly _MONITOR_BOOT_SCRIPT="/data/adb/service.d/99-focus-mode.sh" +readonly _MONITOR_LAUNCHER_PACKAGE="com.qqlabs.minimalistlauncher" +readonly _MONITOR_LAUNCHER_ACTIVITY_FILE="/data/local/tmp/focus_mode/minimalist_launcher.activity" +readonly _MONITOR_COMPANION_PACKAGE="com.kuhy.focusstatus" +readonly _MONITOR_DNS_CHAIN="FOCUS_DNS_BLOCK" +readonly _MONITOR_HOSTS_CANDIDATES="/system/etc/hosts /etc/hosts /vendor/etc/hosts /system/system/etc/hosts" + +_mon_escape_json() { + local escaped="$1" + + escaped=${escaped//\\/\\\\} + escaped=${escaped//"/\\"} + escaped=${escaped//$'\n'/\\n} + escaped=${escaped//$'\r'/\\r} + printf '%s' "${escaped}" +} + +_mon_check() { + local check_name="$1" + local status="$2" + local source_cmd="$3" + local message="$4" + local repairable="${5:-false}" + + printf '{"check":"%s","status":"%s","source":"%s","message":"%s","repairable":%s}\n' \ + "$(_mon_escape_json "${check_name}")" \ + "$(_mon_escape_json "${status}")" \ + "$(_mon_escape_json "${source_cmd}")" \ + "$(_mon_escape_json "${message}")" \ + "${repairable}" +} + +_safe_adb_root_output() { + adb_root_shell "$@" 2>/dev/null || true +} + +_trim_output() { + printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' +} + +_monitor_read_pidfile() { + local pidfile="$1" + local pid="" + + pid="$(_trim_output "$(_safe_adb_root_output "if [ -f ${pidfile} ]; then cat ${pidfile}; fi")")" + printf '%s' "${pid}" +} + +_monitor_resolve_hosts_target() { + local candidate="" + + if adb_root_shell "test -f ${_MONITOR_HOSTS_TARGET}" >/dev/null 2>&1; then + printf '%s' "${_MONITOR_HOSTS_TARGET}" + return 0 + fi + + for candidate in ${_MONITOR_HOSTS_CANDIDATES}; do + if adb_root_shell "test -f ${candidate}" >/dev/null 2>&1; then + printf '%s' "${candidate}" + return 0 + fi + done + + printf '' +} + +_monitor_pid_matches_script() { + local pid="$1" + local script_name="$2" + + adb_root_shell "kill -0 ${pid} >/dev/null 2>&1" >/dev/null 2>&1 || return 1 + + adb_root_shell "tr '\\0' ' ' /dev/null 2>&1 && return 0 + + # Some Android shells hide/normalize cmdline under su; if PID is alive, + # trust the pidfile check to avoid false negatives. + return 0 +} + +monitor_check_format_indicators() { + local indicator="" + local description="" + local command_text="" + + for indicator in "${FORMAT_INDICATORS[@]}"; do + description="${indicator%%|*}" + command_text="${indicator#*|}" + if ! adb_root_shell "${command_text}" >/dev/null 2>&1; then + printf '%s\n' "${description}" + fi + done +} + +monitor_count_missing_format_indicators() { + local -a missing_indicators=() + + mapfile -t missing_indicators < <(monitor_check_format_indicators) + printf '%s\n' "${#missing_indicators[@]}" +} + +monitor_is_formatted() { + local missing_count="" + + missing_count="$(monitor_count_missing_format_indicators)" + [[ "${missing_count}" =~ ^[0-9]+$ ]] || _fatal "Format-detection helper returned a non-numeric count: ${missing_count}" + (( missing_count >= FORMAT_DETECTION_MIN_MISSING )) +} + +monitor_print_format_warning() { + local -a missing_indicators=("$@") + local -a box_lines=( + "" + "The following expected components were NOT found:" + ) + local indicator="" + + for indicator in "${missing_indicators[@]}"; do + box_lines+=(" ✗ ${indicator}") + done + + box_lines+=( + "" + "This strongly suggests the phone was factory-reset or formatted." + "" + "Next step: run the full recovery workflow:" + " ./scripts/run_all/run_phone.sh fresh-phone" + "" + "Do NOT run 'auto' mode — it will not restore anything." + ) + + _box "PHONE APPEARS TO HAVE BEEN WIPED" "${box_lines[@]}" +} + +_check_format_indicators() { + local outfile="$1" + local -a missing_indicators=() + local status="ok" + local message="All format indicators are present" + + mapfile -t missing_indicators < <(monitor_check_format_indicators) + if (( ${#missing_indicators[@]} >= FORMAT_DETECTION_MIN_MISSING )); then + status="fatal" + message="Missing ${#missing_indicators[@]} format indicators: ${missing_indicators[*]}" + elif (( ${#missing_indicators[@]} > 0 )); then + status="warn" + message="Missing ${#missing_indicators[@]} format indicators: ${missing_indicators[*]}" + fi + + _mon_check "format_indicators" "${status}" "FORMAT_INDICATORS" "${message}" "false" >>"${outfile}" +} + +_check_battery() { + local outfile="$1" + local level="" + local health="" + local temp="" + local status="ok" + local message="" + + level="$(_trim_output "$(_safe_adb_root_output "dumpsys battery | awk -F': ' '/level:/{print \$2; exit}'")")" + health="$(_trim_output "$(_safe_adb_root_output "dumpsys battery | awk -F': ' '/health:/{print \$2; exit}'")")" + temp="$(_trim_output "$(_safe_adb_root_output "dumpsys battery | awk -F': ' '/temperature:/{print \$2; exit}'")")" + + if [[ ! "${level}" =~ ^[0-9]+$ ]]; then + status="warn" + message="Battery level unavailable" + elif (( level < BATTERY_WARN_BELOW )); then + status="warn" + message="Battery low: ${level}% (threshold ${BATTERY_WARN_BELOW}%)" + else + message="Battery level ${level}%, health ${health:-unknown}, temp ${temp:-unknown}" + fi + + _mon_check "battery" "${status}" "dumpsys battery" "${message}" "false" >>"${outfile}" +} + +_check_storage() { + local outfile="$1" + local free_kb="" + local free_mb=0 + local status="ok" + local message="" + + free_kb="$(_trim_output "$(_safe_adb_root_output "df /sdcard 2>/dev/null | awk 'NR==2{print \$4; exit}'")")" + if [[ ! "${free_kb}" =~ ^[0-9]+$ ]]; then + free_kb="$(_trim_output "$(_safe_adb_root_output "df /storage/emulated/0 2>/dev/null | awk 'NR==2{print \$4; exit}'")")" + fi + + if [[ "${free_kb}" =~ ^[0-9]+$ ]]; then + free_mb=$((free_kb / 1024)) + if (( free_mb < STORAGE_WARN_BELOW_MB )); then + status="warn" + message="Low storage: ${free_mb} MB free (threshold ${STORAGE_WARN_BELOW_MB} MB)" + else + message="Free storage: ${free_mb} MB" + fi + else + status="warn" + message="Free storage unavailable" + fi + + _mon_check "storage" "${status}" "df /sdcard" "${message}" "false" >>"${outfile}" +} + +_check_daemon() { + local daemon_name="$1" + local script_name="$2" + local pidfile="$3" + local outfile="$4" + local pid="" + local pgrep_pid="" + + pid="$(_monitor_read_pidfile "${pidfile}")" + if [[ "${pid}" =~ ^[0-9]+$ ]] && _monitor_pid_matches_script "${pid}" "${script_name}"; then + _mon_check "${daemon_name}" "ok" "${pidfile}" "${daemon_name} running (PID ${pid})" "false" >>"${outfile}" + return + fi + + pgrep_pid="$(_trim_output "$(_safe_adb_root_output "pgrep -f '${script_name}' 2>/dev/null | head -1")")" + if [[ "${pgrep_pid}" =~ ^[0-9]+$ ]] && adb_root_shell "kill -0 ${pgrep_pid} >/dev/null 2>&1" >/dev/null 2>&1; then + _mon_check "${daemon_name}" "ok" "pgrep -f ${script_name}" "${daemon_name} running (PID ${pgrep_pid})" "false" >>"${outfile}" + return + fi + + _mon_check "${daemon_name}" "error" "${pidfile}" "${daemon_name} is NOT running" "true" >>"${outfile}" +} + +_check_hosts_daemon() { + local outfile="$1" + local resolved_target="" + + resolved_target="$(_monitor_resolve_hosts_target)" + if [[ -z "${resolved_target}" ]]; then + _mon_check "hosts_enforcer" "warn" "hosts target probe" \ + "No hosts target file exists on this ROM; hosts daemon check skipped" "false" >>"${outfile}" + return + fi + + _check_daemon "hosts_enforcer" "hosts_enforcer.sh" "${_MONITOR_REMOTE_DIR}/hosts_enforcer.pid" "${outfile}" +} + +_check_launcher_daemon() { + local outfile="$1" + local has_snapshot="no" + local launcher_installed="no" + + if adb_root_shell "test -s '${_MONITOR_LAUNCHER_ACTIVITY_FILE}'" >/dev/null 2>&1; then + has_snapshot="yes" + fi + + if adb_root_shell "pm path '${_MONITOR_LAUNCHER_PACKAGE}' >/dev/null 2>&1" >/dev/null 2>&1; then + launcher_installed="yes" + fi + + if [[ "${has_snapshot}" == "no" && "${launcher_installed}" == "no" ]]; then + _mon_check "launcher_enforcer" "warn" "launcher optional probe" \ + "Launcher enforcer check skipped (launcher not configured yet)" "false" >>"${outfile}" + return + fi + + _check_daemon "launcher_enforcer" "launcher_enforcer.sh" "${_MONITOR_REMOTE_DIR}/launcher_enforcer.pid" "${outfile}" +} + +_check_hosts_integrity() { + local outfile="$1" + local hosts_target="" + local expected_hash="" + local actual_hash="" + + hosts_target="$(_monitor_resolve_hosts_target)" + if [[ -z "${hosts_target}" ]]; then + _mon_check "hosts_integrity" "warn" "hosts target probe" \ + "No hosts file target exists on this ROM; hosts integrity check skipped" "false" >>"${outfile}" + return + fi + + if ! adb_root_shell "test -f ${_MONITOR_HOSTS_CANONICAL}" >/dev/null 2>&1; then + _mon_check "hosts_integrity" "fatal" "${_MONITOR_HOSTS_CANONICAL}" \ + "Canonical hosts file missing at ${_MONITOR_HOSTS_CANONICAL}" "true" >>"${outfile}" + return + fi + + expected_hash="$(_trim_output "$(_safe_adb_root_output "cat ${_MONITOR_HOSTS_SHA_FILE} 2>/dev/null")")" + actual_hash="$(_trim_output "$(_safe_adb_root_output "sha256sum ${hosts_target} 2>/dev/null | awk '{print \$1}'")")" + + if [[ -z "${expected_hash}" || -z "${actual_hash}" ]]; then + _mon_check "hosts_integrity" "error" "${hosts_target}" \ + "Could not read hosts integrity hashes" "true" >>"${outfile}" + elif [[ "${expected_hash}" == "${actual_hash}" ]]; then + _mon_check "hosts_integrity" "ok" "${hosts_target}" \ + "Hosts file matches canonical (${actual_hash:0:12}…)" "false" >>"${outfile}" + else + _mon_check "hosts_integrity" "error" "${hosts_target}" \ + "Hosts mismatch: active ${actual_hash:0:12}… != expected ${expected_hash:0:12}…" "true" >>"${outfile}" + fi +} + +_check_dns() { + local outfile="$1" + local private_dns_mode="" + local chain_present="no" + local status="ok" + local message="" + + private_dns_mode="$(_trim_output "$(_safe_adb_root_output "settings get global private_dns_mode 2>/dev/null")")" + if adb_root_shell "iptables -L ${_MONITOR_DNS_CHAIN} >/dev/null 2>&1 && ip6tables -L ${_MONITOR_DNS_CHAIN} >/dev/null 2>&1" >/dev/null 2>&1; then + chain_present="yes" + fi + + if [[ "${private_dns_mode}" == "off" || "${private_dns_mode}" == "null" || -z "${private_dns_mode}" ]]; then + if [[ "${chain_present}" == "yes" ]]; then + message="Private DNS disabled and DNS firewall chains present" + else + status="error" + message="Private DNS disabled, but DNS firewall chains are missing" + fi + else + status="error" + message="Private DNS is enabled (mode=${private_dns_mode})" + fi + + _mon_check "dns_enforcement" "${status}" "settings get global private_dns_mode" "${message}" "true" >>"${outfile}" +} + +_check_launcher() { + local outfile="$1" + local desired_activity="" + local actual_activity="" + local has_snapshot="no" + + if adb_root_shell "test -s '${_MONITOR_LAUNCHER_ACTIVITY_FILE}'" >/dev/null 2>&1; then + has_snapshot="yes" + fi + + if ! adb_root_shell "pm path '${_MONITOR_LAUNCHER_PACKAGE}' >/dev/null 2>&1" >/dev/null 2>&1; then + if [[ "${has_snapshot}" == "yes" ]]; then + _mon_check "launcher_state" "fatal" "pm path ${_MONITOR_LAUNCHER_PACKAGE}" \ + "Minimalist launcher is not installed but snapshot metadata exists" "true" >>"${outfile}" + else + _mon_check "launcher_state" "warn" "pm path ${_MONITOR_LAUNCHER_PACKAGE}" \ + "Minimalist launcher is not installed (optional until snapshot is configured)" "false" >>"${outfile}" + fi + return + fi + + desired_activity="$(_trim_output "$(_safe_adb_root_output "cat ${_MONITOR_LAUNCHER_ACTIVITY_FILE} 2>/dev/null")")" + actual_activity="$(_trim_output "$(_safe_adb_root_output "cmd package resolve-activity --brief -c android.intent.category.HOME -a android.intent.action.MAIN 2>/dev/null | awk 'NR==2{print}'")")" + + if [[ -z "${desired_activity}" ]]; then + _mon_check "launcher_state" "warn" "cat ${_MONITOR_LAUNCHER_ACTIVITY_FILE}" \ + "Launcher snapshot metadata is missing or empty" "true" >>"${outfile}" + elif [[ -n "${actual_activity}" && "${desired_activity}" != "${actual_activity}" ]]; then + _mon_check "launcher_state" "error" "cmd package resolve-activity" \ + "Launcher default mismatch: expected ${desired_activity}, got ${actual_activity}" "true" >>"${outfile}" + else + _mon_check "launcher_state" "ok" "pm path ${_MONITOR_LAUNCHER_PACKAGE}" \ + "Minimalist launcher installed and HOME activity aligned" "false" >>"${outfile}" + fi +} + +_check_companion_app() { + local outfile="$1" + + if adb_root_shell "pm list packages -e '${_MONITOR_COMPANION_PACKAGE}' 2>/dev/null | grep -q '${_MONITOR_COMPANION_PACKAGE}'" >/dev/null 2>&1; then + _mon_check "companion_app" "ok" "pm list packages -e ${_MONITOR_COMPANION_PACKAGE}" \ + "Focus companion app is installed" "false" >>"${outfile}" + else + _mon_check "companion_app" "warn" "pm list packages -e ${_MONITOR_COMPANION_PACKAGE}" \ + "Focus companion app is missing" "true" >>"${outfile}" + fi +} + +_check_boot_persistence() { + local outfile="$1" + + if adb_root_shell "test -x ${_MONITOR_BOOT_SCRIPT}" >/dev/null 2>&1; then + _mon_check "boot_persistence" "ok" "test -x ${_MONITOR_BOOT_SCRIPT}" \ + "Magisk boot script present and executable" "false" >>"${outfile}" + else + _mon_check "boot_persistence" "fatal" "test -x ${_MONITOR_BOOT_SCRIPT}" \ + "Magisk boot script missing or not executable" "true" >>"${outfile}" + fi +} + +monitor_collect_snapshot() { + local snapshot_dir="$1" + local tmp_checks="" + local report_path="${snapshot_dir}/report.json" + + mkdir -p "${snapshot_dir}" + tmp_checks="$(mktemp)" + + _check_format_indicators "${tmp_checks}" + _check_battery "${tmp_checks}" + _check_storage "${tmp_checks}" + _check_daemon "focus_daemon" "focus_daemon.sh" "${_MONITOR_REMOTE_DIR}/daemon.pid" "${tmp_checks}" + _check_hosts_daemon "${tmp_checks}" + _check_daemon "dns_enforcer" "dns_enforcer.sh" "${_MONITOR_REMOTE_DIR}/dns_enforcer.pid" "${tmp_checks}" + _check_launcher_daemon "${tmp_checks}" + _check_hosts_integrity "${tmp_checks}" + _check_dns "${tmp_checks}" + _check_launcher "${tmp_checks}" + _check_companion_app "${tmp_checks}" + _check_boot_persistence "${tmp_checks}" + + { + printf '{"timestamp":"%s","device":"%s","checks":[\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$( _mon_escape_json "${ADB_SERIAL}" )" + paste -sd ',' "${tmp_checks}" + printf '\n]}\n' + } >"${report_path}" + + cp "${report_path}" "$(dirname "${snapshot_dir}")/latest.json" 2>/dev/null || true + rm -f "${tmp_checks}" +} + +monitor_print_summary() { + local snapshot_dir="$1" + local report_path="${snapshot_dir}/report.json" + + [[ -f "${report_path}" ]] || { + _warn "No report found at ${report_path}" + return 0 + } + + python - "${report_path}" <<'PY' +import json +import sys + +report_path = sys.argv[1] +with open(report_path, encoding="utf-8") as handle: + report = json.load(handle) + +counts = {"ok": 0, "warn": 0, "error": 0, "fatal": 0} +issues = [] +for check in report.get("checks", []): + status = check.get("status", "warn") + counts[status] = counts.get(status, 0) + 1 + if status in {"warn", "error", "fatal"}: + issues.append((status, check.get("check", "unknown"), check.get("message", ""))) + +print("\n=== Monitoring Summary ===") +print( + f" ok={counts.get('ok', 0):<3} warn={counts.get('warn', 0):<3} " + f"error={counts.get('error', 0):<3} fatal={counts.get('fatal', 0):<3}" +) +if issues: + print("\nIssues found:") + for status, check_name, message in issues: + print(f" [{status}] {check_name}: {message}") +print("==========================\n") +PY +} + +monitor_severity_exit() { + local snapshot_dir="$1" + local report_path="${snapshot_dir}/report.json" + + [[ -f "${report_path}" ]] || return 0 + + python - "${report_path}" <<'PY' +import json +import sys + +report_path = sys.argv[1] +with open(report_path, encoding="utf-8") as handle: + report = json.load(handle) + +has_severe = any( + check.get("status") in {"fatal", "error"} + for check in report.get("checks", []) +) +raise SystemExit(1 if has_severe else 0) +PY +} diff --git a/phone_focus_mode/lib/restore.sh b/phone_focus_mode/lib/restore.sh new file mode 100755 index 0000000..43d2ffa --- /dev/null +++ b/phone_focus_mode/lib/restore.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# lib/restore.sh — Restore security stack, APKs, and media after format. +# Requires: adb_common.sh, backup_manifest.sh sourced, ADB_SERIAL set. +set -euo pipefail + +_RESTORE_PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly _RESTORE_PROJECT_DIR + +_restore_validate_package_name() { + local package_name="$1" + + [[ "${package_name}" =~ ^[A-Za-z0-9._-]+$ ]] || _fatal "Unsafe package name rejected: ${package_name}" +} + +restore_verify_prerequisites() { + local -a problems=() + + if ! adb_cmd get-state >/dev/null 2>&1; then + problems+=("ADB device not reachable. Ensure USB debugging is authorized or wireless ADB is paired.") + fi + + if ! adb_root_shell "echo root_ok" 2>/dev/null | grep -q '^root_ok$'; then + problems+=("Root shell failed. Ensure Magisk is installed and ADB root is authorized in Magisk settings.") + fi + + if ! adb_root_shell "test -d /data/adb && command -v magisk >/dev/null 2>&1" >/dev/null 2>&1; then + problems+=("Magisk runtime not detected. Install Magisk and verify rooted debugging is enabled.") + fi + + if [[ ${#problems[@]} -gt 0 ]]; then + _box "PREREQUISITES NOT MET — CANNOT CONTINUE" \ + "" \ + "Please resolve the following before running fresh-phone:" \ + "${problems[@]/#/ ✗ }" \ + "" \ + "Manual steps for a freshly formatted phone:" \ + " 1. Install Magisk from https://github.com/topjohnwu/Magisk" \ + " 2. In Magisk → Settings → enable 'Rooted Debugging'" \ + " 3. On phone: Settings → Developer options → enable 'USB Debugging'" \ + " 4. Authorize this PC on the phone when prompted" \ + " 5. Pair wireless ADB again if you normally use it" + return 1 + fi + + _info "All restore prerequisites verified" +} + +restore_security_stack() { + local deploy_script="${_RESTORE_PROJECT_DIR}/deploy.sh" + + _info "Restoring security stack via deploy.sh" + [[ -x "${deploy_script}" ]] || _fatal "deploy.sh not found or not executable at ${deploy_script}" + env ADB_SERIAL="${ADB_SERIAL}" PHONE_IP="${PHONE_IP:-}" bash "${deploy_script}" "${PHONE_IP:-}" +} + +restore_apks() { + local backup_dir="$1" + local apk_dir="${backup_dir}/apks" + local entry="" + local package_name="" + local restore_policy="" + local apk_path="" + + [[ -d "${apk_dir}" ]] || { + _warn "No APK backup found at ${apk_dir}" + return 0 + } + + _info "Restoring APKs from ${apk_dir}" + for entry in "${APK_ITEMS[@]}"; do + package_name="${entry%%|*}" + restore_policy="$(printf '%s' "${entry}" | cut -d'|' -f2)" + _restore_validate_package_name "${package_name}" + + if [[ "${restore_policy}" != "safe_restore" ]]; then + _info " Skipping ${package_name} (policy: ${restore_policy})" + continue + fi + + apk_path="${apk_dir}/${package_name}/base.apk" + if [[ ! -f "${apk_path}" ]]; then + _warn " APK not found in backup: ${package_name}" + continue + fi + + if adb_cmd install -r "${apk_path}" >/dev/null 2>&1; then + _info " Installed ${package_name}" + else + _warn " Failed to install ${package_name}" + fi + done +} + +restore_media() { + local backup_dir="$1" + local media_dir="${backup_dir}/media" + local entry="" + local name="" + local on_device_path="" + local restore_policy="" + local source_dir="" + + [[ -d "${media_dir}" ]] || { + _warn "No media backup found at ${media_dir}" + return 0 + } + + _info "Restoring media from ${media_dir}" + for entry in "${MEDIA_ITEMS[@]}"; do + name="${entry%%|*}" + on_device_path="$(printf '%s' "${entry}" | cut -d'|' -f2)" + restore_policy="$(printf '%s' "${entry}" | cut -d'|' -f3)" + + if [[ "${restore_policy}" != "safe_restore" ]]; then + _info " Skipping media/${name} (policy: ${restore_policy})" + continue + fi + + source_dir="${media_dir}/${name}" + if [[ ! -d "${source_dir}" ]]; then + _warn " Media backup not found: ${source_dir}" + continue + fi + + if adb_cmd push "${source_dir}/." "${on_device_path}/" >/dev/null 2>&1; then + _info " Restored media/${name}" + else + _warn " Failed to restore media/${name}" + fi + done +} + +restore_print_manual_steps() { + local backup_dir="$1" + local -a steps=() + local entry="" + local package_name="" + local data_path="" + local restore_policy="" + + for entry in "${APP_DATA_ITEMS[@]}"; do + package_name="${entry%%|*}" + data_path="$(printf '%s' "${entry}" | cut -d'|' -f2)" + restore_policy="$(printf '%s' "${entry}" | cut -d'|' -f3)" + _restore_validate_package_name "${package_name}" + + if [[ "${restore_policy}" == "manual_only" ]]; then + steps+=("App data for ${package_name}: backup at ${backup_dir}/app_data/${package_name}/data.tar.gz (source ${data_path})") + fi + done + + steps+=("Re-enter coordinates in phone_focus_mode/config_secrets.sh if needed") + steps+=("Re-authorize wireless ADB pairing if you use it") + steps+=("Verify Magisk modules are re-installed if any were previously in use") + + if [[ ${#steps[@]} -gt 0 ]]; then + _box "MANUAL FOLLOW-UP STEPS REQUIRED" \ + "" \ + "The automated restore is complete. You must handle the following manually:" \ + "${steps[@]/#/ ▶ }" + fi +} diff --git a/phone_focus_mode/lib/tests/test_adb_common.sh b/phone_focus_mode/lib/tests/test_adb_common.sh new file mode 100755 index 0000000..8e35a7d --- /dev/null +++ b/phone_focus_mode/lib/tests/test_adb_common.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Unit tests for adb_common.sh helper functions (no real device needed). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +_t_pass() { + PASS=$((PASS + 1)) + printf ' OK: %s\n' "$1" +} + +_t_fail() { + FAIL=$((FAIL + 1)) + printf ' FAIL: %s\n' "$1" +} + +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "${TEST_TMPDIR}"' EXIT + +export XDG_STATE_HOME="${TEST_TMPDIR}/state" +mkdir -p "${XDG_STATE_HOME}" + +source "${SCRIPT_DIR}/../adb_common.sh" + +ADB_MOCK_MODEL=$'Pixel "7";$(rm -rf /)`danger`\nline2' +ADB_MOCK_FINGERPRINT='google/pixel:14/UP1A.231005.007/$evil;`cmd`' + +adb() { + if [[ "$#" -eq 1 && "$1" == "devices" ]]; then + printf 'List of devices attached\nSERIAL123\tdevice\n' + return 0 + fi + + if [[ "$1" == "-s" && "$3" == "shell" && "$4" == "getprop" && "$5" == "ro.product.model" ]]; then + printf '%s\r\n' "${ADB_MOCK_MODEL}" + return 0 + fi + + if [[ "$1" == "-s" && "$3" == "shell" && "$4" == "getprop" && "$5" == "ro.build.fingerprint" ]]; then + printf '%s\r\n' "${ADB_MOCK_FINGERPRINT}" + return 0 + fi + + if [[ "$1" == "-s" && "$3" == "shell" && "$4" == "su" && "$5" == "--mount-master" && "$6" == "-c" && "$7" == "echo ok" ]]; then + printf 'ok\n' + return 0 + fi + + printf 'Unexpected adb invocation:' >&2 + printf ' %q' "$@" >&2 + printf '\n' >&2 + return 1 +} + +run_test() { + local name="$1" + shift + + if "$@"; then + _t_pass "${name}" + else + _t_fail "${name}" + fi +} + +test_box_does_not_crash() { + _box "Test title" "line 1" "line 2" >/dev/null 2>&1 +} + +test_check_cooldown_zero_allows() { + LAST_RUN_DIR="${TEST_TMPDIR}/cooldown-zero" + adb_check_cooldown 0 "test_marker" +} + +test_check_cooldown_blocks_when_marker_fresh() { + LAST_RUN_DIR="${TEST_TMPDIR}/cooldown-block" + mkdir -p "${LAST_RUN_DIR}" + date +%s >"${LAST_RUN_DIR}/fresh_marker" + + if adb_check_cooldown 9999 "fresh_marker"; then + return 1 + fi + + return 0 +} + +test_mark_last_run_creates_marker() { + LAST_RUN_DIR="${TEST_TMPDIR}/mark-last-run" + adb_mark_last_run "run_test" + [[ -f "${LAST_RUN_DIR}/run_test" ]] +} + +test_acquire_lock_creates_lock_directory() { + LOCK_DIR="${TEST_TMPDIR}/lock-dir-create" + LOCK_FILE="" + adb_acquire_lock >/dev/null 2>&1 + [[ -d "${LOCK_FILE}" && -f "${LOCK_FILE}/pid" ]] + _adb_release_lock +} + +test_acquire_lock_removes_stale_lock_directory() { + LOCK_DIR="${TEST_TMPDIR}/lock-dir-stale" + LOCK_FILE="${LOCK_DIR}/run_phone.lock" + mkdir -p "${LOCK_FILE}" + printf '999999\n' >"${LOCK_FILE}/pid" + + adb_acquire_lock >/dev/null 2>&1 + [[ -d "${LOCK_FILE}" && -f "${LOCK_FILE}/pid" ]] + _adb_release_lock +} + +test_sanitize_device_string_removes_dangerous_chars() { + local sanitized + sanitized="$(_sanitize_device_string $'abc DEF-._:/$`";\n')" + + [[ "${sanitized}" == 'abc DEF-._:/' ]] +} + +test_save_trusted_device_sanitizes_and_quotes() { + local trusted_contents + + export ADB_SERIAL='SERIAL123$() ;' + adb_save_trusted_device + + [[ -f "${TRUSTED_DEVICE_FILE}" ]] || return 1 + trusted_contents="$(cat "${TRUSTED_DEVICE_FILE}")" + + [[ "${trusted_contents}" != *'$('* ]] + [[ "${trusted_contents}" != *';'* ]] + [[ "${trusted_contents}" != *'`'* ]] + + local -a trusted_values=() + mapfile -t trusted_values < <( + bash -c ' + set -euo pipefail + source "$1" + printf "%s\n" "${TRUSTED_SERIAL:-}" "${TRUSTED_MODEL:-}" "${TRUSTED_FINGERPRINT:-}" + ' bash "${TRUSTED_DEVICE_FILE}" + ) + + [[ "${trusted_values[0]:-}" == 'SERIAL123 ' ]] + [[ "${trusted_values[1]:-}" == 'Pixel 7rm -rf /dangerline2' ]] + [[ "${trusted_values[2]:-}" == 'google/pixel:14/UP1A.231005.007/evilcmd' ]] +} + +test_verify_root_uses_root_shell() { + export ADB_SERIAL='SERIAL123' + adb_verify_root >/dev/null 2>&1 +} + +test_select_device_rejects_multiple_devices_even_with_trusted_record() { + unset ADB_SERIAL + + adb_list_serials() { + printf 'SERIAL123\nSERIAL999\n' + } + + cat >"${TRUSTED_DEVICE_FILE}" <<'EOF' +TRUSTED_SERIAL='SERIAL123' +TRUSTED_MODEL='Pixel 7rm -rf /dangerline2' +TRUSTED_FINGERPRINT='google/pixel:14/UP1A.231005.007/evilcmd' +EOF + + if (adb_select_device >/dev/null 2>&1); then + return 1 + fi + + return 0 +} + +test_verify_trusted_identity_accepts_exact_match() { + export ADB_SERIAL='SERIAL123' + adb_save_trusted_device + adb_verify_trusted_identity >/dev/null 2>&1 +} + +test_verify_trusted_identity_rejects_model_mismatch() { + export ADB_SERIAL='SERIAL123' + adb_save_trusted_device + + cat >"${TRUSTED_DEVICE_FILE}" <<'EOF' +TRUSTED_SERIAL='SERIAL123' +TRUSTED_MODEL='Different Model' +TRUSTED_FINGERPRINT='google/pixel:14/UP1A.231005.007/evilcmd' +EOF + + if (adb_verify_trusted_identity >/dev/null 2>&1); then + return 1 + fi + + return 0 +} + +test_verify_trusted_identity_rejects_fingerprint_mismatch() { + export ADB_SERIAL='SERIAL123' + adb_save_trusted_device + + cat >"${TRUSTED_DEVICE_FILE}" <<'EOF' +TRUSTED_SERIAL='SERIAL123' +TRUSTED_MODEL='Pixel 7rm -rf /dangerline2' +TRUSTED_FINGERPRINT='different/fingerprint' +EOF + + if (adb_verify_trusted_identity >/dev/null 2>&1); then + return 1 + fi + + return 0 +} + +run_test "_box output without crash" test_box_does_not_crash +run_test "adb_check_cooldown 0 returns 0 (proceed)" test_check_cooldown_zero_allows +run_test "adb_check_cooldown blocks when marker is fresh" test_check_cooldown_blocks_when_marker_fresh +run_test "adb_mark_last_run creates marker file" test_mark_last_run_creates_marker +run_test "adb_acquire_lock creates atomic lock directory" test_acquire_lock_creates_lock_directory +run_test "adb_acquire_lock replaces stale lock directory" test_acquire_lock_removes_stale_lock_directory +run_test "_sanitize_device_string strips dangerous characters" test_sanitize_device_string_removes_dangerous_chars +run_test "adb_save_trusted_device sanitizes and safely quotes values" test_save_trusted_device_sanitizes_and_quotes +run_test "adb_verify_root succeeds when root shell returns ok" test_verify_root_uses_root_shell +run_test "adb_select_device rejects multiple devices even with trusted record" test_select_device_rejects_multiple_devices_even_with_trusted_record +run_test "adb_verify_trusted_identity accepts exact saved identity" test_verify_trusted_identity_accepts_exact_match +run_test "adb_verify_trusted_identity rejects model mismatch" test_verify_trusted_identity_rejects_model_mismatch +run_test "adb_verify_trusted_identity rejects fingerprint mismatch" test_verify_trusted_identity_rejects_fingerprint_mismatch + +printf '\nResults: %d passed, %d failed\n' "${PASS}" "${FAIL}" +[[ "${FAIL}" -eq 0 ]] diff --git a/phone_focus_mode/lib/tests/test_dns_enforcer.sh b/phone_focus_mode/lib/tests/test_dns_enforcer.sh new file mode 100755 index 0000000..a7597e4 --- /dev/null +++ b/phone_focus_mode/lib/tests/test_dns_enforcer.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Unit tests for dns_enforcer.sh helper functions. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +_t_pass() { + PASS=$((PASS + 1)) + printf ' OK: %s\n' "$1" +} + +_t_fail() { + FAIL=$((FAIL + 1)) + printf ' FAIL: %s\n' "$1" +} + +TMPDIR_TEST="$(mktemp -d)" +trap 'rm -rf "${TMPDIR_TEST}"' EXIT + +cat >"${TMPDIR_TEST}/config.sh" <"$block_file" +append_unique_line "$block_file" "1.2.3.4" +append_unique_line "$block_file" "1.2.3.4" +append_unique_line "$block_file" "5.6.7.8" + +if [[ "$(wc -l < "$block_file" | tr -d ' ')" == "2" ]]; then + _t_pass "append_unique_line de-duplicates values" +else + _t_fail "append_unique_line should avoid duplicates" +fi + +printf '\nResults: %d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/phone_focus_mode/lib/tests/test_magisk_service.sh b/phone_focus_mode/lib/tests/test_magisk_service.sh new file mode 100755 index 0000000..c6989a1 --- /dev/null +++ b/phone_focus_mode/lib/tests/test_magisk_service.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Unit tests for magisk_service.sh boot safety helpers. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +_t_pass() { + PASS=$((PASS + 1)) + printf ' OK: %s\n' "$1" +} + +_t_fail() { + FAIL=$((FAIL + 1)) + printf ' FAIL: %s\n' "$1" +} + +TMPDIR_TEST="$(mktemp -d)" +trap 'rm -rf "${TMPDIR_TEST}"' EXIT + +cat >"${TMPDIR_TEST}/config.sh" <"${TMPDIR_TEST}/minimalist_launcher.apk" +printf 'pkg/.Activity' >"${TMPDIR_TEST}/minimalist_launcher.activity" +cat >"${TMPDIR_TEST}/config.sh" <&2 + exit 1 +} + +_box() { + : +} + +FORMAT_INDICATORS=() +FORMAT_DETECTION_MIN_MISSING=2 +BATTERY_WARN_BELOW=20 +STORAGE_WARN_BELOW_MB=500 +ADB_SERIAL="test-serial" + +: "${FORMAT_DETECTION_MIN_MISSING}" +: "${BATTERY_WARN_BELOW}" +: "${STORAGE_WARN_BELOW_MB}" +: "${ADB_SERIAL}" +: "${FORMAT_INDICATORS[*]}" + +source "${SCRIPT_DIR}/../monitor.sh" + +PASS=0 +FAIL=0 + +_t_pass() { + PASS=$((PASS + 1)) + printf ' OK: %s\n' "$1" +} + +_t_fail() { + FAIL=$((FAIL + 1)) + printf ' FAIL: %s\n' "$1" +} + +TMPDIR_TEST="$(mktemp -d)" +trap 'rm -rf "${TMPDIR_TEST}"' EXIT + +line="$(_mon_check "test_check" "ok" "some_cmd" "all good" "false")" +if [[ "${line}" == *'"check":"test_check"'* ]]; then + _t_pass "_mon_check outputs check name" +else + _t_fail "_mon_check missing check name" +fi + +if [[ "${line}" == *'"status":"ok"'* ]]; then + _t_pass "_mon_check outputs status" +else + _t_fail "_mon_check missing status" +fi + +if [[ -z "$(_monitor_resolve_hosts_target)" ]]; then + _t_pass "_monitor_resolve_hosts_target returns empty when no hosts file exists" +else + _t_fail "_monitor_resolve_hosts_target should be empty when all candidates are missing" +fi + +adb_root_shell() { + case "$*" in + 'test -f /system/etc/hosts') return 1 ;; + 'test -f /etc/hosts') return 1 ;; + 'test -f /vendor/etc/hosts') return 0 ;; + 'test -f /system/system/etc/hosts') return 1 ;; + *) return 1 ;; + esac +} + +if [[ "$(_monitor_resolve_hosts_target)" == "/vendor/etc/hosts" ]]; then + _t_pass "_monitor_resolve_hosts_target picks first existing candidate" +else + _t_fail "_monitor_resolve_hosts_target did not return expected candidate" +fi + +adb_root_shell() { + return 1 +} + +if ! monitor_is_formatted 2>/dev/null; then + _t_pass "monitor_is_formatted returns 1 when no indicators are missing" +else + _t_fail "monitor_is_formatted should return 1 with empty FORMAT_INDICATORS" +fi + +if monitor_severity_exit "${TMPDIR_TEST}/nonexistent"; then + _t_pass "monitor_severity_exit returns 0 when no report exists" +else + _t_fail "monitor_severity_exit should return 0 for missing report" +fi + +printf '%s\n' '{"checks":[{"check":"x","status":"fatal","source":"s","message":"m","repairable":false}]}' > "${TMPDIR_TEST}/report.json" +if ! monitor_severity_exit "${TMPDIR_TEST}"; then + _t_pass "monitor_severity_exit returns 1 on fatal status" +else + _t_fail "monitor_severity_exit should return 1 on fatal status" +fi + +printf '%s\n' '{"checks":[{"check":"x","status":"ok","source":"s","message":"mentions fatal and error in text","repairable":false}]}' > "${TMPDIR_TEST}/report.json" +if monitor_severity_exit "${TMPDIR_TEST}"; then + _t_pass "monitor_severity_exit ignores free-text fatal/error words in message" +else + _t_fail "monitor_severity_exit should only inspect the JSON status field" +fi + +FORMAT_INDICATORS=( + "present|test -f /present" + "missing-one|test -f /missing-one" + "missing-two|test -f /missing-two" +) + +: "${FORMAT_INDICATORS[*]}" + +adb_root_shell() { + case "$*" in + 'test -f /present') + return 0 + ;; + *) + return 1 + ;; + esac +} + +mapfile -t missing_indicators < <(monitor_check_format_indicators) +if [[ ${#missing_indicators[@]} -eq 2 ]] && [[ "${missing_indicators[0]}" == "missing-one" ]] && [[ "${missing_indicators[1]}" == "missing-two" ]]; then + _t_pass "monitor_check_format_indicators prints only missing indicator names" +else + _t_fail "monitor_check_format_indicators did not return the expected missing indicators" +fi + +printf '\nResults: %d passed, %d failed\n' "${PASS}" "${FAIL}" +[[ "${FAIL}" -eq 0 ]] diff --git a/phone_focus_mode/magisk_service.sh b/phone_focus_mode/magisk_service.sh index 005169b..dd1e0be 100755 --- a/phone_focus_mode/magisk_service.sh +++ b/phone_focus_mode/magisk_service.sh @@ -6,33 +6,177 @@ # Magisk executes everything in service.d on boot with root. # ============================================================ -# Wait for system to be fully booted before starting daemons -sleep 120 +set -eu -SCRIPT_DIR="/data/local/tmp/focus_mode" +SCRIPT_DIR="${FOCUS_MODE_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" +load_launcher_config() { + if [ -f "$SCRIPT_DIR/config.sh" ]; then + export FOCUS_MODE_SCRIPT_DIR="$SCRIPT_DIR" + # shellcheck source=/dev/null + . "$SCRIPT_DIR/config.sh" + return 0 + fi -# Start hosts enforcer FIRST - it must bind-mount the hosts file before -# the user has a chance to exploit it. This runs even outside focus mode -# because hosts hardening should always be active. -setsid sh "$SCRIPT_DIR/hosts_enforcer.sh" /dev/null 2>&1 & + return 1 +} -# Start DNS enforcer - forces Private DNS off and blocks DoH/DoT endpoints -# so the hosts file actually gets consulted by apps that would otherwise -# bypass it (e.g. Chrome's built-in secure DNS). Always on. -setsid sh "$SCRIPT_DIR/dns_enforcer.sh" /dev/null 2>&1 & +boot_config_ready() { + [ -f "$SCRIPT_DIR/config.sh" ] +} -# Start launcher enforcer - keeps Minimalist Phone installed and pinned as -# the default HOME. Always on (not location-gated). -setsid sh "$SCRIPT_DIR/launcher_enforcer.sh" /dev/null 2>&1 & +launcher_boot_autostart_enabled() { + load_launcher_config || return 1 + [ "${LAUNCHER_BOOT_AUTOSTART:-0}" = "1" ] +} -# Start focus daemon in a new session (detached from any controlling terminal) -setsid sh "$SCRIPT_DIR/focus_daemon.sh" /dev/null 2>&1 & +launcher_boot_snapshot_ready() { + load_launcher_config || return 1 + [ -s "${LAUNCHER_APK:-}" ] && [ -s "${LAUNCHER_ACTIVITY_FILE:-}" ] +} -exit 0 +should_start_boot_stack() { + load_launcher_config || return 1 + [ "${FOCUS_BOOT_AUTOSTART:-0}" = "1" ] +} + +boot_delay_seconds() { + load_launcher_config || { + echo 10 + return 0 + } + + raw_delay="${FOCUS_BOOT_DELAY_SECONDS:-10}" + case "$raw_delay" in + ''|*[!0-9]*) + echo 10 + return 0 + ;; + esac + + # Safety cap requested by user: keep post-boot delay short. + if [ "$raw_delay" -gt 10 ]; then + echo 10 + return 0 + fi + + echo "$raw_delay" +} + +boot_emergency_disable_file() { + load_launcher_config || { + echo "$SCRIPT_DIR/disable_boot_autostart" + return 0 + } + + echo "${FOCUS_BOOT_EMERGENCY_DISABLE_FILE:-$SCRIPT_DIR/disable_boot_autostart}" +} + +boot_emergency_disabled() { + marker_file="$(boot_emergency_disable_file)" + [ -f "$marker_file" ] +} + +wait_for_boot_completed() { + elapsed=0 + max_wait="${FOCUS_BOOT_WAIT_MAX_SECONDS:-180}" + + while [ "$elapsed" -lt "$max_wait" ]; do + if [ "$(getprop sys.boot_completed 2>/dev/null || true)" = "1" ]; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + + return 1 +} + +wait_for_boot_config() { + elapsed=0 + max_wait="${FOCUS_BOOT_WAIT_MAX_SECONDS:-180}" + + while [ "$elapsed" -lt "$max_wait" ]; do + if boot_config_ready; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + + return 1 +} + +should_start_launcher_enforcer() { + launcher_boot_autostart_enabled && launcher_boot_snapshot_ready +} + +safe_chmod() { + if [ -f "$1" ]; then + chmod +x "$1" + fi +} + +start_launcher_enforcer_if_safe() { + if should_start_launcher_enforcer; then + setsid sh "$SCRIPT_DIR/launcher_enforcer.sh" /dev/null 2>&1 & + return 0 + fi + + return 1 +} + +main() { + if ! wait_for_boot_config; then + exit 0 + fi + + if ! should_start_boot_stack; then + exit 0 + fi + + if boot_emergency_disabled; then + exit 0 + fi + + if ! wait_for_boot_completed; then + exit 0 + fi + + sleep "$(boot_delay_seconds)" + + if boot_emergency_disabled; then + exit 0 + fi + + # Ensure scripts are executable. + safe_chmod "$SCRIPT_DIR/focus_daemon.sh" + safe_chmod "$SCRIPT_DIR/focus_ctl.sh" + safe_chmod "$SCRIPT_DIR/hosts_enforcer.sh" + safe_chmod "$SCRIPT_DIR/dns_enforcer.sh" + safe_chmod "$SCRIPT_DIR/launcher_enforcer.sh" + + # Start hosts enforcer FIRST - it must bind-mount the hosts file before + # the user has a chance to exploit it. This runs even outside focus mode + # because hosts hardening should always be active. + setsid sh "$SCRIPT_DIR/hosts_enforcer.sh" /dev/null 2>&1 & + + # Start DNS enforcer - forces Private DNS off and blocks DoH/DoT endpoints + # so the hosts file actually gets consulted by apps that would otherwise + # bypass it (e.g. Chrome's built-in secure DNS). Always on. + setsid sh "$SCRIPT_DIR/dns_enforcer.sh" /dev/null 2>&1 & + + # Start launcher enforcer only when boot autostart is explicitly enabled + # and a valid launcher snapshot exists. This avoids boot loops or a blank + # HOME screen caused by stale launcher state after OTA updates/resets. + start_launcher_enforcer_if_safe || true + + # Start focus daemon in a new session (detached from any controlling terminal). + setsid sh "$SCRIPT_DIR/focus_daemon.sh" /dev/null 2>&1 & + + exit 0 +} + +if [ "${FOCUS_MODE_MAGISK_SERVICE_TESTING:-0}" != "1" ]; then + main "$@" +fi diff --git a/phone_focus_mode/run_phone.sh b/phone_focus_mode/run_phone.sh new file mode 100755 index 0000000..2224b38 --- /dev/null +++ b/phone_focus_mode/run_phone.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# run_phone.sh — Phone focus mode orchestrator. +# +# Usage: +# run_phone.sh [auto] Everyday: backup, monitor, repair minor drift. +# If the phone looks formatted, shows a warning +# and exits — does nothing else. +# run_phone.sh fresh-phone Full recovery after a factory reset. +# run_phone.sh backup Incremental backup only. +# run_phone.sh monitor Health and security snapshot only. +# run_phone.sh doctor Diagnose and repair drift without data restore. +# run_phone.sh --help | -h Show this help. +# +# Environment variables: +# ADB_SERIAL Target a specific device by serial. +# PHONE_BACKUP_ROOT Override backup root (default: ~/phone_backups). +# PHONE_IP Wireless ADB host (used by deploy.sh when ADB_SERIAL unset). +set -euo pipefail + +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=lib/adb_common.sh +source "${_SCRIPT_DIR}/lib/adb_common.sh" +# shellcheck source=backup_manifest.sh +source "${_SCRIPT_DIR}/backup_manifest.sh" +# shellcheck source=lib/monitor.sh +source "${_SCRIPT_DIR}/lib/monitor.sh" +# shellcheck source=lib/backup.sh +source "${_SCRIPT_DIR}/lib/backup.sh" +# shellcheck source=lib/restore.sh +source "${_SCRIPT_DIR}/lib/restore.sh" + +SUBCOMMAND="${1:-auto}" +shift 2>/dev/null || true + +case "${SUBCOMMAND}" in + --help|-h|help) + grep '^#' "${BASH_SOURCE[0]}" | grep -v '^#!/' | grep -v '^# shellcheck ' | sed 's/^# \?//' + exit 0 + ;; + auto|fresh-phone|backup|monitor|doctor) ;; + *) + _fatal "Unknown subcommand: '${SUBCOMMAND}'. Run with --help for usage." + ;; +esac + +_setup_common() { + adb_select_device "${ADB_SERIAL:-}" + adb_verify_root + adb_verify_trusted_identity + adb_collect_identity +} + +_auto_repair_minor_drift() { + local snapshot_dir="$1" + local report="${snapshot_dir}/report.json" + local repaired=0 + + [[ -f "${report}" ]] || return 0 + + if grep -q '"check":"focus_daemon","status":"error"' "${report}" 2>/dev/null; then + if adb_root_shell "test -x /data/adb/service.d/99-focus-mode.sh" >/dev/null 2>&1; then + _info "Repair: restarting focus daemons via boot script" + adb_root_shell "sh /data/adb/service.d/99-focus-mode.sh" >/dev/null 2>&1 || true + repaired=$(( repaired + 1 )) + else + _warn "Cannot restart daemons: boot script missing. Run 'fresh-phone' or 'doctor'." + fi + fi + + [[ ${repaired} -gt 0 ]] && _info "Minor repairs applied: ${repaired}" || true +} + +cmd_auto() { + adb_acquire_lock + + if ! adb_check_cooldown "${COOLDOWN_AUTO_SECS}" "auto"; then + _info "auto: cooldown active, nothing to do." + exit 0 + fi + + _setup_common + + # Detect fresh format FIRST and exit immediately if detected. + local -a missing + mapfile -t missing < <(monitor_check_format_indicators) + local missing_count="${#missing[@]}" + if (( missing_count >= FORMAT_DETECTION_MIN_MISSING )); then + monitor_print_format_warning "${missing[@]}" + exit 2 + fi + + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + local snapshot_dir="" + snapshot_dir="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/$(date -u +%Y%m%dT%H%M%SZ)" + + monitor_collect_snapshot "${snapshot_dir}" + backup_run_incremental "${device_id}" + _auto_repair_minor_drift "${snapshot_dir}" + + monitor_print_summary "${snapshot_dir}" + adb_mark_last_run "auto" + monitor_severity_exit "${snapshot_dir}" || exit 1 +} + +cmd_fresh_phone() { + _info "=== fresh-phone: Full recovery mode ===" + adb_select_device "${ADB_SERIAL:-}" + + restore_verify_prerequisites + + adb_collect_identity + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + local backup_root="${PHONE_BACKUP_ROOT}/${device_id}/latest" + local has_backup=0 + + if [[ -d "${backup_root}" ]]; then + has_backup=1 + else + _warn "No backup found at ${backup_root}. Proceeding with security-stack-only setup." + fi + + local pre_snapshot="" + pre_snapshot="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/pre_restore_$(date -u +%Y%m%dT%H%M%SZ)" + monitor_collect_snapshot "${pre_snapshot}" || true + + # Delegate security restore to deploy.sh via restore helper. + restore_security_stack + + if (( has_backup == 1 )); then + restore_apks "${backup_root}" + restore_media "${backup_root}" + else + _warn "Skipping APK/media restore because no backup snapshot is available." + fi + + local post_snapshot="" + post_snapshot="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/post_restore_$(date -u +%Y%m%dT%H%M%SZ)" + monitor_collect_snapshot "${post_snapshot}" || true + monitor_print_summary "${post_snapshot}" + + adb_save_trusted_device + if (( has_backup == 1 )); then + restore_print_manual_steps "${backup_root}" + else + _box "BACKUP RESTORE SKIPPED" \ + "" \ + "Security stack deployment completed, but no backup snapshot was found." \ + "Next steps:" \ + " ▶ Run './scripts/run_all/run_phone.sh backup' after stabilization" \ + " ▶ Reinstall required apps that are not part of deploy assets" \ + " ▶ Reconfigure app data manually" + fi +} + +cmd_backup() { + _setup_common + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + backup_run_incremental "${device_id}" +} + +cmd_monitor() { + _setup_common + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + local snapshot_dir="" + snapshot_dir="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/$(date -u +%Y%m%dT%H%M%SZ)" + monitor_collect_snapshot "${snapshot_dir}" + monitor_print_summary "${snapshot_dir}" + monitor_severity_exit "${snapshot_dir}" || exit 1 +} + +cmd_doctor() { + _setup_common + local device_id="${ADB_SERIAL//[^a-zA-Z0-9_-]/_}" + local snapshot_dir="" + snapshot_dir="${PHONE_BACKUP_ROOT}/${device_id}/monitoring/doctor_$(date -u +%Y%m%dT%H%M%SZ)" + local report="" + local repaired=0 + + monitor_collect_snapshot "${snapshot_dir}" + report="${snapshot_dir}/report.json" + + local daemon="" + for daemon in focus_daemon hosts_enforcer dns_enforcer launcher_enforcer; do + if grep -q "\"check\":\"${daemon}\",\"status\":\"error\"" "${report}" 2>/dev/null; then + _info "Doctor: restarting ${daemon}" + adb_root_shell "pgrep -f ${daemon}.sh | xargs kill -9 2>/dev/null || true" >/dev/null 2>&1 || true + adb_root_shell "nohup sh /data/adb/focus_mode/${daemon}.sh /dev/null 2>&1 &" >/dev/null 2>&1 || \ + _warn "Could not restart ${daemon}" + repaired=$(( repaired + 1 )) + fi + done + + # Re-deploy is allowed only for managed security-stack drift. + if grep -q '"check":"boot_persistence","status":"fatal"' "${report}" 2>/dev/null; then + _info "Doctor: boot script missing — re-running deploy.sh" + restore_security_stack + repaired=$(( repaired + 1 )) + fi + + if grep -q '"check":"hosts_integrity","status":"error"' "${report}" 2>/dev/null; then + if [[ -f "${PHONE_BACKUP_ROOT}/${device_id}/latest/security_state/data/local/tmp/focus_mode/hosts.canonical" ]]; then + _info "Doctor: restoring canonical hosts from backup" + adb_cmd push \ + "${PHONE_BACKUP_ROOT}/${device_id}/latest/security_state/data/local/tmp/focus_mode/hosts.canonical" \ + "/data/local/tmp/focus_mode/hosts.canonical" + repaired=$(( repaired + 1 )) + else + _warn "Doctor: hosts integrity failed but no backup copy available. Run fresh-phone." + fi + fi + + monitor_collect_snapshot "${snapshot_dir}_after" + monitor_print_summary "${snapshot_dir}_after" + + _info "Doctor complete. Repairs applied: ${repaired}" + monitor_severity_exit "${snapshot_dir}_after" || { + _warn "Unresolved issues remain after doctor run." + exit 1 + } +} + +case "${SUBCOMMAND}" in + auto) cmd_auto ;; + fresh-phone) cmd_fresh_phone ;; + backup) cmd_backup ;; + monitor) cmd_monitor ;; + doctor) cmd_doctor ;; +esac diff --git a/phone_focus_mode/systemd/install_pc_phone_automation.sh b/phone_focus_mode/systemd/install_pc_phone_automation.sh new file mode 100755 index 0000000..9b9a1d5 --- /dev/null +++ b/phone_focus_mode/systemd/install_pc_phone_automation.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# install_pc_phone_automation.sh — Install user-level systemd automation for +# periodic phone sync. Runs as the current user (no sudo required). +set -euo pipefail + +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_SYSTEMD_USER_DIR="${HOME}/.config/systemd/user" + +mkdir -p "${_SYSTEMD_USER_DIR}" + +cp "${_SCRIPT_DIR}/phone-auto-sync.service" "${_SYSTEMD_USER_DIR}/" +cp "${_SCRIPT_DIR}/phone-auto-sync.timer" "${_SYSTEMD_USER_DIR}/" + +systemctl --user daemon-reload +systemctl --user enable --now phone-auto-sync.timer + +printf 'Installed and enabled phone-auto-sync.timer\n' +printf 'Next run: ' +systemctl --user list-timers phone-auto-sync.timer --no-legend | awk '{print $1, $2}' || \ + printf '(check with: systemctl --user list-timers)\n' diff --git a/phone_focus_mode/systemd/phone-auto-sync.service b/phone_focus_mode/systemd/phone-auto-sync.service new file mode 100644 index 0000000..3b5c85d --- /dev/null +++ b/phone_focus_mode/systemd/phone-auto-sync.service @@ -0,0 +1,9 @@ +[Unit] +Description=Phone focus mode auto sync +After=network.target + +[Service] +Type=oneshot +ExecStart=%h/testsAndMisc/scripts/run_all/run_phone.sh auto +StandardOutput=journal +StandardError=journal diff --git a/phone_focus_mode/systemd/phone-auto-sync.timer b/phone_focus_mode/systemd/phone-auto-sync.timer new file mode 100644 index 0000000..a0762df --- /dev/null +++ b/phone_focus_mode/systemd/phone-auto-sync.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Run phone focus mode auto sync periodically + +[Timer] +OnBootSec=5min +OnUnitActiveSec=30min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..18d5a02 --- /dev/null +++ b/run.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Easy entrypoint for system usage reports. +# Usage: +# ./run.sh # today's report to stdout +# ./run.sh --date 20260501 # specific day +# ./run.sh --top 25 # override row count +# +# Any args are forwarded to usage_report.py unchanged. + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPORT_SCRIPT="$SCRIPT_DIR/linux_configuration/scripts/system-maintenance/bin/usage_report.py" + +if [[ ! -f "$REPORT_SCRIPT" ]]; then + echo "Error: usage_report.py not found at: $REPORT_SCRIPT" >&2 + exit 1 +fi + +exec python3 "$REPORT_SCRIPT" "$@" diff --git a/scripts/run_all/run_phone.sh b/scripts/run_all/run_phone.sh new file mode 100755 index 0000000..79ba54c --- /dev/null +++ b/scripts/run_all/run_phone.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# run_phone.sh — Visible entrypoint for the phone focus mode workflow. +# +# Quick reference: +# ./scripts/run_all/run_phone.sh Everyday: backup + monitor + minor repair. +# Shows a warning if the phone was wiped. +# ./scripts/run_all/run_phone.sh fresh-phone Full recovery after a factory reset. +# ./scripts/run_all/run_phone.sh doctor Diagnose and repair security drift. +# ./scripts/run_all/run_phone.sh backup Incremental backup only. +# ./scripts/run_all/run_phone.sh monitor Health snapshot only. +# ./scripts/run_all/run_phone.sh --help Show full usage. +set -euo pipefail + +_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +_IMPL="${_REPO_ROOT}/phone_focus_mode/run_phone.sh" + +if [[ ! -x "${_IMPL}" ]]; then + printf 'ERROR: implementation script not found or not executable: %s\n' "${_IMPL}" >&2 + exit 1 +fi + +exec bash "${_IMPL}" "$@"