Extract phone_focus_mode together with focus_policy

github.com/kuhyx/phone-focus-mode

The two halves go as one repo: focus_policy parses phone_focus_mode's
config.sh into the typed policy that focus-owner consumes, and nothing
else references it. 77 commits of history, plus the two Python suites
from linux_configuration/tests/ and the six mutation fixtures from
meta/scripts/fixtures/, all of which named phone_focus_mode paths.

Running the suites -- not reading them -- turned up a real defect the
extraction would otherwise have inherited. config.sh sources the
untracked config_secrets.sh, and ctl_libs_harness.sh never pinned
HOME_LAT/HOME_LON, so the distance assertions were checking against
whatever real coordinates that file happened to hold. The suite passed on
this machine and could not run anywhere else, and a real home location
was load-bearing in a pass/fail condition. The harness now pins a pair
4300 m south of the coordinate its own case seeds, and config.sh sources
the secrets file only when it exists. 136/136 pass with no secrets file
present.

Before making the repo public I scanned the whole history for the known
home coordinates and for any committed config_secrets.sh: zero hits, and
the file was never tracked. The new repo also gained a .gitignore, since
none came across in the filter-repo pass.

test_magisk_service.sh hangs. It hangs identically in the pre-extraction
baseline worktree and appears in no CI list here, so the new repo's CI
skips it with that reason rather than pretending it passes.

check_python_location.sh loses its phone_focus_mode exception now that
the directory is gone; every tracked .py still passes it. The .gitignore
rules for config_secrets.sh and the keystore STAY -- the working copy is
still on this machine and still holds a real location.

Depth 262 -> 248.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 18:32:58 +02:00
parent cbdf3a3d85
commit a5209b6878
141 changed files with 115 additions and 17641 deletions

8
.gitignore vendored
View File

@ -352,16 +352,18 @@ python_pkg/*/cache/
# FVM Version Cache # FVM Version Cache
.fvm/ .fvm/
# Focus mode secrets (contains home GPS coordinates) # Focus mode secrets (contains home GPS coordinates). phone_focus_mode/ moved
# to its own repo, but the working copy is still on this machine and still
# holds a real location, so the ignore stays until the directory is gone.
phone_focus_mode/config_secrets.sh phone_focus_mode/config_secrets.sh
phone_focus_mode/focus_status_app/build
phone_focus_mode/focus_status_app/debug.keystore
# Generated output files # Generated output files
out.txt out.txt
# Build output symlinks (point to ../testsAndMisc_builds/) # Build output symlinks (point to ../testsAndMisc_builds/)
# Patterns without trailing / to match symlinks, not just directories # Patterns without trailing / to match symlinks, not just directories
phone_focus_mode/focus_status_app/build
phone_focus_mode/focus_status_app/debug.keystore
.worktrees .worktrees
.claude/ .claude/

View File

@ -133,7 +133,7 @@ repos:
language: script language: script
types: [shell] types: [shell]
require_serial: true require_serial: true
exclude: ^(\.git/|phone_focus_mode/lib/tests/|tests/) exclude: ^(\.git/|tests/)
- id: shell-coverage-ratchet - id: shell-coverage-ratchet
name: Every shell library has a test suite beside it name: Every shell library has a test suite beside it
# The ratchet from meta/shell-coverage-allowlist.txt: a lib under a # The ratchet from meta/shell-coverage-allowlist.txt: a lib under a

View File

@ -7,15 +7,3 @@
**/dist/ **/dist/
**/coverage/ **/coverage/
node_modules/ node_modules/
# Captured real-world payloads used as test fixtures. These must stay
# byte-faithful to what the device or service actually emits — a real Polish
# e-paragon arrives as minified single-line JSON, and the tests read the file
# as raw text, not only as parsed JSON. Pretty-printing one silently broke
# `accepts bare JPK payload and bare JWS token`, whose regex matches
# `"data":"..."` with no space after the colon.
billsplit/test/fixtures/
# Generated by `python3 -m python_pkg.focus_policy` from phone_focus_mode/config.sh.
# json.dumps cannot reproduce prettier's single-line-short-array style, so
# formatting it here would conflict with every regeneration.

View File

@ -9,7 +9,6 @@ Mixed-language monorepo: Python packages, Bash automation, and several apps.
| `python_pkg/` | Python packages — every `.py` in the repo belongs here or in an allowlisted dir | | `python_pkg/` | Python packages — every `.py` in the repo belongs here or in an allowlisted dir |
| `meta/` | Repo-wide tooling: `pyproject.toml`, `requirements.txt`, `run.sh`, `lint_python.sh`, `.fvmrc`, `scripts/` | | `meta/` | Repo-wide tooling: `pyproject.toml`, `requirements.txt`, `run.sh`, `lint_python.sh`, `.fvmrc`, `scripts/` |
| `linux_configuration/` | Arch setup, i3 config, system maintenance, and the tracked git hooks | | `linux_configuration/` | Arch setup, i3 config, system maintenance, and the tracked git hooks |
| `phone_focus_mode/` | GPS-based Android focus enforcer (Bash, ADB, Magisk) |
| `billsplit/` | Receipt bill-splitting Flutter app (Dart) | | `billsplit/` | Receipt bill-splitting Flutter app (Dart) |
| `bottles/` | Small standalone project | | `bottles/` | Small standalone project |
| `docs/` | Reference docs; `docs/superpowers/` holds AI workflow artifacts | | `docs/` | Reference docs; `docs/superpowers/` holds AI workflow artifacts |
@ -32,7 +31,9 @@ Extracted to their own repos: [`steam-backlog-enforcer`](https://github.com/kuhy
[`system-maintenance`](https://github.com/kuhyx/system-maintenance) (lives at [`system-maintenance`](https://github.com/kuhyx/system-maintenance) (lives at
`~/system-maintenance`), `~/system-maintenance`),
[`android-guardian`](https://github.com/kuhyx/android-guardian) (lives at [`android-guardian`](https://github.com/kuhyx/android-guardian) (lives at
`~/android-guardian`). `~/android-guardian`),
[`phone-focus-mode`](https://github.com/kuhyx/phone-focus-mode) (with
`python_pkg/focus_policy`).
Archived work: [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive). Archived work: [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
## Adding a Flutter app ## Adding a Flutter app
@ -47,8 +48,7 @@ committed. To add another, repeat **both** halves or the icons fail silently:
Icons come from the shared generator in `python_pkg/app_icons/`. Dart style is Icons come from the shared generator in `python_pkg/app_icons/`. Dart style is
gated by CI only (`.github/workflows/billsplit-ci.yml`), not by a local hook. gated by CI only (`.github/workflows/billsplit-ci.yml`), not by a local hook.
Keep app dirs free of Python: the `check-python-location` hook requires every Keep app dirs free of Python: the `check-python-location` hook requires every
`.py` to sit under `python_pkg/` (or `linux_configuration/`, `phone_focus_mode/`, `.py` to sit under `python_pkg/` (or `linux_configuration/`, `meta/scripts/`). `billsplit/`'s coverage gate therefore lives at
`meta/scripts/`). `billsplit/`'s coverage gate therefore lives at
`python_pkg/billsplit_coverage/`. `python_pkg/billsplit_coverage/`.
## Git Workflow ## Git Workflow

View File

@ -0,0 +1,21 @@
{
"title": "Extract phone_focus_mode with focus_policy into one public repo",
"objective": "Move the GPS focus enforcer and its Python policy half out together, since focus_policy parses phone_focus_mode's config.sh and is referenced by nothing else. The repo is public and the subject matter is a home location, so proving no coordinate leaks is part of the job, not an afterthought.",
"acceptance_criteria": [
"Both trees land in one public repo with full history and python_pkg.focus_policy imports rewritten.",
"The full git history contains no real home coordinate and no committed config_secrets.sh, verified BEFORE the repo is made public.",
"The new repo has a .gitignore covering config_secrets.sh; none came across in the filter-repo pass.",
"The Python suites pass from a clean clone: 161 tests.",
"The shell suites pass with NO config_secrets.sh present, which means the harness must pin its own coordinates instead of inheriting real ones.",
"config.sh can be sourced without a secrets file, so CI and a fresh clone work.",
"Any suite that does not pass is skipped in CI with a stated reason, and its behaviour is compared against the pre-extraction baseline to establish whether the split caused it.",
"The monorepo's remaining tests still pass and no config, hook or workflow names a removed path.",
"pre-commit passes without --no-verify."
],
"out_of_scope": [
"hosts and digital_wellbeing, deferred while the guard-lib repoint is pending.",
"Fixing test_magisk_service.sh, which was already broken and is not a regression from this split.",
"python-tests.yml's stale lichess_bot and tests/ path filters, absent since May 2026."
],
"verifier": "git log --all -p greps clean for the home coordinates; pytest reports 161 passed in the new repo; the shell suites run with the secrets file moved away; pytest linux_configuration/tests reports 178 passed here; check_shell_coverage.sh --all and check_python_location.sh over every .py both exit 0; check_directory_depth.sh --all drops 262 -> 248."
}

View File

@ -0,0 +1,54 @@
{
"intent": "Extract phone_focus_mode together with python_pkg/focus_policy into one public repo, and fix the test suite's hidden dependency on an untracked secrets file rather than carrying it across.",
"scope": [
"phone_focus_mode/ (109 files) + python_pkg/focus_policy/ (14) -> github.com/kuhyx/phone-focus-mode",
"2 Python suites from linux_configuration/tests/ and 6 mutation fixtures from meta/scripts/fixtures/mutations/ moved with them",
"conftest.py, meta/pyproject.toml, .pre-commit-config.yaml, .gitignore, .prettierignore, shell-coverage-allowlist.txt cleaned",
"check_python_location.sh: the phone_focus_mode exception removed now that the directory is gone",
"Non-goal: hosts and digital_wellbeing, deferred at the user's request"
],
"changes": [
"Extracted both trees in one filter-repo pass (77 commits), hoisting phone_focus_mode to the root, python_pkg/focus_policy to focus_policy/, and rewriting python_pkg.focus_policy imports plus the mutation fixtures' phone_focus_mode/ path prefix.",
"Gave the new repo a conftest putting the root and lib/ on sys.path, a pyproject, a .shellcheckrc and CI covering Python, shellcheck and the shell suites.",
"Added a .gitignore to the new repo: none came across in the filter-repo pass and the repo is public. config_secrets.sh is ignored.",
"ctl_libs_harness.sh now pins HOME_LAT/HOME_LON to a pair 4300 m south of the coordinate its case seeds, instead of inheriting whatever config_secrets.sh holds.",
"config.sh sources config_secrets.sh only when it exists, so a clean clone and CI can run.",
"Tightened check_python_location.sh and the AGENTS.md rule it documents, dropping the exception for a directory that no longer exists."
],
"verification": [
{
"command": "python -m pytest tests focus_policy -q (extracted repo)",
"result": "pass",
"evidence": "161 passed."
},
{
"command": "bash lib/tests/test_*.sh with NO config_secrets.sh present",
"result": "pass",
"evidence": "10 of 11 suites pass, including test_ctl_libs.sh at 136/136. Before the fix it failed 'status distance should be ~4300 m, got 25555' with a placeholder secrets file and could not run at all without one."
},
{
"command": "bash lib/tests/test_magisk_service.sh, in the extracted repo AND the pre-extraction baseline worktree",
"result": "fail in both (pre-existing)",
"evidence": "Times out (exit 124) in the monorepo baseline too, and is named in no CI list there. Carried across knowingly; the new CI skips it explicitly with that reason rather than hiding it."
},
{
"command": "git log --all -p | grep for the known home coordinates; git log for config_secrets.sh",
"result": "pass",
"evidence": "Zero matches across all history and the file was never committed, checked before the repo was made public. Remaining coordinate literals are the Warsaw city-centre test fixture (52.2297, 21.0122)."
},
{
"command": "pytest linux_configuration/tests; check_shell_coverage.sh --all; check_python_location.sh over every tracked .py",
"result": "pass",
"evidence": "178 passed; coverage gate exit 0; all Python files pass the tightened location hook. Depth 262 -> 248."
}
],
"risks": [
"test_magisk_service.sh remains broken. It is skipped in CI with a stated reason, not silently dropped, but it is a real gap the new repo inherits.",
"python-tests.yml still filters on python_pkg/lichess_bot and tests/, neither of which has existed since the May 2026 archive commit. Pre-existing and harmless (a filter on a missing path just never matches); left alone rather than widening this change.",
"The distance assertion now encodes a synthetic home 4300 m from the fixture. If the seeded fix ever changes, the pinned pair must change with it - the harness comment says so."
],
"rollback": [
"git revert to restore both trees, the two tests and the mutation fixtures, then re-add the conftest entries, pylint sys.path elements, pre-commit exclude and the location-hook exception.",
"Re-run pytest linux_configuration/tests and check_shell_coverage.sh --all."
]
}

View File

@ -23,8 +23,6 @@ _REPO_ROOT = Path(__file__).resolve().parents[2]
# Each standalone script directory whose Python modules these tests import. # Each standalone script directory whose Python modules these tests import.
_SCRIPT_DIRS = ( _SCRIPT_DIRS = (
_REPO_ROOT / "meta" / "scripts", # validate_evidence, validate_contract _REPO_ROOT / "meta" / "scripts", # validate_evidence, validate_contract
_REPO_ROOT / "phone_focus_mode" / "lib", # monitor_report
_REPO_ROOT / "phone_focus_mode", # strip_workout_hosts
_REPO_ROOT _REPO_ROOT
/ "linux_configuration" / "linux_configuration"
/ "scripts" / "scripts"

View File

@ -1,156 +0,0 @@
"""Tests for phone_focus_mode/lib/monitor_report.py (report summary/severity)."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import monitor_report
from monitor_report import _field, _has_severe, _load_checks, _render_summary, main
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _write(tmp_path: Path, obj: object) -> Path:
"""Write ``obj`` as JSON to a temp file and return its path."""
path = tmp_path / "report.json"
path.write_text(json.dumps(obj), encoding="utf-8")
return path
class TestLoadChecks:
"""``_load_checks`` tolerates malformed reports."""
def test_reads_checks(self, tmp_path: Path) -> None:
"""A well-formed report yields its checks list."""
path = _write(tmp_path, {"checks": [{"status": "ok"}]})
assert _load_checks(path) == [{"status": "ok"}]
def test_non_dict_report_yields_empty(self, tmp_path: Path) -> None:
"""A non-object report yields no checks."""
assert _load_checks(_write(tmp_path, [1, 2])) == []
def test_non_list_checks_yields_empty(self, tmp_path: Path) -> None:
"""A non-list ``checks`` value yields no checks."""
assert _load_checks(_write(tmp_path, {"checks": "nope"})) == []
class TestField:
"""``_field`` reads string fields with a default."""
def test_reads_present_string(self) -> None:
"""A present string field is returned."""
assert _field({"status": "ok"}, "status", "warn") == "ok"
def test_default_when_missing(self) -> None:
"""A missing field falls back to the default."""
assert _field({}, "status", "warn") == "warn"
def test_default_when_not_string(self) -> None:
"""A non-string field falls back to the default."""
assert _field({"status": 5}, "status", "warn") == "warn"
def test_default_when_check_not_dict(self) -> None:
"""A non-dict check falls back to the default."""
assert _field("nope", "status", "warn") == "warn"
class TestRenderSummary:
"""``_render_summary`` produces the counts header and issue list."""
def test_counts_and_issues(self) -> None:
"""Counts and a per-issue line are rendered."""
checks: list[object] = [
{"status": "ok", "check": "a", "message": ""},
{"status": "warn", "check": "b", "message": "drift"},
{"status": "error", "check": "c", "message": "boom"},
]
out = _render_summary(checks)
assert "ok=1" in out
assert "warn=1" in out
assert "error=1" in out
assert "[warn] b: drift" in out
assert "[error] c: boom" in out
def test_no_issues_section_when_all_ok(self) -> None:
"""With no problems, the issues section is omitted."""
out = _render_summary([{"status": "ok", "check": "a", "message": ""}])
assert "Issues found:" not in out
# Footer line plus a trailing blank line, matching the original output.
assert out.endswith("==========================\n\n")
class TestHasSevere:
"""``_has_severe`` flags fatal/error checks."""
def test_true_for_error(self) -> None:
"""An error status is severe."""
assert _has_severe([{"status": "error"}]) is True
def test_true_for_fatal(self) -> None:
"""A fatal status is severe."""
assert _has_severe([{"status": "fatal"}]) is True
def test_false_for_ok_warn(self) -> None:
"""ok/warn statuses are not severe."""
assert _has_severe([{"status": "ok"}, {"status": "warn"}]) is False
class TestMain:
"""The CLI dispatches on mode and reports severity via exit code."""
def test_bad_usage_returns_2(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""An unknown mode is a usage error (rc 2)."""
monkeypatch.setattr(
monitor_report.sys, "argv", ["monitor_report", "bogus", "x"]
)
assert main() == 2
def test_summary_prints_and_returns_0(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Summary mode prints the report and returns 0."""
path = _write(
tmp_path, {"checks": [{"status": "warn", "check": "b", "message": "m"}]}
)
monkeypatch.setattr(
monitor_report.sys,
"argv",
["monitor_report", "summary", str(path)],
)
assert main() == 0
assert "Monitoring Summary" in capsys.readouterr().out
def test_severity_returns_1_on_error(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Severity mode returns 1 when a severe check exists."""
path = _write(tmp_path, {"checks": [{"status": "error"}]})
monkeypatch.setattr(
monitor_report.sys,
"argv",
["monitor_report", "severity", str(path)],
)
assert main() == 1
def test_severity_returns_0_when_clean(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Severity mode returns 0 when no severe check exists."""
path = _write(tmp_path, {"checks": [{"status": "ok"}]})
monkeypatch.setattr(
monitor_report.sys,
"argv",
["monitor_report", "severity", str(path)],
)
assert main() == 0

View File

@ -1,139 +0,0 @@
"""Tests for phone_focus_mode/strip_workout_hosts.py (hosts workout variant)."""
from __future__ import annotations
from typing import TYPE_CHECKING
import strip_workout_hosts
from strip_workout_hosts import _strip, main
if TYPE_CHECKING:
from pathlib import Path
import pytest
_HOSTS = """\
# header
127.0.0.1 localhost
0.0.0.0 youtube.com www.youtube.com
0.0.0.0 ads.example.com
0.0.0.0 m.youtube.com alias.youtube.com
0.0.0.0 keepme.com
"""
def _src(tmp_path: Path) -> Path:
"""Write the sample hosts file and return its path."""
path = tmp_path / "hosts"
path.write_text(_HOSTS, encoding="utf-8")
return path
class TestStrip:
"""``_strip`` removes lines mapping unblocked domains."""
def test_removes_matching_and_alias_lines(self, tmp_path: Path) -> None:
"""Entries whose name or any alias is unblocked are dropped."""
dest = tmp_path / "out"
_strip(
_src(tmp_path),
dest,
frozenset({"youtube.com", "www.youtube.com", "m.youtube.com"}),
)
result = dest.read_text(encoding="utf-8")
assert "youtube.com" not in result # both youtube entries gone
assert "ads.example.com" in result
assert "keepme.com" in result
assert "localhost" in result
assert "# header" in result
def test_empty_unblock_keeps_everything(self, tmp_path: Path) -> None:
"""An empty unblock set copies the file verbatim."""
dest = tmp_path / "out"
_strip(_src(tmp_path), dest, frozenset())
assert dest.read_text(encoding="utf-8") == _HOSTS
def test_comment_and_blank_lines_preserved(self, tmp_path: Path) -> None:
"""Comment and blank lines are never stripped."""
src = tmp_path / "hosts"
src.write_text("# c\n\n0.0.0.0 block.me\n", encoding="utf-8")
dest = tmp_path / "out"
_strip(src, dest, frozenset({"block.me"}))
assert dest.read_text(encoding="utf-8") == "# c\n\n"
class TestMain:
"""The CLI reads paths from argv and domains from the environment."""
def test_bad_arg_count_returns_2(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A wrong number of path arguments is a usage error (rc 2)."""
monkeypatch.setattr(
strip_workout_hosts.sys, "argv", ["strip_workout_hosts", "only-one"]
)
assert main() == 2
def test_strips_via_env(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Domains from the env var drive the stripping."""
src = _src(tmp_path)
dest = tmp_path / "out"
monkeypatch.setenv("WORKOUT_UNBLOCK_DOMAINS", "youtube.com m.youtube.com")
monkeypatch.setattr(
strip_workout_hosts.sys,
"argv",
["strip_workout_hosts", str(src), str(dest)],
)
assert main() == 0
result = dest.read_text(encoding="utf-8")
assert "www.youtube.com" not in result
assert "keepme.com" in result
class TestSubdomainMatching:
"""Regression tests for the exact-match bug fixed on 2026-08-05.
The original implementation tested ``name.lower() in unblock``, i.e. exact
set membership. YouTube streams video from dynamically named CDN hosts, so
an allowlist entry of ``googlevideo.com`` released nothing that actually
carried video and playback stayed blocked during workouts.
"""
def test_releases_dynamic_cdn_subdomains(self, tmp_path: Path) -> None:
"""A parent domain releases the CDN hosts beneath it."""
src = tmp_path / "hosts"
src.write_text(
"0.0.0.0 googlevideo.com\n"
"0.0.0.0 r1---sn-4g5e6nls.googlevideo.com\n"
"0.0.0.0 r1---sn-4g5lne7s.googlevideo.com\n"
"0.0.0.0 keepme.com\n",
encoding="utf-8",
)
dest = tmp_path / "out"
_strip(src, dest, frozenset({"googlevideo.com"}))
result = dest.read_text(encoding="utf-8")
assert "googlevideo.com" not in result
assert "keepme.com" in result
def test_does_not_release_lookalike_domains(self, tmp_path: Path) -> None:
"""Suffix matching must not be fooled by substring similarity."""
src = tmp_path / "hosts"
src.write_text(
"0.0.0.0 notgooglevideo.com\n0.0.0.0 googlevideo.com.evil.net\n",
encoding="utf-8",
)
dest = tmp_path / "out"
_strip(src, dest, frozenset({"googlevideo.com"}))
result = dest.read_text(encoding="utf-8")
assert "notgooglevideo.com" in result
assert "googlevideo.com.evil.net" in result
def test_case_and_trailing_dot_are_normalised(self, tmp_path: Path) -> None:
"""Host names are compared case-insensitively and without the root dot."""
src = tmp_path / "hosts"
src.write_text("0.0.0.0 WWW.YouTube.COM.\n", encoding="utf-8")
dest = tmp_path / "out"
_strip(src, dest, frozenset({"youtube.com"}))
assert dest.read_text(encoding="utf-8") == ""

View File

@ -170,7 +170,7 @@ disallow_untyped_decorators = false
# bare name (the same dirs linux_configuration/tests/conftest.py adds to # bare name (the same dirs linux_configuration/tests/conftest.py adds to
# sys.path at runtime) resolve under static analysis instead of raising E0401. # sys.path at runtime) resolve under static analysis instead of raising E0401.
# Paths are relative to the repo root, which is pre-commit's working directory. # Paths are relative to the repo root, which is pre-commit's working directory.
init-hook = "import sys; sys.path[:0] = ['meta/scripts', 'python_pkg', 'phone_focus_mode', 'phone_focus_mode/lib', 'linux_configuration/scripts/single_use/utils', 'linux_configuration/scripts/periodic_background/digital_wellbeing', 'linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools']" init-hook = "import sys; sys.path[:0] = ['meta/scripts', 'python_pkg', 'linux_configuration/scripts/single_use/utils', 'linux_configuration/scripts/periodic_background/digital_wellbeing', 'linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools']"
# Analyse import fallback blocks # Analyse import fallback blocks
analyse-fallback-blocks = true analyse-fallback-blocks = true
# Pickle collected data for later comparisons # Pickle collected data for later comparisons

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Check that all Python files are under python_pkg/. # Check that all Python files are under python_pkg/.
# Exceptions: linux_configuration/, phone_focus_mode/, Bash/, # Exceptions: linux_configuration/, Bash/,
# and vendored/generated directories. # and vendored/generated directories.
# Used as a pre-commit hook; receives staged file paths as arguments. # Used as a pre-commit hook; receives staged file paths as arguments.
@ -9,36 +9,39 @@ set -uo pipefail
errors=() errors=()
for file in "$@"; do for file in "$@"; do
# Only check .py files # Only check .py files
[[ "$file" != *.py ]] && continue [[ "$file" != *.py ]] && continue
# Skip files already under python_pkg/ # Skip files already under python_pkg/
[[ "$file" == python_pkg/* ]] && continue [[ "$file" == python_pkg/* ]] && continue
# Skip allowed directories (non-Python projects with some Python scripts) # Skip allowed directories (non-Python projects with some Python scripts)
case "$file" in case "$file" in
linux_configuration/*|phone_focus_mode/*|scripts/*|meta/scripts/*) continue ;; linux_configuration/* | scripts/* | meta/scripts/*) continue ;;
esac esac
# Skip vendored/generated directories # Skip vendored/generated directories
skip=0 skip=0
for part in /.venv/ /venv/ /__pycache__/ /build/ /dist/ /node_modules/ /.git/; do for part in /.venv/ /venv/ /__pycache__/ /build/ /dist/ /node_modules/ /.git/; do
case "/$file" in case "/$file" in
*"$part"*) skip=1; break ;; *"$part"*)
esac skip=1
done break
[[ $skip -eq 1 ]] && continue ;;
esac
done
[[ $skip -eq 1 ]] && continue
errors+=("$file") errors+=("$file")
done done
if [[ ${#errors[@]} -gt 0 ]]; then if [[ ${#errors[@]} -gt 0 ]]; then
echo "ERROR: Python files must be under python_pkg/." echo "ERROR: Python files must be under python_pkg/."
echo "The following files are in the wrong location:" echo "The following files are in the wrong location:"
for err in "${errors[@]}"; do for err in "${errors[@]}"; do
echo " $err" echo " $err"
done done
echo "" echo ""
echo "Move them with: git mv <file> python_pkg/<file>" echo "Move them with: git mv <file> python_pkg/<file>"
exit 1 exit 1
fi fi

View File

@ -1,84 +0,0 @@
{
"test": "phone_focus_mode/lib/tests/test_backup_capture.sh",
"timeout": 180,
"mutations": [
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "sed 's/^package://'",
"replace": "cat",
"describe": "stop stripping the package: prefix"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "tr -d '\\r'",
"replace": "cat",
"describe": "stop stripping carriage returns"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "comm -13",
"replace": "comm -12",
"describe": "invert the purged-package derivation"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "pm list packages -3",
"replace": "pm list packages",
"describe": "dump all packages as third-party"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "mapfile -t pkgs <\"${dir}/packages-third-party.txt\"",
"replace": "read -r pkgs <\"${dir}/packages-third-party.txt\"",
"describe": "reintroduce the stdin-draining apk bug"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "for path in \"${paths[@]}\"; do",
"replace": "for path in \"${paths[0]}\"; do",
"describe": "pull only the first split of a split app"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "_warn \"no APK path for ${pkg}\"",
"replace": ":",
"describe": "silence the missing-APK-path warning"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "_warn \"could not pull ${base} for ${pkg}\"",
"replace": ":",
"describe": "silence the failed-pull warning"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "_warn \"partial or failed pull: ${d}\"",
"replace": ":",
"describe": "silence the sdcard partial-pull warning"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "if adb_cmd shell \"test -d /sdcard/${d}\"",
"replace": "if ! adb_cmd shell \"test -d /sdcard/${d}\"",
"describe": "invert the sdcard directory probe"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "for ns in system secure global; do",
"replace": "for ns in system; do",
"describe": "drop two settings namespaces"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "grep -qx 'com.celzero.bravedns'",
"replace": "grep -qvx 'com.celzero.bravedns'",
"describe": "invert the RethinkDNS detection"
},
{
"file": "phone_focus_mode/lib/backup_capture.sh",
"find": "mkdir -p \"${dir}/sdcard/keepass\"",
"replace": ":",
"describe": "stop creating the keepass directory"
}
]
}

View File

@ -1,138 +0,0 @@
{
"test": "phone_focus_mode/lib/tests/test_ctl_libs.sh",
"timeout": 300,
"mutations": [
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "[ \"$now\" -ge \"$start\" ] || [ \"$now\" -lt \"$end\" ]",
"replace": "[ \"$now\" -ge \"$start\" ] && [ \"$now\" -lt \"$end\" ]",
"describe": "break the midnight-wrapping curfew window"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "case \"$now\" in '' | *[!0-9]*) return 1 ;; esac",
"replace": "case \"$now\" in '' | *[!0-9]*) return 0 ;; esac",
"describe": "fail CLOSED on a broken clock instead of open"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "while [ \"${n#0}\" != \"$n\" ] && [ \"${#n}\" -gt 1 ]; do n=\"${n#0}\"; done",
"replace": ":",
"describe": "stop stripping leading zeros (octal parse)"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "\ttouch \"$CURFEW_FORCE_FILE\"",
"replace": "\t:",
"describe": "curfew-test-on stops creating the force file"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "cmd_curfew_off() {\n\ttouch \"$CURFEW_OVERRIDE_FILE\"",
"replace": "cmd_curfew_off() {\n\t:",
"describe": "curfew-off stops creating the 2am escape hatch"
},
{
"file": "phone_focus_mode/ctl_curfew.sh",
"find": "cmd_curfew_on() {\n\trm -f \"$CURFEW_OVERRIDE_FILE\"",
"replace": "cmd_curfew_on() {\n\t:",
"describe": "curfew-on stops clearing the override"
},
{
"file": "phone_focus_mode/ctl_tether.sh",
"find": "\ttouch \"$TETHER_FORCE_FILE\"",
"replace": "\t:",
"describe": "tether-test-on stops creating the force file"
},
{
"file": "phone_focus_mode/ctl_tether.sh",
"find": "cmd_tether_test_off() {\n\trm -f \"$TETHER_FORCE_FILE\"",
"replace": "cmd_tether_test_off() {\n\t:",
"describe": "tether-test-off stops clearing the force file"
},
{
"file": "phone_focus_mode/ctl_dns.sh",
"find": "if iptables -L \"$DNS_IPT_CHAIN\" >/dev/null 2>&1; then",
"replace": "if ! iptables -L \"$DNS_IPT_CHAIN\" >/dev/null 2>&1; then",
"describe": "invert the v4 DNS chain presence check"
},
{
"file": "phone_focus_mode/ctl_dns.sh",
"find": "if ip6tables -L \"$DNS_IPT_CHAIN\" >/dev/null 2>&1; then",
"replace": "if ! ip6tables -L \"$DNS_IPT_CHAIN\" >/dev/null 2>&1; then",
"describe": "invert the v6 DNS chain presence check"
},
{
"file": "phone_focus_mode/ctl_dns.sh",
"find": "${mode:-<unset>}",
"replace": "${mode}",
"describe": "render an unset private DNS mode as blank"
},
{
"file": "phone_focus_mode/ctl_hosts.sh",
"find": "cmd_hosts_start() {\n\tlocal pid\n\tpid=\"$(hosts_enforcer_pid)\"\n\tif [ -n \"$pid\" ]; then",
"replace": "cmd_hosts_start() {\n\tlocal pid\n\tpid=\"$(hosts_enforcer_pid)\"\n\tif [ -z \"$pid\" ]; then",
"describe": "invert the hosts already-running guard"
},
{
"file": "phone_focus_mode/ctl_workout.sh",
"find": "if [ ! -x \"$WORKOUT_SQLITE3_BIN\" ]; then",
"replace": "if [ -x \"$WORKOUT_SQLITE3_BIN\" ]; then",
"describe": "invert the sqlite3 precondition"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "local mode=\"unknown\"",
"replace": "local mode=\"\"",
"describe": "report an absent mode as blank rather than unknown"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "echo \"Location: unavailable\"",
"replace": ":",
"describe": "stay silent when no GPS fix is available"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "R=6371000",
"replace": "R=1",
"describe": "wrong Earth radius in cmd_status's Haversine (awk kcov cannot see)"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "pm enable \"$pkg\" >/dev/null 2>&1 && count=$((count + 1))",
"replace": "count=$((count + 1))",
"describe": "count apps as restored without enabling them"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "\t\t: >\"$DISABLED_APPS_FILE\"",
"replace": "\t\t:",
"describe": "leave the disabled record in place after restoring"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "\ttouch \"$RECHECK_TRIGGER\"",
"replace": "\t:",
"describe": "recheck stops writing the trigger"
},
{
"file": "phone_focus_mode/ctl_daemon.sh",
"find": "echo \"Daemon not running - start it first with: focus_ctl.sh start\"\n\t\treturn 1",
"replace": "echo \"Daemon not running - start it first with: focus_ctl.sh start\"",
"describe": "recheck continues after reporting no daemon"
},
{
"file": "phone_focus_mode/ctl_usage.sh",
"find": "\t\t*.*) ;;",
"replace": "\t\t*) ;;",
"describe": "accept non-dotted tokens as package names"
},
{
"file": "phone_focus_mode/ctl_usage.sh",
"find": "\t\tcase \"$pkg\" in\n\t\t*[!A-Za-z0-9._]*) continue ;;\n\t\tesac",
"replace": "\t\t:",
"describe": "accept packages containing illegal characters"
}
]
}

View File

@ -1,108 +0,0 @@
{
"test": "phone_focus_mode/lib/tests/test_curfew_net.sh",
"timeout": 180,
"mutations": [
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "-I OUTPUT 1 -j",
"replace": "-I OUTPUT 2 -j",
"describe": "stop pinning the OUTPUT jump at position 1"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "iptw \"$ipt\" -N \"$CURFEW_NET_IPT_CHAIN\" 2>/dev/null || return 1",
"replace": "iptw \"$ipt\" -N \"$CURFEW_NET_IPT_CHAIN\" 2>/dev/null || true",
"describe": "swallow a failed chain creation"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "iptw \"$ipt\" -I OUTPUT 1 -j \"$CURFEW_NET_IPT_CHAIN\" 2>/dev/null || return 1",
"replace": "iptw \"$ipt\" -I OUTPUT 1 -j \"$CURFEW_NET_IPT_CHAIN\" 2>/dev/null || true",
"describe": "swallow a failed jump insert"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "iptw \"$ipt\" -F \"$CURFEW_NET_IPT_CHAIN\" 2>/dev/null || return 1",
"replace": "iptw \"$ipt\" -F \"$CURFEW_NET_IPT_CHAIN\" 2>/dev/null || true",
"describe": "swallow a failed flush and layer onto stale rules"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "-o lo -j ACCEPT",
"replace": "-o lo -j REJECT",
"describe": "reject loopback"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "--state ESTABLISHED,RELATED -j ACCEPT",
"replace": "--state ESTABLISHED,RELATED -j REJECT",
"describe": "reject established flows"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "--uid-owner 0 -j ACCEPT",
"replace": "--uid-owner 0 -j REJECT",
"describe": "cut off root"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "-p udp --dport 53 -j ACCEPT",
"replace": "-p udp --dport 5353 -j ACCEPT",
"describe": "allow the wrong DNS port"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "--uid-owner 10000-19999 -j REJECT",
"replace": "--uid-owner 10000-19999 -j ACCEPT",
"describe": "let every app through instead of cutting it off"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "case \"$uid\" in ''|*[!0-9]*) continue ;; esac",
"replace": ":",
"describe": "pass non-numeric uids to iptables"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "$1 == p {",
"replace": "$1 != p {",
"describe": "invert the package-to-uid match"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "mv \"$CURFEW_NET_UID_CACHE.tmp\" \"$CURFEW_NET_UID_CACHE\"",
"replace": "cp \"$CURFEW_NET_UID_CACHE.tmp\" \"$CURFEW_NET_UID_CACHE\"",
"describe": "leave the uid cache temp file behind"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "rebuilds=$((rebuilds + 1))",
"replace": ":",
"describe": "stop counting net_hold rebuilds"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "[ \"${CURFEW_NET_ENABLED:-0}\" = \"1\" ] || return 0",
"replace": ":",
"describe": "ignore the net-layer disable switch"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "NET_BUILT=1",
"replace": ":",
"describe": "never mark the chain as built"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "iptw \"$ipt\" -X \"$CURFEW_NET_IPT_CHAIN\" 2>/dev/null || true",
"replace": ":",
"describe": "leave the chain behind on teardown"
},
{
"file": "phone_focus_mode/curfew_net.sh",
"find": "rm -f \"$map\"",
"replace": ":",
"describe": "leak the uid map temp file"
}
]
}

View File

@ -1,204 +0,0 @@
{
"test": "phone_focus_mode/lib/tests/test_daemon_libs.sh",
"timeout": 300,
"mutations": [
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "R = 6371000.0",
"replace": "R = 1.0",
"describe": "wrong Earth radius in the Haversine (awk body kcov cannot measure)"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "sdlat * sdlat + cos(lat1) * cos(lat2) * sdlon * sdlon",
"replace": "sdlat * sdlat",
"describe": "drop the longitude term from the Haversine"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "PI / 180.0",
"replace": "PI",
"describe": "skip the degrees-to-radians conversion"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "head -1",
"replace": "tail -1",
"describe": "take the stalest GPS fix instead of the freshest"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "[0-9]{4,}",
"replace": "[0-9]{1,}",
"describe": "accept imprecise coordinates as a real fix"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "[ \"$now\" -ge \"$start\" ] || [ \"$now\" -lt \"$end\" ]",
"replace": "[ \"$now\" -ge \"$start\" ] && [ \"$now\" -lt \"$end\" ]",
"describe": "break the midnight-wrapping curfew window"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "[ \"$now\" -ge \"$start\" ] && [ \"$now\" -lt \"$end\" ]\n\telse",
"replace": "[ \"$now\" -gt \"$start\" ] && [ \"$now\" -lt \"$end\" ]\n\telse",
"describe": "exclude the start minute of a same-day window"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "'' | *[!0-9]*) return 1 ;;",
"replace": "'' | *[!0-9]*) return 0 ;;",
"describe": "fail CLOSED on a broken clock instead of open"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "while [ \"${n#0}\" != \"$n\" ] && [ \"${#n}\" -gt 1 ]; do n=\"${n#0}\"; done",
"replace": ":",
"describe": "stop stripping leading zeros (octal parse)"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "[ ! -e \"$CURFEW_OVERRIDE_FILE\" ]",
"replace": "[ -e \"$CURFEW_OVERRIDE_FILE\" ]",
"describe": "invert the curfew override"
},
{
"file": "phone_focus_mode/daemon_location.sh",
"find": "_CURFEW_TICK_CACHED=1",
"replace": ":",
"describe": "never set the per-tick curfew memo"
},
{
"file": "phone_focus_mode/daemon_state.sh",
"find": "[ \"${n:-0}\" -lt 30 ]",
"replace": "[ \"${n:-0}\" -lt 0 ]",
"describe": "never warn about a truncated whitelist"
},
{
"file": "phone_focus_mode/daemon_state.sh",
"find": "[ \"${n:-0}\" -lt 10 ]",
"replace": "[ \"${n:-0}\" -lt 0 ]",
"describe": "never warn about a truncated night whitelist"
},
{
"file": "phone_focus_mode/daemon_state.sh",
"find": "grep -v '^[[:space:]]*#'",
"replace": "cat",
"describe": "keep comment lines as whitelist entries"
},
{
"file": "phone_focus_mode/daemon_state.sh",
"find": "cut -d/ -f1",
"replace": "cat",
"describe": "record the IME component instead of its package"
},
{
"file": "phone_focus_mode/daemon_state.sh",
"find": "[ \"$sms\" != \"null\" ]",
"replace": "true",
"describe": "record a literal null as the SMS handler"
},
{
"file": "phone_focus_mode/daemon_state.sh",
"find": "grep -qxF",
"replace": "grep -qF",
"describe": "match default handlers by substring instead of exactly"
},
{
"file": "phone_focus_mode/daemon_state.sh",
"find": "sort -u \"$tmp\" -o \"$f\"",
"replace": ":",
"describe": "never write the default-handler file"
},
{
"file": "phone_focus_mode/daemon_state.sh",
"find": "if curfew_active; then cf=1; else cf=0; fi",
"replace": "cf=0",
"describe": "always report curfew inactive in the snapshot"
},
{
"file": "phone_focus_mode/daemon_state.sh",
"find": "\"$DISABLED_APPS_FILE\" 2>/dev/null | tr -d ' ' || echo 0",
"replace": "/dev/null 2>/dev/null | tr -d ' ' || echo 0",
"describe": "always report zero disabled apps"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "list=\"$STATE_DIR/night_whitelist.txt\"",
"replace": "list=\"$STATE_DIR/whitelist.txt\"",
"describe": "use the permissive day list during the night curfew"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "if grep -qxF \"$pkg\" \"$list\" 2>/dev/null; then",
"replace": "if grep -qF \"$pkg\" \"$list\" 2>/dev/null; then",
"describe": "match the whitelist by substring instead of exactly"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "\"$prefix\"*) return 0 ;;",
"replace": "*\"$prefix\"*) return 0 ;;",
"describe": "match sysprotect anywhere instead of as a prefix"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "is_default_handler \"$pkg\" && return 0",
"replace": ":",
"describe": "drop the default-handler safety net"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "if ! curfew_active &&",
"replace": "if true &&",
"describe": "let the browser guard resurrect a browser during curfew"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "done <\"$tmp_pkgs\"",
"replace": "done </dev/null",
"describe": "sweep no packages (proves the redirect line kcov misses runs)"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "done <\"$STATE_DIR/sysprotect.txt\"",
"replace": "done </dev/null",
"describe": "read no sysprotect prefixes (same redirect class)"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "done <\"$blocked_sys\"",
"replace": "done </dev/null",
"describe": "disable no blocked system apps (same redirect class)"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "newly_disabled=$((newly_disabled + 1))",
"replace": ":",
"describe": "stop counting newly disabled apps"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "CURRENT_MODE=\"focus\"",
"replace": ":",
"describe": "never record entry into focus mode"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "CURRENT_MODE=\"normal\"",
"replace": ":",
"describe": "never record the exit from focus mode"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "mv \"$tmp_disabled\" \"$DISABLED_APPS_FILE\"",
"replace": ":",
"describe": "never apply the reconciliation result"
},
{
"file": "phone_focus_mode/daemon_apps.sh",
"find": "pm install-existing --user 0 \"$pkg\" >/dev/null 2>&1 || true",
"replace": ":",
"describe": "skip reinstalling for user 0 before enabling"
}
]
}

View File

@ -1,108 +0,0 @@
{
"test": "phone_focus_mode/lib/tests/test_hosts_libs.sh",
"timeout": 180,
"mutations": [
{
"file": "phone_focus_mode/hosts_mount.sh",
"find": "[ -n \"$target_hash\" ] && [ \"$target_hash\" = \"$canonical_hash\" ]",
"replace": "true",
"describe": "call any target correctly mounted"
},
{
"file": "phone_focus_mode/hosts_mount.sh",
"find": "[ -n \"$target_hash\" ] && ",
"replace": "",
"describe": "treat a missing target as mounted"
},
{
"file": "phone_focus_mode/hosts_mount.sh",
"find": "[ \"$attempts\" -ge 5 ]",
"replace": "[ \"$attempts\" -ge 500 ]",
"describe": "raise the unmount give-up threshold"
},
{
"file": "phone_focus_mode/hosts_mount.sh",
"find": "mount -o remount,ro \"$system_mount\"",
"replace": ":",
"describe": "leave /system mounted read-write"
},
{
"file": "phone_focus_mode/hosts_mount.sh",
"find": "chattr +i \"$HOSTS_TARGET\"",
"replace": ":",
"describe": "never re-lock the hosts target"
},
{
"file": "phone_focus_mode/hosts_mount.sh",
"find": "log \"Bind mount reported success but target still mismatches - unmounting\"",
"replace": ":",
"describe": "silence the no-op bind detection"
},
{
"file": "phone_focus_mode/hosts_mount.sh",
"find": "\n return 1\n}",
"replace": "\n return 0\n}",
"describe": "claim success when nothing fixed the target"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "if [ \"$module_hash\" != \"$canonical_hash\" ]; then",
"replace": "if true; then",
"describe": "rewrite the module on every poll"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "\n protect_magisk_module\n}",
"replace": "\n :\n}",
"describe": "stop re-locking the module directory"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "for marker in disable remove update; do",
"replace": "for marker in disable; do",
"describe": "ignore the remove and update markers"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "removed=$((removed + 1))",
"replace": ":",
"describe": "stop counting removed markers"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "chattr +i \"$module_dir\"",
"replace": ":",
"describe": "never lock the module directory"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "[ -f \"${profile_dir}prefs.js\" ] || continue",
"replace": ":",
"describe": "write to non-profile directories"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "network.trr.mode\", 5",
"replace": "network.trr.mode\", 0",
"describe": "set the DoH pref to the wrong mode"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "chattr +i \"$HOSTS_CANONICAL\"",
"replace": ":",
"describe": "leave the canonical mutable"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "if [ \"$current_pid\" = \"$last_pid\" ]; then",
"replace": "if false; then",
"describe": "restart netd on every tick"
},
{
"file": "phone_focus_mode/hosts_magisk.sh",
"find": "echo \"${new_pid:-$current_pid}\" > \"$stamp_file\"",
"replace": ":",
"describe": "never stamp the netd pid"
}
]
}

View File

@ -1,114 +0,0 @@
{
"test": "phone_focus_mode/lib/tests/test_monitor.sh",
"timeout": 180,
"mutations": [
{
"file": "phone_focus_mode/lib/monitor.sh",
"find": "escaped=${escaped//\\\"/\\\\\\\"}",
"replace": ":",
"describe": "drop the JSON quote escaping"
},
{
"file": "phone_focus_mode/lib/monitor.sh",
"find": "escaped=${escaped//$'\\n'/\\\\n}",
"replace": ":",
"describe": "drop the newline escaping"
},
{
"file": "phone_focus_mode/lib/monitor.sh",
"find": "(( missing_count >= FORMAT_DETECTION_MIN_MISSING ))",
"replace": "(( missing_count > FORMAT_DETECTION_MIN_MISSING ))",
"describe": "off-by-one the format threshold"
},
{
"file": "phone_focus_mode/lib/monitor.sh",
"find": "adb_root_shell \"kill -0 ${pid} >/dev/null 2>&1\" >/dev/null 2>&1 || return 1",
"replace": "adb_root_shell \"kill -0 ${pid} >/dev/null 2>&1\" >/dev/null 2>&1 || return 0",
"describe": "make pid_matches_script always succeed"
},
{
"file": "phone_focus_mode/lib/monitor.sh",
"find": "printf '%s' \"${candidate}\"",
"replace": "printf ''",
"describe": "make hosts candidate resolution return empty"
},
{
"file": "phone_focus_mode/lib/monitor_checks_health.sh",
"find": "status=\"warn\"",
"replace": "status=\"ok\"",
"describe": "silence every health warning"
},
{
"file": "phone_focus_mode/lib/monitor_checks_health.sh",
"find": "status=\"fatal\"",
"replace": "status=\"ok\"",
"describe": "silence the format-indicator fatal"
},
{
"file": "phone_focus_mode/lib/monitor_checks_health.sh",
"find": "(( level < BATTERY_WARN_BELOW ))",
"replace": "(( level > BATTERY_WARN_BELOW ))",
"describe": "invert the battery threshold"
},
{
"file": "phone_focus_mode/lib/monitor_checks_health.sh",
"find": "(( free_mb < STORAGE_WARN_BELOW_MB ))",
"replace": "(( free_mb > STORAGE_WARN_BELOW_MB ))",
"describe": "invert the storage threshold"
},
{
"file": "phone_focus_mode/lib/monitor_checks_health.sh",
"find": "free_mb=$((free_kb / 1024))",
"replace": "free_mb=$((free_kb / 512))",
"describe": "wrong KB-to-MB divisor"
},
{
"file": "phone_focus_mode/lib/monitor_checks_health.sh",
"find": "\"${daemon_name} is NOT running\" \"true\"",
"replace": "\"${daemon_name} is NOT running\" \"false\"",
"describe": "mark a dead daemon unrepairable"
},
{
"file": "phone_focus_mode/lib/monitor_checks_policy.sh",
"find": "\"hosts_integrity\" \"fatal\"",
"replace": "\"hosts_integrity\" \"ok\"",
"describe": "call a missing canonical hosts file ok"
},
{
"file": "phone_focus_mode/lib/monitor_checks_policy.sh",
"find": "\"${expected_hash}\" == \"${actual_hash}\"",
"replace": "\"${expected_hash}\" != \"${actual_hash}\"",
"describe": "invert the hosts hash comparison"
},
{
"file": "phone_focus_mode/lib/monitor_checks_policy.sh",
"find": "chain_present=\"yes\"",
"replace": "chain_present=\"no\"",
"describe": "never observe the DNS chain"
},
{
"file": "phone_focus_mode/lib/monitor_checks_policy.sh",
"find": "\"launcher_state\" \"fatal\"",
"replace": "\"launcher_state\" \"warn\"",
"describe": "downgrade the missing-launcher fatal"
},
{
"file": "phone_focus_mode/lib/monitor_checks_policy.sh",
"find": "\"launcher_state\" \"error\"",
"replace": "\"launcher_state\" \"ok\"",
"describe": "call a launcher mismatch ok"
},
{
"file": "phone_focus_mode/lib/monitor_checks_policy.sh",
"find": "\"boot_persistence\" \"fatal\"",
"replace": "\"boot_persistence\" \"ok\"",
"describe": "call a missing boot script ok"
},
{
"file": "phone_focus_mode/lib/monitor_checks_policy.sh",
"find": "\"companion_app\" \"warn\"",
"replace": "\"companion_app\" \"ok\"",
"describe": "call a missing companion app ok"
}
]
}

View File

@ -77,12 +77,3 @@ meta/lib/lint_output.sh
meta/scripts/lib/disk_cleanup_common.sh meta/scripts/lib/disk_cleanup_common.sh
meta/scripts/lib/trace_prefix.sh meta/scripts/lib/trace_prefix.sh
meta/scripts/lib/trace_stubs.sh meta/scripts/lib/trace_stubs.sh
phone_focus_mode/lib/adb_common.sh
phone_focus_mode/lib/adb_locking.sh
phone_focus_mode/lib/adb_trusted.sh
phone_focus_mode/lib/backup_capture.sh
phone_focus_mode/lib/backup.sh
phone_focus_mode/lib/monitor_checks_health.sh
phone_focus_mode/lib/monitor_checks_policy.sh
phone_focus_mode/lib/monitor.sh
phone_focus_mode/lib/restore.sh

View File

@ -1,159 +0,0 @@
# Phone Focus Mode
Location-based app restriction for a rooted Android phone using wireless ADB.
When within ~500m of home: only whitelisted productive apps remain usable.
When outside that radius: all apps work normally.
## 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
## Setup (first time)
### 1. Find your home coordinates
Open Google Maps, right-click your apartment → copy the coordinates shown.
### 2. Edit `config_secrets.sh`
```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:
```bash
./deploy.sh <phone_ip> --find-pkg stronglift
./deploy.sh <phone_ip> --find-pkg anki
./deploy.sh <phone_ip> --find-pkg pomodoro
```
Then add the correct package name to `WHITELIST` in `config.sh`.
### 4. Deploy
```bash
chmod +x deploy.sh
./deploy.sh 192.168.1.42 # replace with your phone's IP
```
This:
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
## Usage
```bash
./deploy.sh <ip> --status # Current mode, location, distance from home
./deploy.sh <ip> --log # View recent daemon log
./deploy.sh <ip> --list # List all apps + whitelist status
./deploy.sh <ip> --enable # Force focus mode ON (for testing)
./deploy.sh <ip> --disable # Force focus mode OFF
./deploy.sh <ip> --stop # Stop daemon entirely (restores all apps)
./deploy.sh <ip> --start # Start daemon
./deploy.sh <ip> --restart # Restart daemon (picks up config changes)
./deploy.sh <ip> --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 |
| `curfew_enforcer.sh` | Night-curfew enforcer — grayscale + DND + optional net |
| `hosts_enforcer.sh` | Bind-mounts `hosts.canonical` over `/system/etc/hosts` |
| `magisk_service.sh` | Magisk boot hook → auto-starts both daemons |
| `deploy.sh` | PC-side ADB deployment and control script |
## Hosts hardening
A second daemon, `hosts_enforcer.sh`, locks the phone's `/system/etc/hosts`
to the same blocklist installed by `linux_configuration/hosts/install.sh`
on the PC. Three layers:
1. Canonical copy at `/data/adb/focus_mode/hosts.canonical` is `chattr +i`.
2. It is bind-mounted read-only over `/system/etc/hosts` at boot.
3. A watchdog verifies a sha256 every 15 seconds and restores on mismatch.
This blocks the common `echo > /etc/hosts` one-liner from a terminal app.
It is NOT a guarantee against a determined root user on the device itself —
a real "impossible without USB" gate would require removing `su` access,
which would break the rest of this system. The watchdog at least ensures
tampering is logged and reverted within ~15s.
Status and logs:
```bash
./deploy.sh <ip> --hosts-status
./deploy.sh <ip> --hosts-log
```
## Periodic rescan / Play Store
The focus daemon now **re-scans every tick** (not just on first entry). If
you re-enable an app via Play Store or `pm enable`, it gets re-disabled
within `CHECK_INTERVAL_FOCUS` seconds. `com.android.vending` (Play Store),
`com.*.packageinstaller`, and popular terminal apps are also uninstalled
`--user 0` in focus mode to close the usual bypass paths. Google Play
Services (`com.google.android.gms`) is left alone so banking apps work.
## More detail
Split out to stay under the 250-line cap.
- [Night curfew and the hotspot/tethering block](docs/night-curfew.md)
- [Updating and troubleshooting](docs/updating-and-troubleshooting.md)

View File

@ -1,85 +0,0 @@
#!/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

View File

@ -1,185 +0,0 @@
#!/bin/bash
# ============================================================
# BL-9000 Bloatware Uninstall
#
# Removes Blackview OEM bloatware and specified Google apps
# using `pm uninstall --user 0` (soft-remove, reversible via
# `pm install-existing --user 0 <pkg>`). No reboots between
# packages — one optional reboot at the end.
#
# Usage:
# ./batch3_bloatware_uninstall.sh [--list] [--reboot]
#
# --list Dry-run: show which packages would be removed.
# --reboot Reboot the phone after all removals.
#
# Device selection (pick one):
# ADB_SERIAL=BL9000EEA0000102 ./batch3_bloatware_uninstall.sh
# PHONE_IP=192.168.1.x ./batch3_bloatware_uninstall.sh
# ============================================================
set -euo pipefail
DRY_RUN=0
DO_REBOOT=0
for arg in "$@"; do
case "$arg" in
--list) DRY_RUN=1 ;;
--reboot) DO_REBOOT=1 ;;
*) echo "Unknown flag: $arg"; exit 1 ;;
esac
done
# ---- Device targeting (mirrors deploy.sh) ----
ADB_TARGET=()
if [[ -n "${ADB_SERIAL:-}" ]]; then
ADB_TARGET=(-s "${ADB_SERIAL}")
elif [[ -n "${PHONE_IP:-}" ]]; then
echo "Connecting to ${PHONE_IP}:5555 ..."
adb connect "${PHONE_IP}:5555"
ADB_TARGET=(-s "${PHONE_IP}:5555")
fi
adb_cmd() { adb "${ADB_TARGET[@]}" "$@"; }
# Requires --mount-master so the command runs in the global mount namespace.
adb_root() {
printf '%s\n' "$1" | adb_cmd shell su --mount-master -c "sh -s"
}
# ============================================================
# PACKAGES TO REMOVE
#
# All removed with `pm uninstall --user 0` — the APK stays in
# /system so a factory reset or `pm install-existing` restores
# it. Safe to run even if a package is absent (skipped).
# ============================================================
# ---- Blackview OEM bloatware (BL-9000 confirmed packages) ----
BV_BLOATWARE=(
com.blackview.apkupgrade # Blackview OTA updater
com.blackview.bvworkspace # BV desktop workspace
com.blackview.childmode # Child mode
com.blackview.cplog # CPU/hardware logger
com.blackview.darkmode.one # Dark mode theme variant
com.blackview.darkmode.two
com.blackview.darkmode.three
com.blackview.easytrans # BV easy-transfer tool
com.blackview.filetrans # BV file-transfer tool
com.blackview.focusmode # BV own focus mode (replaced by ours)
com.blackview.frozenapp # App freezer
com.blackview.gamemode # Game mode panel
com.blackview.health # BV health tracker
com.blackview.helper # BV AI assistant / helper
# NOTE: com.blackview.launcher is intentionally NOT removed here.
# Blackview embeds com.android.quickstep.RecentsActivity inside this APK —
# removing it kills the "swipe up for recent apps" gesture system-wide.
# Focus mode already disables it as a HOME competitor via LAUNCHER_COMPETITORS.
# com.blackview.launcher
# com.blackview.launcher.overlay.framework
com.blackview.leftscreen # Left swipe panel
com.blackview.notebook # BV notes app
com.blackview.qrcode # QR scanner (camera does it natively)
com.blackview.reversepay # Reverse wireless charging pay
com.blackview.smscode # SMS code extractor
com.blackview.systemmanager # BV system manager
com.blackview.theme.color.mode0 # Color themes (8 variants)
com.blackview.theme.color.mode1
com.blackview.theme.color.mode2
com.blackview.theme.color.mode3
com.blackview.theme.color.mode4
com.blackview.theme.color.mode5
com.blackview.theme.color.mode6
com.blackview.theme.color.mode7
com.blackview.theme.config
com.blackview.theme.icon.clearwave # Icon themes
com.blackview.theme.icon.oil
com.blackview.tool # BV diagnostic tool
com.blackview.userfeedback # BV telemetry / feedback
com.blackview.wallpaper # BV wallpaper collection
com.blackview.wallpaperpicker
com.blackview.wallpaperpicker.overlay
com.blackview.weather # BV weather widget
)
# ---- Google apps explicitly requested for removal ----
GOOGLE_REMOVE=(
com.android.chrome # Google Chrome (Firefox is whitelisted)
com.google.android.youtube # YouTube app (hosts-blocked anyway)
com.google.android.apps.youtube.music # YouTube Music
)
ALL_PACKAGES=("${BV_BLOATWARE[@]}" "${GOOGLE_REMOVE[@]}")
# ============================================================
# Main
# ============================================================
echo "============================================================"
if [[ $DRY_RUN -eq 1 ]]; then
echo "DRY RUN — packages that would be removed:"
else
echo "BL-9000 bloatware removal"
fi
echo "============================================================"
echo ""
echo "[1] Verifying device connection..."
if ! adb_cmd get-state >/dev/null 2>&1; then
echo "ERROR: No ADB device reachable."
echo " Set ADB_SERIAL=<serial> or PHONE_IP=<ip> and retry."
exit 1
fi
echo "[2] Verifying root..."
if ! adb_root "id" 2>/dev/null | grep -q "uid=0"; then
echo "ERROR: Root shell failed. Ensure Magisk is installed."
exit 1
fi
echo " Root confirmed."
echo ""
removed=0
skipped=0
errors=0
for pkg in "${ALL_PACKAGES[@]}"; do
# Check presence
if ! adb_cmd shell pm list packages 2>/dev/null | grep -qx "package:${pkg}"; then
printf ' %-55s [not installed]\n' "$pkg"
(( skipped++ )) || true
continue
fi
if [[ $DRY_RUN -eq 1 ]]; then
printf ' %-55s [would remove]\n' "$pkg"
(( removed++ )) || true
continue
fi
printf ' Removing %-48s ... ' "$pkg"
result="$(adb_cmd shell pm uninstall --user 0 "$pkg" 2>&1 || true)"
if echo "$result" | grep -qi "success"; then
echo "OK"
(( removed++ )) || true
else
echo "FAILED (${result})"
(( errors++ )) || true
fi
done
echo ""
echo "============================================================"
if [[ $DRY_RUN -eq 1 ]]; then
echo "Dry-run complete: ${removed} would be removed, ${skipped} not installed."
echo "Run without --list to apply."
else
echo "Done: ${removed} removed, ${skipped} skipped (not installed), ${errors} errors."
if [[ $DO_REBOOT -eq 1 ]]; then
echo "Rebooting phone..."
adb_cmd reboot
else
echo "Run with --reboot to reboot now, or reboot manually."
fi
fi
echo "============================================================"

View File

@ -1,250 +0,0 @@
#!/system/bin/sh
# Focus Mode Configuration. Set HOME_LAT/HOME_LON in config_secrets.sh before
# deploying (Google Maps: right-click your apartment -> coords).
#
# The lists below stay in THIS file: focus_policy's loader finds them by
# regex over config.sh's own text, so one moved into a config_*.sh sibling
# parses as empty with no error. Rationale lives in docs/policy-lists.md.
# --- Home location (loaded from config_secrets.sh, not tracked by git) ---
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
# config_secrets.sh is deliberately untracked (it holds secrets, see
# .gitignore) and is created per-machine / pushed to the phone at deploy
# time. It does not exist in a clean checkout, so the linter cannot follow
# it there -- which is exactly what CI lints.
# shellcheck disable=SC1091
. "$SCRIPT_DIR/config_secrets.sh"
# --- Radius in meters ---
export RADIUS=150
# --- Hysteresis buffer in meters (prevents rapid toggling at boundary) ---
export HYSTERESIS=30
# shellcheck source=config_paths.sh
. "$SCRIPT_DIR/config_paths.sh"
# NIGHT CURFEW (time-gated strict allow-list). Times are local 24h HHMM;
# the window wraps past midnight when START > END (e.g. 2300 -> 0500).
# see docs/policy-lists.md#night-curfew
export NIGHT_CURFEW_ENABLED=1
export NIGHT_CURFEW_START="2300"
export NIGHT_CURFEW_END="0500"
# shellcheck source=config_curfew.sh
. "$SCRIPT_DIR/config_curfew.sh"
# shellcheck source=config_tether.sh
. "$SCRIPT_DIR/config_tether.sh"
# Domains unblocked while a workout is in progress. Used by deploy.sh to
# generate $HOSTS_CANONICAL_WORKOUT (each line becomes a `0.0.0.0 <host>`
# match that is stripped from the canonical) and by focus_ctl.sh status.
# Comments and blank lines ignored. Keep entries lower-case.
export WORKOUT_UNBLOCK_DOMAINS="
youtube.com
www.youtube.com
m.youtube.com
youtu.be
youtubei.googleapis.com
youtube.googleapis.com
youtube-nocookie.com
www.youtube-nocookie.com
googlevideo.com
ytimg.com
i.ytimg.com
s.ytimg.com
yt3.ggpht.com
yt3.googleusercontent.com
i9.ytimg.com
"
# shellcheck source=config_dns.sh
. "$SCRIPT_DIR/config_dns.sh"
# Browsers to force-stop when the hosts file is updated or restored.
# Force-stopping clears the in-process DNS cache so the next launch
# consults the system resolver (which sees our /etc/hosts blocks).
# Packages not installed on the device are silently skipped.
export BROWSER_PACKAGES="
org.mozilla.fenix
com.android.chrome
"
# --- 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).
# The Pixel 6a's stock launcher. The previous value named the rooted
# Blackview's minimalist launcher, which is not installed here -- the exporter's
# launcher check caught it, which is the whole reason that check exists: hiding
# the launcher leaves the device with no home screen and no way back to the
# enforcer app.
export LAUNCHER_PACKAGE="com.google.android.apps.nexuslauncher"
# shellcheck source=config_launcher.sh
. "$SCRIPT_DIR/config_launcher.sh"
# WHITELISTED APPS -- always enabled, even in focus mode.
# see docs/policy-lists.md#whitelisted-apps
export WHITELIST="
# Rewritten 2026-08-11 for the unrooted Pixel 6a under Device Owner. The list
# it replaced described the rooted Blackview and named apps this phone does not
# have. Everything absent from this list is hidden while at home, including
# every browser -- so no link opens at all until you leave the geofence.
# --- Launcher (MUST be listed: hiding it leaves no home screen) ---
com.google.android.apps.nexuslauncher
# --- The enforcer and its VPN provider (hiding either is unrecoverable) ---
com.kuhy.focus_owner
com.celzero.bravedns
com.zaneschepke.wireguardautotunnel
# --- Phone, messaging, contacts (Polish UI: telefon / wiadomosci) ---
org.fossify.phone
org.fossify.messages
org.fossify.contacts
com.facebook.orca
# --- Banking and identity (all device-paired over SMS; see docs) ---
pl.mbank
com.revolut.revolut
pl.infakt.infakt
pl.nask.mobywatel
com.kunzisoft.keepass.libre
# --- kuhy's own apps ---
com.kuhy.diet_guard_app
com.kuhy.home_inventory
com.kuhy.lyricanki
com.kuhy.untools
com.kuhy.wake_alarm_sync
com.kuhy.workout_app
dev.kuhy.todo
# --- Daily utility ---
com.google.android.calendar
com.google.android.deskclock
com.google.android.apps.maps
com.sosauce.cutecalc
com.ichi2.anki
com.metrolist.music
eu.kanade.tachiyomi.sy
# com.android.vending -- day only. NEVER put a dollar-sign reference or a
# double quote in this string, not even in a comment: both break the list
# silently. see docs/policy-lists.md#why-the-play-store-is-in-the-day-list
com.android.vending
# --- Workout tracking (always beneficial; must stay enabled to export runs) ---
org.runnerup
org.runnerup.free
"
# ALLOWED PACKAGE PREFIXES -- why prefixes, and how narrow to keep them:
# see docs/policy-lists.md#allowed-package-prefixes
export ALLOWED_PREFIXES="
# Manga reader + its per-source extension apks.
eu.kanade.tachiyomi
"
# Prefixes that survive the curfew as well. Must be a subset of
# $ALLOWED_PREFIXES, mirroring the NIGHT_WHITELIST/WHITELIST subset rule.
export NIGHT_ALLOWED_PREFIXES="
eu.kanade.tachiyomi
"
# NIGHT CURFEW WHITELIST -- what stays enabled at night and why, plus the
# prefix exception: see docs/policy-lists.md#night-curfew-whitelist
export NIGHT_WHITELIST="
# Curfew (23:00-05:00 at home) is strictly tighter than the day list: only what
# is needed to answer the phone, reach a bank, or handle an emergency. Rewritten
# 2026-08-11 alongside the day list.
com.kuhy.focus_owner
com.celzero.bravedns
com.google.android.apps.nexuslauncher
org.fossify.phone
org.fossify.messages
org.fossify.contacts
pl.mbank
com.revolut.revolut
pl.infakt.infakt
pl.nask.mobywatel
com.kunzisoft.keepass.libre
com.kuhy.wake_alarm_sync
com.google.android.deskclock
com.metrolist.music
org.runnerup
org.runnerup.free
# dev.kuhy.todo: deliberate loosening of the night rule.
# see docs/policy-lists.md#why-devkuhytodo-is-in-the-night-list
dev.kuhy.todo
"
# ============================================================
# BLOCKED SYSTEM APPS
# System apps that should be disabled in focus mode.
# These are NOT covered by third-party package blocking.
# --- System / essential packages that must NEVER be disabled ---
# Prefix-matched. Why pl.infakt.infakt is here rather than allowlisted:
# see docs/policy-lists.md#system-packages-that-must-never-be-disabled
export SYSTEM_NEVER_DISABLE="
pl.infakt.infakt
# The always-on VPN provider. Hiding it is self-defeating in the worst way:
# it is the network-level YouTube block, and with DISALLOW_CONFIG_VPN pinned
# to a hidden package the device can lose general connectivity rather than
# just the filter. Must outrank every enforcement branch.
com.celzero.bravedns
com.android.launcher
com.android.settings
com.android.systemui
com.android.phone
com.android.dialer
com.android.contacts
com.android.mms
com.android.messaging
com.android.providers
com.android.inputmethod
com.android.shell
com.android.permissioncontroller
com.android.bluetooth
com.android.nfc
com.android.wifi
com.android.certinstaller
com.android.vpndialogs
com.android.se
com.android.emergency
com.android.camera
com.android.documentsui
com.android.externalstorage
com.android.keychain
com.android.location
com.android.networkstack
com.android.captiveportallogin
com.google.android.gms
com.google.android.gsf
com.google.android.ext.services
com.google.android.ext.shared
com.google.android.webview
com.google.android.trichromelibrary
com.google.android.inputmethod.latin
com.google.android.setupwizard
com.google.android.permissioncontroller
com.google.android.deskclock
com.google.android.dialer
com.google.android.contacts
com.google.android.apps.messaging
android
com.mediatek
com.qualcomm
"

View File

@ -1,50 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# config_curfew.sh — the night-curfew enforcer's settings: poll interval, log
# and state paths, the grayscale/DND/network feature switches, and the
# per-UID network chain.
#
# Sourced by config.sh where these definitions used to sit.
#
# The curfew WINDOW ($NIGHT_CURFEW_START/END/ENABLED) is deliberately NOT
# here: focus_policy's loader reads those out of config.sh's text. Only names
# the loader never reads may live in a sibling.
# --- Curfew enforcer (grayscale + DND + per-UID network allow-list) ---
# See curfew_enforcer.sh. Always-on like dns_enforcer, but only ACTS while the
# curfew window is open AND focus mode is ON. Re-applies every interval so a
# manual toggle in Settings snaps back ("hard to turn off"). NOTE: snap-back is
# the realistic lock; true impossibility would mean blocking the Settings app,
# which risks system instability, so we deliberately do not.
export CURFEW_ENFORCER_INTERVAL=5
export CURFEW_ENFORCER_LOG="$STATE_DIR/curfew_enforcer.log"
export CURFEW_ENFORCER_STATE="$STATE_DIR/curfew_applied"
# Grayscale: force the display to monochrome via the accessibility daltonizer.
export CURFEW_GRAYSCALE_ENABLED=1
# DND: force Do-Not-Disturb to alarms-only so notifications stop pulling you in
# while the morning alarm still rings.
export CURFEW_DND_ENABLED=1
# Per-UID internet allow-list. DEFAULT OFF: highest-risk layer, must be proven
# on-device (`focus_ctl.sh curfew-test-on`) before it is trusted to fire
# unattended at 23:00. When on, only $NIGHT_WHITELIST app UIDs (plus
# root/system/shell + DNS) get network; every other app is cut off. It is also
# largely redundant with the app-disable layer, so leaving it off is safe.
export CURFEW_NET_ENABLED=1
export CURFEW_NET_IPT_CHAIN="FOCUS_CURFEW_NET"
# Android's netd periodically reasserts the whole `filter` table via
# iptables-restore, which atomically erases our custom chain (proven on-device:
# the chain vanishes ~every 5s). The main enforcer tick (5s) rebuilds it but
# leaves up-to-5s leak windows. This watchdog re-pins the chain every
# CURFEW_NET_REASSERT_INTERVAL seconds *from a cached UID list* (no pm fork).
# A 1s interval was measured on-device to peg netd at ~30% CPU continuously
# (an `iptables -L` fork every second, all night), overheating the phone -
# throttled to 15s (matches HOSTS_CHECK_INTERVAL/LAUNCHER_CHECK_INTERVAL
# pacing) as a deliberate tradeoff: leak window grows from <=1s to <=15s.
# The proper fix is netd's own per-UID firewall (ndc/eBPF), tracked as a
# follow-up; this remains an interim stopgap either way.
export CURFEW_NET_REASSERT_INTERVAL=15
export CURFEW_NET_UID_CACHE="$STATE_DIR/curfew_net_uids.txt"
# Manual test toggle: `focus_ctl.sh curfew-test-on` writes this file to force
# curfew ACTIVE regardless of clock, so the whole stack can be validated during
# the day. `curfew-test-off` removes it.
export CURFEW_FORCE_FILE="$STATE_DIR/curfew_force_on"

View File

@ -1,86 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# config_dns.sh — the DNS enforcer's settings: poll interval, log and iptables
# chain names, the trusted DoT host, and the DoH endpoint lists that
# dns_iptables.sh turns into reject rules.
#
# Sourced by config.sh where these definitions used to sit.
#
# Nothing python_pkg/focus_policy/loader.py reads may move into a sibling: it
# regex-scans the text of config.sh ALONE, so a name moved out becomes an
# empty value with no error at all. See the pinned list in config_paths.sh.
# --- DNS enforcer state (see dns_enforcer.sh) ---
# The hosts file is only consulted by the *system* resolver. Apps using
# DNS-over-HTTPS (DoH, e.g. Chrome's built-in secure DNS) or DNS-over-TLS
# (DoT, e.g. Android 9+ Private DNS "opportunistic" mode) bypass it.
# The DNS enforcer pins Private DNS to OFF and blocks well-known DoH/DoT
# endpoints so lookups fall back to the system resolver -> hosts file.
export DNS_CHECK_INTERVAL=20
export DNS_LOG="$STATE_DIR/dns_enforcer.log"
# --- Trusted DoT resolver (opt-in, empty = old behaviour) ---
# Set this to the hostname of a DoT resolver you control, and the enforcer
# switches from "no DoT at all" to "only YOUR DoT". Two things change:
# 1. an ACCEPT for that resolver's IPs is inserted BEFORE the blanket
# 853 REJECT, so the connection survives;
# 2. private_dns_mode is pinned to "hostname" with this specifier, instead
# of being forced off -- so the phone cannot silently fall back to a
# resolver that does not filter.
# Leave EMPTY to keep the original behaviour (Private DNS forced off, all
# 853 rejected). Do not point this at a public resolver: the whole point is
# that the resolver applies your blocklist. See python_pkg/focus_policy.
export DNS_TRUSTED_DOT_HOST=""
# Where to resolve DNS_TRUSTED_DOT_HOST when Private DNS is pinned to it.
# Chicken-and-egg: the resolver's own name must resolve without using it.
# Space-separated IPv4/IPv6 literals; leave empty to resolve at runtime.
export DNS_TRUSTED_DOT_IPS=""
# iptables chain used exclusively by us; we flush+refill it every check.
export DNS_IPT_CHAIN="FOCUS_DNS_BLOCK"
# DoH/DoT endpoints to DROP. Well-known public resolvers used by browsers
# and OS when Private DNS is enabled. Updating this list is cheap — just
# edit and redeploy.
export DNS_DOH_HOSTS="
dns.google
dns64.dns.google
dns.quad9.net
dns.cloudflare.com
one.one.one.one
cloudflare-dns.com
mozilla.cloudflare-dns.com
chrome.cloudflare-dns.com
dns.nextdns.io
doh.opendns.com
dns.adguard-dns.com
dns.adguard.com
dns.controld.com
"
# IPv4/IPv6 literals used by DoT (port 853) and DoH (port 443). Anything
# not already resolved via /etc/hosts still needs literal-IP blocks.
export DNS_DOH_IPV4="
8.8.8.8
8.8.4.4
1.1.1.1
1.0.0.1
9.9.9.9
149.112.112.112
94.140.14.14
94.140.15.15
208.67.222.222
208.67.220.220
45.90.28.0
45.90.30.0
104.16.248.249
104.16.249.249
"
export DNS_DOH_IPV6="
2001:4860:4860::8888
2001:4860:4860::8844
2606:4700:4700::1111
2606:4700:4700::1001
2620:fe::fe
2620:fe::9
2a10:50c0::ad1:ff
2a10:50c0::ad2:ff
2606:4700::6810:f8f9
2606:4700::6810:f9f9
"

View File

@ -1,29 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# config_launcher.sh — the minimalist-launcher enforcer's settings: where the
# APK and its hash live, which launchers count as competitors, and the poll
# interval and log path.
#
# Sourced by config.sh where these definitions used to sit.
#
# $LAUNCHER_PACKAGE is deliberately NOT here: focus_policy's loader reads it
# out of config.sh's text and would silently see an empty value instead.
export LAUNCHER_APK="/data/adb/focus_mode/minimalist_launcher.apk"
export LAUNCHER_SHA_FILE="/data/adb/focus_mode/minimalist_launcher.sha256"
# Captured home-activity component (package/.Activity). Saved by
# --snapshot-launcher so the enforcer knows which component to pin as HOME.
export LAUNCHER_ACTIVITY_FILE="/data/adb/focus_mode/minimalist_launcher.activity"
# Competing launchers to disable so the "pick a launcher" dialog has
# nothing else to offer. Matched exactly; add more with `focus_ctl.sh
# launcher-disable-other <pkg>`.
# com.blackview.launcher is intentionally excluded from LAUNCHER_COMPETITORS:
# Blackview embeds com.android.quickstep.RecentsActivity inside this APK,
# so disabling it kills the system-wide "swipe up for recent apps" gesture.
export LAUNCHER_COMPETITORS="
com.android.launcher
com.android.launcher3
com.google.android.apps.nexuslauncher
"
export LAUNCHER_CHECK_INTERVAL=15
export LAUNCHER_LOG="$STATE_DIR/launcher_enforcer.log"

View File

@ -1,118 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# config_paths.sh — the shared plumbing: poll intervals, the log file, the
# state/mode/status paths, the hosts-enforcer and workout-detector settings,
# and the boot-autostart gate.
#
# Sourced by config.sh where these definitions used to sit, after $STATE_DIR
# (which stays in config.sh, since every one of these paths is built from it).
#
# WHAT MUST NOT MOVE OUT OF config.sh
# -----------------------------------
# python_pkg/focus_policy/loader.py builds the whole policy by running a regex
# over the TEXT OF config.sh ALONE (plus a sibling config_secrets.sh). A name
# moved into any of these config_*.sh siblings becomes invisible to it, and
# the loader does not fail -- it silently yields an empty set, which reads as
# "nothing is whitelisted". These fifteen therefore stay in config.sh:
#
# ALLOWED_PREFIXES BROWSER_PACKAGES HOME_LAT HOME_LON HYSTERESIS
# LAUNCHER_PACKAGE NIGHT_ALLOWED_PREFIXES NIGHT_CURFEW_ENABLED
# NIGHT_CURFEW_END NIGHT_CURFEW_START NIGHT_WHITELIST RADIUS
# SYSTEM_NEVER_DISABLE WHITELIST WORKOUT_UNBLOCK_DOMAINS
#
# Check before and after any change here -- the hash must not move:
# python3 -m python_pkg.focus_policy --config phone_focus_mode/config.sh \
# | sha256sum
# Every path below is built from this, so it leads the file.
# --- State file (tracks which apps were disabled by focus mode) ---
STATE_DIR="/data/local/tmp/focus_mode"
# --- Location check interval in seconds ---
# When focus mode is ON (at home): check very frequently for near-instant
# detection of leaving home (phone is charging anyway).
# When focus mode is OFF (away): check less often to save battery.
export CHECK_INTERVAL_FOCUS=10
export CHECK_INTERVAL_NORMAL=120
# --- Log file ---
export LOG_FILE="/data/local/tmp/focus_mode/focus_mode.log"
export LOG_MAX_LINES=500
export DISABLED_APPS_FILE="$STATE_DIR/disabled_by_focus.txt"
export MODE_FILE="$STATE_DIR/current_mode.txt"
# Status snapshot consumed by the companion notification app (focus_status_app).
# Written by focus_daemon.sh every loop iteration. Chmod 644 so apps can read.
export STATUS_FILE="$STATE_DIR/status.json"
# Trigger file: companion app (or user) touches this to request an immediate
# re-check. focus_daemon.sh polls for it and skips the remainder of its sleep.
export RECHECK_TRIGGER="$STATE_DIR/trigger_recheck"
# Escape hatch: if this file exists, curfew is suspended (treated as daytime)
# everywhere — app list, grayscale, DND and network. Delete it to re-arm.
# WHO CAN CREATE IT (recovery paths, strongest lock first):
# * `focus_ctl.sh curfew-off` over ADB from the PC (always available daily).
# * The Magisk boot emergency-disable file ($FOCUS_BOOT_EMERGENCY_DISABLE_FILE)
# stops the whole stack at next boot.
# * A root file-manager/terminal ONLY IF one is added to $NIGHT_WHITELIST.
# None is whitelisted by default — that is deliberate ("hard to turn off").
# The active-IME guard in focus_daemon.sh keeps the keyboard alive so whichever
# path you choose is always typable. If you want a true no-PC 2am opt-out,
# whitelist a root terminal at night (see NIGHT_WHITELIST) or wire a companion-
# app button. Until then, recovery is PC/ADB-based by design.
export CURFEW_OVERRIDE_FILE="$STATE_DIR/curfew_override"
# --- 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="$STATE_DIR/hosts.canonical"
export HOSTS_TARGET="/system/etc/hosts"
export HOSTS_SHA_FILE="$STATE_DIR/hosts.sha256"
export HOSTS_CHECK_INTERVAL=15
export HOSTS_LOG="$STATE_DIR/hosts_enforcer.log"
# Workout-variant canonical: same content as $HOSTS_CANONICAL but with all
# YouTube-related domain blocks removed. hosts_enforcer.sh switches to this
# variant when $WORKOUT_ACTIVE_FILE contains "1" (StrongLifts workout in
# progress) and switches back when it contains "0" or is missing.
export HOSTS_CANONICAL_WORKOUT="$STATE_DIR/hosts.canonical.workout"
export HOSTS_SHA_FILE_WORKOUT="$STATE_DIR/hosts.sha256.workout"
# Magisk Systemless Hosts module path. The enforcer keeps this in sync with
# the currently-active canonical so a fresh boot sees the right hosts file.
export HOSTS_MAGISK_MODULE_FILE="/data/adb/modules/hosts/system/etc/hosts"
# --- Workout detector (see workout_detector.sh) ---
# Polls the StrongLifts SQLite DB to determine whether a workout is in
# progress. A workout is "in progress" iff there is at least one row in
# `workouts` with start>0 AND (finish IS NULL OR finish=0). While true,
# YouTube is unblocked at the hosts level (see HOSTS_CANONICAL_WORKOUT).
export WORKOUT_DETECTOR_INTERVAL=10
export WORKOUT_ACTIVE_FILE="$STATE_DIR/workout_active"
export WORKOUT_DETECTOR_LOG="$STATE_DIR/workout_detector.log"
# Static aarch64 sqlite3 binary pushed by deploy.sh. Built from the SQLite
# amalgamation against the Android NDK; ~1.6 MB. Stored outside the repo at
# ../testsAndMisc_binaries/phone_focus_mode/sqlite3 per binary-files policy.
export WORKOUT_SQLITE3_BIN="/data/local/tmp/focus_mode/sqlite3"
export WORKOUT_DB_PATH="/data/data/com.stronglifts.app/databases/StrongLifts-Database-3"
# StrongLifts package — must be in $WHITELIST so its DB stays writable while
# focus mode is enforcing. Used here only for status/log clarity.
export WORKOUT_TRIGGER_PACKAGE="com.stronglifts.app"
export BLOCKED_SYSTEM_APPS="
# *** 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 on BL9000).
#
# System/distraction apps are enforced via DNS+iptables in dns_enforcer.sh
# instead of persistent package-disable state.
"

View File

@ -1,44 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# config_tether.sh — the tethering enforcer's settings: the feature switch,
# poll interval, log and state paths, the offload setting it snapshots, its
# iptables chain, and the override/force hooks.
#
# Sourced by config.sh where these definitions used to sit. None of these is
# read by focus_policy's loader; see the note in config_paths.sh.
# ============================================================
# HOTSPOT / TETHERING BLOCK (closes the "second phone" bypass)
# ============================================================
# The hosts file, dns_enforcer and the curfew net layer all only touch this
# phone's OWN traffic (system resolver + OUTPUT chain). A tethered second
# device's packets are FORWARDed + NAT'd through us on an unfiltered path, so
# it browses freely on our mobile data. tether_enforcer.sh closes that hole.
# Always-on like dns_enforcer, but only ACTS while focus mode is ON (i.e. you
# are at home: $MODE_FILE == "focus") OR the force-test file is present. Covers
# WiFi, USB and Bluetooth tethering, since all of it traverses FORWARD.
export TETHER_ENFORCER_ENABLED=1
export TETHER_CHECK_INTERVAL=5
export TETHER_LOG="$STATE_DIR/tether_enforcer.log"
export TETHER_ENFORCER_STATE="$STATE_DIR/tether_applied"
# Lever 1: disable Android's hardware/BPF tether offload so forwarded traffic
# is actually seen by netfilter (otherwise the FORWARD rule can be silently
# skipped). AOSP global key Settings.Global.TETHER_OFFLOAD_DISABLED; 1=disabled.
# Snapshotted on entry and restored on exit so we never clobber a user value.
export TETHER_OFFLOAD_KEY="tether_offload_disabled"
export TETHER_OFFLOAD_SNAP="$STATE_DIR/tether_offload.snap"
# Lever 2: blanket REJECT of the FORWARD chain (both iptables + ip6tables).
# Re-pinned only when tampered (chain_intact gate, like dns_enforcer) so we do
# not fork an `iptables -L` every second — that pegged netd + overheated the
# phone during curfew-net tuning (see CURFEW_NET_REASSERT_INTERVAL note above).
export TETHER_IPT_CHAIN="FOCUS_TETHER_BLOCK"
# Lever 3 (best-effort, WiFi only): actively stop a running softAP each tick so
# the hotspot toggle visibly flips off. Version-gated (cmd wifi stop-softap is
# Android 11+). The FORWARD block remains the version-independent catch-all.
export TETHER_STOP_SOFTAP_ENABLED=1
# Escape hatches (mirrors the curfew override/force pair):
# tether_override -> suspend the block without stopping the daemon.
# tether_force_on -> force the block ACTIVE regardless of location (daytime
# on-device validation via `focus_ctl.sh tether-test-on`).
export TETHER_OVERRIDE_FILE="$STATE_DIR/tether_override"
export TETHER_FORCE_FILE="$STATE_DIR/tether_force_on"

View File

@ -1,152 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_curfew.sh — focus_ctl.sh's subcommands for the night-curfew enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
CURFEW_PIDFILE="$STATE_DIR/curfew_enforcer.pid"
curfew_enforcer_pid() {
if [ -f "$CURFEW_PIDFILE" ]; then
local pid
pid="$(cat "$CURFEW_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
# Replicates focus_daemon.sh::is_curfew_now for status display.
_ctl_dec() {
local n="$1"
while [ "${n#0}" != "$n" ] && [ "${#n}" -gt 1 ]; do n="${n#0}"; done
printf '%s' "$n"
}
ctl_is_curfew_now() {
local now start end
now="$(date +%H%M 2>/dev/null)"
case "$now" in '' | *[!0-9]*) return 1 ;; esac
now="$(_ctl_dec "$now")"
start="$(_ctl_dec "$NIGHT_CURFEW_START")"
end="$(_ctl_dec "$NIGHT_CURFEW_END")"
if [ "$start" -le "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
fi
}
cmd_curfew_status() {
local pid
pid="$(curfew_enforcer_pid)"
echo "=== Night Curfew Status ==="
echo "Enabled: ${NIGHT_CURFEW_ENABLED}"
echo "Window: ${NIGHT_CURFEW_START}-${NIGHT_CURFEW_END} (now $(date +%H%M))"
if [ -n "$pid" ]; then
echo "Enforcer: RUNNING (PID $pid)"
else
echo "Enforcer: STOPPED"
fi
ctl_is_curfew_now && echo "Within window: YES" || echo "Within window: no"
[ -e "$CURFEW_FORCE_FILE" ] && echo "Forced ON: YES (demo/test active)"
[ -e "$CURFEW_OVERRIDE_FILE" ] && echo "Override: YES (curfew SUSPENDED)"
if [ -f "$MODE_FILE" ]; then
echo "Focus mode: $(cat "$MODE_FILE" 2>/dev/null)"
fi
[ -e "$CURFEW_ENFORCER_STATE" ] && echo "Applied now: YES (grayscale/DND locked)" ||
echo "Applied now: no"
echo "Grayscale: ${CURFEW_GRAYSCALE_ENABLED} DND: ${CURFEW_DND_ENABLED} Net: ${CURFEW_NET_ENABLED}"
if iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
echo "iptables $CURFEW_NET_IPT_CHAIN: $(iptables -S "$CURFEW_NET_IPT_CHAIN" 2>/dev/null | wc -l) rules"
else
echo "iptables $CURFEW_NET_IPT_CHAIN: absent (net curfew not applied)"
fi
if [ -f "$STATE_DIR/night_whitelist.txt" ]; then
echo "Night whitelist: $(wc -l <"$STATE_DIR/night_whitelist.txt" | tr -d ' ') apps allowed"
fi
}
cmd_curfew_start() {
local pid
pid="$(curfew_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Curfew enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/curfew_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(curfew_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Curfew enforcer started (PID $pid)"
else
echo "ERROR: curfew enforcer failed to start. Check log: $CURFEW_ENFORCER_LOG"
fi
}
cmd_curfew_stop() {
local pid
pid="$(curfew_enforcer_pid)"
if [ -n "$pid" ]; then
kill "$pid" 2>/dev/null
echo "Curfew enforcer stopped (PID $pid) - daytime state restored"
else
echo "Curfew enforcer not running"
fi
}
# Test hook: force curfew ACTIVE regardless of clock/location so the whole
# stack can be validated during the day. Daemon re-checks within one tick.
cmd_curfew_test_on() {
touch "$CURFEW_FORCE_FILE"
chmod 666 "$CURFEW_FORCE_FILE" 2>/dev/null || true
rm -f "$CURFEW_OVERRIDE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew FORCED ON. App sweep + enforcer will engage within a few seconds."
echo "Validate: open mBank (works), keyboard (works), Firefox (gone). Then: curfew-test-off"
}
cmd_curfew_test_off() {
rm -f "$CURFEW_FORCE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew force cleared. Back to clock-based behaviour."
}
# Demo mode = the same force mechanism as the test hook, worded for an
# on-demand demo. The companion app's Start/Stop demo button drives the same
# force file. Easy off: curfew-demo-off (or tap "Stop demo curfew").
cmd_curfew_demo_on() {
touch "$CURFEW_FORCE_FILE"
chmod 666 "$CURFEW_FORCE_FILE" 2>/dev/null || true
rm -f "$CURFEW_OVERRIDE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Demo curfew STARTED - full curfew engaged now (apps, grayscale, DND, net)."
echo "Stop any time with: curfew-demo-off (or tap 'Stop demo curfew')"
}
cmd_curfew_demo_off() {
rm -f "$CURFEW_FORCE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Demo curfew STOPPED - back to clock-based behaviour."
}
# Escape hatch: suspend curfew now (the 2am 'let me out' button). Survives
# until you re-arm. Reachable on-device only via ADB (this command) unless a
# root file-manager/terminal has been added to NIGHT_WHITELIST.
cmd_curfew_off() {
touch "$CURFEW_OVERRIDE_FILE"
chmod 666 "$CURFEW_OVERRIDE_FILE" 2>/dev/null || true
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew SUSPENDED (override set: $CURFEW_OVERRIDE_FILE). Re-arm with: curfew-on"
}
cmd_curfew_on() {
rm -f "$CURFEW_OVERRIDE_FILE"
touch "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Curfew re-armed (override cleared)."
}

View File

@ -1,198 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_daemon.sh — focus_ctl.sh's subcommands for the main focus daemon:
# start, stop, status, enable, disable, recheck, log and the companion
# notification status.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one directory.
# Sourced by focus_ctl.sh, which owns log() and the config.sh globals these
# read. $PIDFILE lives here rather than in the entry script because this file
# is its only reader (SC2034), and the linter runs without -x so each file
# stands alone.
PIDFILE="$STATE_DIR/daemon.pid"
# 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
}
cmd_start() {
local pid
pid="$(daemon_pid)"
if [ -n "$pid" ]; then
echo "Daemon already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/focus_daemon.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(daemon_pid)"
if [ -n "$pid" ]; then
echo "Daemon started (PID $pid)"
else
echo "ERROR: Daemon failed to start. Check log: $LOG_FILE"
fi
}
cmd_stop() {
local pid
pid="$(daemon_pid)"
if [ -z "$pid" ]; then
echo "Daemon not running"
# Clean up stale pidfile if present
rm -f "$PIDFILE"
else
kill -TERM "$pid"
echo "Daemon stopped (sent SIGTERM to PID $pid)"
fi
}
cmd_status() {
local pid
pid="$(daemon_pid)"
local mode="unknown"
[ -f "$MODE_FILE" ] && mode="$(cat "$MODE_FILE")"
echo "=== Focus Mode Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Mode: $mode"
echo "Home: $HOME_LAT, $HOME_LON (radius: ${RADIUS}m)"
echo ""
# Show current location if available
location="$(dumpsys location 2>/dev/null |
grep -oE 'Location\[.*[-]?[0-9]{1,3}\.[0-9]+,[-]?[0-9]{1,3}\.[0-9]+' |
grep -oE '[-]?[0-9]{1,3}\.[0-9]+,[-]?[0-9]{1,3}\.[0-9]+' |
head -1)"
if [ -n "$location" ]; then
lat="$(echo "$location" | cut -d',' -f1)"
lon="$(echo "$location" | cut -d',' -f2)"
dist="$(echo "$lat $lon $HOME_LAT $HOME_LON" | awk '{
PI=3.14159265358979; R=6371000
a1=$1*PI/180; o1=$2*PI/180
a2=$3*PI/180; o2=$4*PI/180
da=a2-a1; dlon=o2-o1
x=sin(da/2)^2+cos(a1)*cos(a2)*sin(dlon/2)^2
printf "%d", R*2*atan2(sqrt(x),sqrt(1-x))
}')"
echo "Location: $lat, $lon"
echo "Distance: ${dist}m from home"
else
echo "Location: unavailable"
fi
echo ""
if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then
echo "=== Apps disabled by focus mode ==="
cat "$DISABLED_APPS_FILE"
else
echo "No apps currently disabled by focus mode"
fi
}
cmd_enable() {
# Disable daemon temporarily, force focus
echo "Forcing focus mode ON..."
. "$SCRIPT_DIR/config.sh"
# Source common functions - inline here for standalone use
: >"$STATE_DIR/disabled_by_focus.txt"
local count=0
for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do
# Check whitelist
whitelisted=0
for w in $(iter_whitelist_packages); do
w_clean="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w_clean" ] && continue
[ "$pkg" = "$w_clean" ] && {
whitelisted=1
break
}
done
[ "$whitelisted" -eq 1 ] && continue
# Check system protection
protected=0
for prefix in $SYSTEM_NEVER_DISABLE; do
prefix_clean="$(echo "$prefix" | tr -d '[:space:]')"
[ -z "$prefix_clean" ] && continue
case "$pkg" in
"$prefix_clean"*)
protected=1
break
;;
esac
done
[ "$protected" -eq 1 ] && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
echo "$pkg" >>"$STATE_DIR/disabled_by_focus.txt"
count=$((count + 1))
fi
done
echo "focus" >"$MODE_FILE"
echo "Done: disabled $count apps"
}
cmd_recheck() {
# Write the trigger file; the daemon's sleep_with_recheck() will pick it
# up within ~1 second and perform an immediate location check.
if [ ! -f "$PIDFILE" ] || ! kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
echo "Daemon not running - start it first with: focus_ctl.sh start"
return 1
fi
touch "$RECHECK_TRIGGER"
chmod 666 "$RECHECK_TRIGGER" 2>/dev/null || true
echo "Recheck requested. Tail the log to see the next reading:"
echo " tail -f $LOG_FILE"
}
cmd_notif_status() {
if [ -f "$STATUS_FILE" ]; then
echo "=== $STATUS_FILE ==="
cat "$STATUS_FILE"
echo
else
echo "No status snapshot yet (daemon has not written $STATUS_FILE)."
fi
if command -v dumpsys >/dev/null 2>&1; then
echo "=== Companion app state ==="
dumpsys package com.kuhy.focusstatus 2>/dev/null | grep -E 'enabled=|installed=|userId=' | head -5 || true
fi
}
cmd_disable() {
echo "Forcing focus mode OFF..."
if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then
local count=0
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
pm enable "$pkg" >/dev/null 2>&1 && count=$((count + 1))
done <"$DISABLED_APPS_FILE"
: >"$DISABLED_APPS_FILE"
echo "Done: re-enabled $count apps"
else
echo "No apps to re-enable"
fi
echo "normal" >"$MODE_FILE"
}
cmd_log() {
local lines="${1:-50}"
if [ -f "$LOG_FILE" ]; then
tail -n "$lines" "$LOG_FILE"
else
echo "Log file not found: $LOG_FILE"
fi
}

View File

@ -1,100 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_dns.sh — focus_ctl.sh's subcommands for the DNS enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
DNS_PIDFILE="$STATE_DIR/dns_enforcer.pid"
dns_enforcer_pid() {
if [ -f "$DNS_PIDFILE" ]; then
local pid
pid="$(cat "$DNS_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_dns_status() {
local pid
pid="$(dns_enforcer_pid)"
echo "=== DNS Enforcer Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
local mode spec
mode="$(settings get global private_dns_mode 2>/dev/null)"
spec="$(settings get global private_dns_specifier 2>/dev/null)"
echo "private_dns_mode: ${mode:-<unset>}"
echo "private_dns_specifier: ${spec:-<unset>}"
if iptables -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
local v4rules
v4rules="$(iptables -S "$DNS_IPT_CHAIN" 2>/dev/null | wc -l)"
echo "iptables $DNS_IPT_CHAIN: $v4rules rules"
else
echo "iptables $DNS_IPT_CHAIN: MISSING"
fi
if ip6tables -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
local v6rules
v6rules="$(ip6tables -S "$DNS_IPT_CHAIN" 2>/dev/null | wc -l)"
echo "ip6tables $DNS_IPT_CHAIN: $v6rules rules"
else
echo "ip6tables $DNS_IPT_CHAIN: MISSING"
fi
}
cmd_dns_start() {
local pid
pid="$(dns_enforcer_pid)"
if [ -n "$pid" ]; then
echo "DNS enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/dns_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(dns_enforcer_pid)"
if [ -n "$pid" ]; then
echo "DNS enforcer started (PID $pid)"
else
echo "ERROR: DNS enforcer failed to start. Check log: $DNS_LOG"
fi
}
cmd_dns_stop() {
local pid
pid="$(dns_enforcer_pid)"
if [ -z "$pid" ]; then
echo "DNS enforcer not running"
rm -f "$DNS_PIDFILE"
else
kill -TERM "$pid"
echo "DNS enforcer stopped (sent SIGTERM to PID $pid)"
fi
# Explicit teardown of the iptables chain so maintenance work can
# use DoH. The enforcer itself leaves the chain intact on TERM to
# keep the block closed between periodic re-applies.
iptables -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null || true
iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || true
iptables -X "$DNS_IPT_CHAIN" 2>/dev/null || true
ip6tables -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null || true
ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || true
ip6tables -X "$DNS_IPT_CHAIN" 2>/dev/null || true
echo "iptables chain $DNS_IPT_CHAIN removed"
}
cmd_dns_log() {
local lines="${1:-50}"
if [ -f "$DNS_LOG" ]; then
tail -n "$lines" "$DNS_LOG"
else
echo "DNS enforcer log not found: $DNS_LOG"
fi
}

View File

@ -1,118 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_hosts.sh — focus_ctl.sh's subcommands for the hosts-blocklist enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
HOSTS_PIDFILE="$STATE_DIR/hosts_enforcer.pid"
hosts_enforcer_pid() {
if [ -f "$HOSTS_PIDFILE" ]; then
local pid
pid="$(cat "$HOSTS_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_hosts_status() {
local pid
pid="$(hosts_enforcer_pid)"
echo "=== Hosts Enforcer Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Canonical: $HOSTS_CANONICAL"
echo "Target: $HOSTS_TARGET"
if grep -qE "[[:space:]]${HOSTS_TARGET}[[:space:]]" /proc/self/mounts 2>/dev/null; then
# A mount exists on the target path, but on Android the OEM sometimes
# already mounts its own hosts file here. Trust the sha check below.
echo "Mount: present (integrity check below tells us if ours)"
else
echo "Mount: NOT mounted (unprotected)"
fi
if [ -f "$HOSTS_CANONICAL" ]; then
local expected actual
expected="$(cat "$HOSTS_SHA_FILE" 2>/dev/null)"
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$HOSTS_TARGET" 2>/dev/null | awk '{print $1}')"
else
actual="$(md5sum "$HOSTS_TARGET" 2>/dev/null | awk '{print $1}')"
fi
echo "Expected: ${expected:-<none>}"
echo "Actual: ${actual:-<unreadable>}"
if [ -n "$expected" ] && [ "$expected" = "$actual" ]; then
echo "Integrity: OK"
else
echo "Integrity: MISMATCH"
fi
else
echo "Canonical hosts file missing - run deploy.sh"
fi
# Magisk Systemless Hosts module protection state.
local module_dir="/data/adb/modules/hosts"
if [ -d "$module_dir" ]; then
local lock_state="UNLOCKED (Magisk app can disable!)"
if lsattr -d "$module_dir" 2>/dev/null | awk '{print $1}' | grep -q i; then
lock_state="LOCKED (chattr +i)"
fi
echo "Magisk dir: $module_dir [$lock_state]"
local marker_warn=""
for marker in disable remove update; do
if [ -e "$module_dir/$marker" ]; then
marker_warn="$marker_warn $marker"
fi
done
if [ -n "$marker_warn" ]; then
echo "WARN: Magisk markers present:$marker_warn (will be auto-removed by hosts_enforcer)"
fi
else
echo "Magisk dir: <missing - module not installed>"
fi
}
cmd_hosts_start() {
local pid
pid="$(hosts_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Hosts enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/hosts_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(hosts_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Hosts enforcer started (PID $pid)"
else
echo "ERROR: hosts enforcer failed to start. Check log: $HOSTS_LOG"
fi
}
cmd_hosts_stop() {
local pid
pid="$(hosts_enforcer_pid)"
if [ -z "$pid" ]; then
echo "Hosts enforcer not running"
rm -f "$HOSTS_PIDFILE"
return
fi
kill -TERM "$pid"
echo "Hosts enforcer stopped (sent SIGTERM to PID $pid)"
}
cmd_hosts_log() {
local lines="${1:-50}"
if [ -f "$HOSTS_LOG" ]; then
tail -n "$lines" "$HOSTS_LOG"
else
echo "Hosts enforcer log not found: $HOSTS_LOG"
fi
}

View File

@ -1,154 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_launcher.sh — focus_ctl.sh's subcommands for the minimalist-launcher enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
LAUNCHER_PIDFILE="$STATE_DIR/launcher_enforcer.pid"
DISABLED_COMPETITORS_FILE="$STATE_DIR/disabled_competitors.txt"
launcher_enforcer_pid() {
if [ -f "$LAUNCHER_PIDFILE" ]; then
local pid
pid="$(cat "$LAUNCHER_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_launcher_snapshot() {
# Find the APK path for the currently-installed launcher and copy it
# to LAUNCHER_APK. Also capture the current HOME activity component.
local apk_path
apk_path="$(pm path "$LAUNCHER_PACKAGE" 2>/dev/null | head -1 | sed 's/^package://')"
if [ -z "$apk_path" ] || [ ! -f "$apk_path" ]; then
echo "ERROR: $LAUNCHER_PACKAGE is not installed. Install it once via Aurora/Play Store, then rerun this command."
return 1
fi
mkdir -p "$(dirname "$LAUNCHER_APK")"
chattr -i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true
cp "$apk_path" "$LAUNCHER_APK" || return 1
chmod 644 "$LAUNCHER_APK"
sha256sum "$LAUNCHER_APK" | awk '{print $1}' >"$LAUNCHER_SHA_FILE"
chmod 644 "$LAUNCHER_SHA_FILE"
# Resolve the current HOME activity (or the launcher's default activity
# if it isn't yet the default).
local component
component="$(cmd package resolve-activity --brief \
-c android.intent.category.HOME \
-a android.intent.action.MAIN 2>/dev/null | awk 'NR==2{print}')"
if [ -z "$component" ] || [ "${component%%/*}" != "$LAUNCHER_PACKAGE" ]; then
# Fall back to the launcher's MAIN/LAUNCHER activity
component="$(cmd package resolve-activity --brief \
-c android.intent.category.LAUNCHER \
-a android.intent.action.MAIN "$LAUNCHER_PACKAGE" 2>/dev/null |
awk 'NR==2{print}')"
fi
if [ -z "$component" ]; then
echo "ERROR: could not resolve HOME activity for $LAUNCHER_PACKAGE"
return 1
fi
echo "$component" >"$LAUNCHER_ACTIVITY_FILE"
chmod 644 "$LAUNCHER_ACTIVITY_FILE"
# Make snapshot immutable so even root-in-a-terminal can't overwrite
# it without first running `chattr -i`.
chattr +i "$LAUNCHER_APK" "$LAUNCHER_SHA_FILE" "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null || true
echo "Snapshot saved:"
echo " APK: $LAUNCHER_APK ($(wc -c <"$LAUNCHER_APK") bytes)"
echo " SHA256: $(cat "$LAUNCHER_SHA_FILE")"
echo " Activity: $component"
}
cmd_launcher_status() {
local pid
pid="$(launcher_enforcer_pid)"
echo "=== Launcher Enforcer Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Package: $LAUNCHER_PACKAGE"
if pm path "$LAUNCHER_PACKAGE" >/dev/null 2>&1; then
echo "Installed: YES ($(pm path "$LAUNCHER_PACKAGE" | head -1))"
else
echo "Installed: NO"
fi
local desired actual
desired="$(cat "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null)"
actual="$(cmd package resolve-activity --brief \
-c android.intent.category.HOME -a android.intent.action.MAIN \
2>/dev/null | awk 'NR==2{print}')"
echo "Expected: ${desired:-<not armed - run launcher-snapshot>}"
echo "Actual: ${actual:-<unresolved>}"
if [ -n "$desired" ] && [ "$desired" = "$actual" ]; then
echo "Default: OK (pinned)"
else
echo "Default: MISMATCH"
fi
echo "Snapshot: $LAUNCHER_APK"
if [ -f "$LAUNCHER_APK" ]; then
echo "Snapshot size: $(wc -c <"$LAUNCHER_APK") bytes"
fi
if [ -s "$DISABLED_COMPETITORS_FILE" ]; then
echo "Disabled competitors:"
sed 's/^/ - /' "$DISABLED_COMPETITORS_FILE"
fi
}
cmd_launcher_start() {
local pid
pid="$(launcher_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Launcher enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/launcher_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(launcher_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Launcher enforcer started (PID $pid)"
else
echo "ERROR: launcher enforcer failed to start. Check log: $LAUNCHER_LOG"
fi
}
cmd_launcher_stop() {
local pid
pid="$(launcher_enforcer_pid)"
if [ -z "$pid" ]; then
echo "Launcher enforcer not running"
rm -f "$LAUNCHER_PIDFILE"
else
kill -TERM "$pid"
echo "Launcher enforcer stopped (sent SIGTERM to PID $pid)"
fi
# Re-enable any competitors we disabled so the device is usable if the
# enforcer is intentionally stopped (e.g. during maintenance).
if [ -s "$DISABLED_COMPETITORS_FILE" ]; then
while read -r pkg; do
[ -z "$pkg" ] && continue
pm enable --user 0 "$pkg" >/dev/null 2>&1 &&
echo "Re-enabled competing launcher: $pkg"
done <"$DISABLED_COMPETITORS_FILE"
: >"$DISABLED_COMPETITORS_FILE"
fi
}
cmd_launcher_log() {
local lines="${1:-50}"
if [ -f "$LAUNCHER_LOG" ]; then
tail -n "$lines" "$LAUNCHER_LOG"
else
echo "Launcher enforcer log not found: $LAUNCHER_LOG"
fi
}

View File

@ -1,104 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_tether.sh — focus_ctl.sh's subcommands for the tethering enforcer:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
TETHER_PIDFILE="$STATE_DIR/tether_enforcer.pid"
tether_enforcer_pid() {
if [ -f "$TETHER_PIDFILE" ]; then
local pid
pid="$(cat "$TETHER_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_tether_status() {
local pid
pid="$(tether_enforcer_pid)"
echo "=== Hotspot / Tethering Block Status ==="
echo "Enabled: ${TETHER_ENFORCER_ENABLED}"
if [ -n "$pid" ]; then
echo "Enforcer: RUNNING (PID $pid)"
else
echo "Enforcer: STOPPED"
fi
if [ -f "$MODE_FILE" ]; then
echo "Focus mode: $(cat "$MODE_FILE" 2>/dev/null)"
fi
[ -e "$TETHER_FORCE_FILE" ] && echo "Forced ON: YES (test active)"
[ -e "$TETHER_OVERRIDE_FILE" ] && echo "Override: YES (block SUSPENDED)"
[ -e "$TETHER_ENFORCER_STATE" ] && echo "Applied now: YES (offload off + FORWARD reject)" ||
echo "Applied now: no"
local off
off="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)"
echo "tether_offload_disabled: ${off:-<unset>}"
if iptables -C FORWARD -j "$TETHER_IPT_CHAIN" >/dev/null 2>&1; then
echo "iptables $TETHER_IPT_CHAIN: pinned to FORWARD ($(iptables -S "$TETHER_IPT_CHAIN" 2>/dev/null | grep -c '^-A') rule)"
else
echo "iptables $TETHER_IPT_CHAIN: absent (block not applied)"
fi
}
cmd_tether_start() {
local pid
pid="$(tether_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Tether enforcer already running (PID $pid)"
return
fi
setsid sh "$SCRIPT_DIR/tether_enforcer.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(tether_enforcer_pid)"
if [ -n "$pid" ]; then
echo "Tether enforcer started (PID $pid)"
else
echo "ERROR: tether enforcer failed to start. Check log: $TETHER_LOG"
fi
}
cmd_tether_stop() {
local pid
pid="$(tether_enforcer_pid)"
if [ -z "$pid" ]; then
echo "Tether enforcer not running"
rm -f "$TETHER_PIDFILE"
else
kill -TERM "$pid"
echo "Tether enforcer stopped (sent SIGTERM to PID $pid)"
fi
# Explicit teardown so maintenance can tether even if the daemon was already
# dead (its own TERM handler reverts too; this is the belt-and-suspenders).
iptables -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null || true
iptables -F "$TETHER_IPT_CHAIN" 2>/dev/null || true
iptables -X "$TETHER_IPT_CHAIN" 2>/dev/null || true
ip6tables -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null || true
ip6tables -F "$TETHER_IPT_CHAIN" 2>/dev/null || true
ip6tables -X "$TETHER_IPT_CHAIN" 2>/dev/null || true
echo "FORWARD chain $TETHER_IPT_CHAIN removed (tethering restored)"
}
cmd_tether_log() { tail -n "${1:-50}" "$TETHER_LOG" 2>/dev/null || echo "No tether log yet."; }
# Test hook: force the block ACTIVE regardless of location so the full stack can
# be validated during the day with a real second phone on the hotspot.
cmd_tether_test_on() {
touch "$TETHER_FORCE_FILE"
chmod 666 "$TETHER_FORCE_FILE" 2>/dev/null || true
rm -f "$TETHER_OVERRIDE_FILE"
echo "Tether block FORCED ON. Enforcer engages within ${TETHER_CHECK_INTERVAL}s."
echo "Validate: second phone on the hotspot loses internet; this phone stays online."
}
cmd_tether_test_off() {
rm -f "$TETHER_FORCE_FILE"
echo "Tether force cleared. Back to focus-mode-gated behaviour."
}

View File

@ -1,144 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_usage.sh — focus_ctl.sh's help text and the two commands that report on
# the whitelist: list-apps and whitelist.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one directory.
# Sourced by focus_ctl.sh, which owns the config.sh globals these read.
#
# NOTE on iter_whitelist_packages: its `while read` loop is fed by a pipe from
# printf, and nothing in the body reads stdin, so the pipe is safe here. That
# is NOT true of the pm-driven loops elsewhere in this repo -- see the warning
# in daemon_apps.sh before converting any redirect-driven loop to a pipe.
# Emit one valid package name per line from WHITELIST.
# This strips comments/blank lines from the multi-line quoted string and avoids
# treating heading text (e.g. "---") as package tokens.
iter_whitelist_packages() {
printf '%s\n' "$WHITELIST" | while IFS= read -r line; do
line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
# Blank lines only. A comment needs no case of its own: `set -- $line`
# below splits on whitespace, so a comment's first token is always "#",
# which the dot check then rejects. A dedicated `\#*)` arm here was
# provably equivalent -- a mutation removing it could not be
# distinguished by any input -- and equivalent code is a claim of a
# check that is not actually being made.
case "$line" in
"") continue ;;
esac
# Keep first token only; ignore any inline prose if present.
# Intentional word split to grab the first token. This runs under
# /system/bin/sh on the phone, where arrays do not exist, so an
# unquoted expansion is the POSIX way to do it.
# shellcheck disable=SC2086
set -- $line
pkg="$1"
# Package names are dot-delimited identifiers.
case "$pkg" in
*.*) ;;
*) continue ;;
esac
case "$pkg" in
*[!A-Za-z0-9._]*) continue ;;
esac
echo "$pkg"
done
}
usage() {
echo "Usage: focus_ctl.sh <command>"
echo ""
echo "Commands:"
echo " start - Start the focus mode daemon"
echo " stop - Stop the daemon and re-enable all apps"
echo " status - Show current mode, location and disabled apps"
echo " enable - Force focus mode on (regardless of location)"
echo " disable - Force focus mode off (regardless of location)"
echo " log - Show daemon log"
echo " list-apps - List all non-whitelisted third-party apps"
echo " whitelist - List currently whitelisted packages"
echo " restart - Restart the daemon"
echo " hosts-status - Show hosts enforcer state (mount + hash)"
echo " hosts-start - Start the hosts enforcer daemon"
echo " hosts-stop - Stop the hosts enforcer daemon"
echo " hosts-log - Show hosts enforcer log"
echo " dns-status - Show DNS enforcer state (Private DNS + iptables)"
echo " dns-start - Start the DNS enforcer daemon"
echo " dns-stop - Stop the DNS enforcer daemon (removes iptables chain)"
echo " dns-log - Show DNS enforcer log"
echo " launcher-status - Show launcher enforcer state"
echo " launcher-start - Start the launcher enforcer daemon"
echo " launcher-stop - Stop the launcher enforcer daemon"
echo " launcher-log - Show launcher enforcer log"
echo " launcher-snapshot - Back up currently-installed launcher APK"
echo " workout-status - Show StrongLifts workout-detection state"
echo " workout-start - Start the workout detector daemon"
echo " workout-stop - Stop the workout detector daemon (sets flag=0)"
echo " workout-log - Show workout detector log"
echo " recheck - Nudge the daemon to perform a fresh location check now"
echo " curfew-status - Show night-curfew + enforcer state"
echo " curfew-start - Start the curfew enforcer (grayscale/DND/net)"
echo " curfew-stop - Stop it and restore daytime display/DND"
echo " curfew-log - Show curfew enforcer log"
echo " curfew-test-on - Force curfew ACTIVE now (daytime validation)"
echo " curfew-test-off - Clear the test force"
echo " curfew-demo-on - Start a demo: full curfew now, easy one-tap off"
echo " curfew-demo-off - Stop the demo"
echo " curfew-off - Escape hatch: suspend curfew now (2am opt-out)"
echo " curfew-on - Re-arm curfew (clear the override)"
echo " tether-status - Show hotspot/tethering-block state"
echo " tether-start - Start the tether enforcer (FORWARD block + offload off)"
echo " tether-stop - Stop it and restore tethering (teardown FORWARD chain)"
echo " tether-log - Show tether enforcer log"
echo " tether-test-on - Force the tether block ACTIVE now (daytime validation)"
echo " tether-test-off - Clear the tether force"
echo " notif-status - Show companion status-notification details"
echo ""
}
cmd_list_apps() {
echo "=== Third-party apps NOT in whitelist ==="
for pkg in $(pm list packages -3 2>/dev/null | sed 's/^package://'); do
whitelisted=0
for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue
[ "$pkg" = "$w" ] && {
whitelisted=1
break
}
done
if [ "$whitelisted" -eq 0 ]; then
# Check if currently disabled by focus mode
if grep -qF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null; then
echo " [BLOCKED] $pkg"
else
echo " [active] $pkg"
fi
fi
done
echo ""
echo "=== Whitelisted apps ==="
for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue
echo " [allowed] $w"
done
}
cmd_whitelist() {
echo "=== Whitelisted packages ==="
for w in $(iter_whitelist_packages); do
w="$(echo "$w" | tr -d '[:space:]')"
[ -z "$w" ] && continue
# Check if installed
if pm list packages "$w" 2>/dev/null | grep -qF "$w"; then
echo " [installed] $w"
else
echo " [not found] $w"
fi
done
}

View File

@ -1,122 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ctl_workout.sh — focus_ctl.sh's subcommands for the workout detector:
# status, start, stop and log.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory. Sourced by focus_ctl.sh, which owns log() and the config.sh
# globals these read. The pidfile path below lives here rather than in
# the entry script because this file is its only reader (SC2034), and
# the linter runs without -x, so each file stands alone.
WORKOUT_PIDFILE="$STATE_DIR/workout_detector.pid"
workout_detector_pid() {
if [ -f "$WORKOUT_PIDFILE" ]; then
local pid
pid="$(cat "$WORKOUT_PIDFILE")"
if kill -0 "$pid" 2>/dev/null; then
echo "$pid"
fi
fi
}
cmd_workout_status() {
local pid
pid="$(workout_detector_pid)"
echo "=== Workout Detector Status ==="
if [ -n "$pid" ]; then
echo "Daemon: RUNNING (PID $pid)"
else
echo "Daemon: STOPPED"
fi
echo "Package: $WORKOUT_TRIGGER_PACKAGE"
if pm path "$WORKOUT_TRIGGER_PACKAGE" >/dev/null 2>&1; then
echo "Installed: YES"
else
echo "Installed: NO (detector will always report inactive)"
fi
echo "sqlite3: $WORKOUT_SQLITE3_BIN"
if [ -x "$WORKOUT_SQLITE3_BIN" ]; then
echo "sqlite3 ver: $("$WORKOUT_SQLITE3_BIN" -version 2>/dev/null | awk '{print $1}')"
else
echo "sqlite3 ver: <missing or not executable — detector cannot query DB>"
fi
echo "DB path: $WORKOUT_DB_PATH"
if [ -f "$WORKOUT_DB_PATH" ]; then
echo "DB present: YES"
else
echo "DB present: NO"
fi
echo "Poll interval: ${WORKOUT_DETECTOR_INTERVAL}s"
local flag="<unset>"
if [ -f "$WORKOUT_ACTIVE_FILE" ]; then
flag="$(cat "$WORKOUT_ACTIVE_FILE" 2>/dev/null)"
fi
case "$flag" in
1) echo "Workout flag: 1 (workout IN PROGRESS → YouTube hosts UNBLOCKED)" ;;
0) echo "Workout flag: 0 (no workout → YouTube hosts BLOCKED)" ;;
*) echo "Workout flag: '$flag' (treated as 0, fail-closed)" ;;
esac
# Live one-shot query so the user can see ground truth without waiting
# for the next poll cycle. Best-effort — never fails the status command.
if [ -x "$WORKOUT_SQLITE3_BIN" ] && [ -f "$WORKOUT_DB_PATH" ]; then
local live_count
live_count="$("$WORKOUT_SQLITE3_BIN" "file:${WORKOUT_DB_PATH}?mode=ro" \
"SELECT COUNT(*) FROM workouts WHERE start>0 AND (finish IS NULL OR finish=0);" \
2>/dev/null)"
echo "Live DB query: in-progress workouts = ${live_count:-<query failed>}"
fi
if [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then
echo "Workout hosts: $HOSTS_CANONICAL_WORKOUT ($(wc -l <"$HOSTS_CANONICAL_WORKOUT" 2>/dev/null) lines)"
else
echo "Workout hosts: <missing — deploy.sh must regenerate it>"
fi
}
cmd_workout_start() {
local pid
pid="$(workout_detector_pid)"
if [ -n "$pid" ]; then
echo "Workout detector already running (PID $pid)"
return
fi
if [ ! -x "$WORKOUT_SQLITE3_BIN" ]; then
echo "ERROR: $WORKOUT_SQLITE3_BIN missing or not executable. Re-run deploy.sh."
return 1
fi
setsid sh "$SCRIPT_DIR/workout_detector.sh" </dev/null >/dev/null 2>&1 &
sleep 2
pid="$(workout_detector_pid)"
if [ -n "$pid" ]; then
echo "Workout detector started (PID $pid)"
else
echo "ERROR: Workout detector failed to start. Check log: $WORKOUT_DETECTOR_LOG"
fi
}
cmd_workout_stop() {
local pid
pid="$(workout_detector_pid)"
if [ -z "$pid" ]; then
echo "Workout detector not running"
rm -f "$WORKOUT_PIDFILE"
else
kill -TERM "$pid"
echo "Workout detector stopped (sent SIGTERM to PID $pid)"
fi
# Fail-closed on manual stop: write 0 so the hosts enforcer reverts to
# the full-block canonical and YouTube goes back to being blocked.
printf '0\n' >"$WORKOUT_ACTIVE_FILE" 2>/dev/null || true
chmod 666 "$WORKOUT_ACTIVE_FILE" 2>/dev/null || true
echo "workout_active flag forced to 0"
}
cmd_workout_log() {
local lines="${1:-50}"
if [ -f "$WORKOUT_DETECTOR_LOG" ]; then
tail -n "$lines" "$WORKOUT_DETECTOR_LOG"
else
echo "Workout detector log not found: $WORKOUT_DETECTOR_LOG"
fi
}

View File

@ -1,244 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# Night-curfew enforcer for rooted Android.
#
# Companion to focus_daemon.sh. The daemon handles the APP layer (disabling
# everything not in $NIGHT_WHITELIST while the curfew window is open at home).
# This enforcer adds the three "make the phone boring + unreachable" layers and
# keeps them locked by re-applying every $CURFEW_ENFORCER_INTERVAL seconds:
#
# 1. Grayscale - force the display monochrome via the accessibility
# daltonizer. The single biggest behavioural deterrent.
# 2. DND - force Do-Not-Disturb to alarms-only so notifications stop
# pulling you back in, while the morning alarm still rings.
# 3. Net curfew - (default OFF) per-UID iptables allow-list: only the
# $NIGHT_WHITELIST app UIDs (plus root/system/shell + DNS)
# get network; every other app is cut off.
#
# "Locked" = snap-back: a manual toggle in Settings is reverted within one
# interval. True impossibility would require blocking the Settings app, which
# risks system instability, so we deliberately do not.
#
# Acts ONLY while curfew is active (time window or forced) AND, for the
# non-forced case, while focus mode is ON (i.e. you are at home). On the
# transition back to day it restores the snapshotted display/DND state and
# tears the iptables chain down, so daytime is left exactly as it was.
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=config.sh
. "$SCRIPT_DIR/config.sh"
# shellcheck source=curfew_net.sh
. "$SCRIPT_DIR/curfew_net.sh"
PIDFILE="$STATE_DIR/curfew_enforcer.pid"
# Snapshot of the user's pre-curfew display/DND state, captured on entry and
# restored on exit so we never clobber settings we did not set.
GRAYSCALE_SNAP="$STATE_DIR/curfew_grayscale.snap"
mkdir -p "$STATE_DIR"
touch "$CURFEW_ENFORCER_LOG"
chmod 666 "$CURFEW_ENFORCER_LOG" 2>/dev/null || true
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >> "$CURFEW_ENFORCER_LOG"
}
rotate_log() {
local lines
lines="$(wc -l < "$CURFEW_ENFORCER_LOG" 2>/dev/null || echo 0)"
if [ "$lines" -gt 500 ]; then
local tmp="$CURFEW_ENFORCER_LOG.tmp"
tail -n 500 "$CURFEW_ENFORCER_LOG" > "$tmp"
mv "$tmp" "$CURFEW_ENFORCER_LOG"
fi
}
acquire_lock() {
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
local cmdline
cmdline="$(tr '\0' ' ' < "/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "curfew_enforcer"; then
echo "curfew_enforcer already running (PID $old_pid)"
exit 0
fi
fi
rm -f "$PIDFILE"
fi
echo $$ > "$PIDFILE"
}
# ---- Time / activation (mirrors focus_daemon.sh::curfew_active) ----
_dec() {
# Strip leading zeros so a zero-padded HHMM ("0500", "0830") is not parsed
# as (sometimes invalid) octal by the shell's arithmetic. Keeps one digit.
local n="$1"
while [ "${n#0}" != "$n" ] && [ "${#n}" -gt 1 ]; do n="${n#0}"; done
printf '%s' "$n"
}
is_curfew_now() {
local now start end
now="$(date +%H%M 2>/dev/null)"
case "$now" in
''|*[!0-9]*) return 1 ;;
esac
now="$(_dec "$now")"; start="$(_dec "$NIGHT_CURFEW_START")"; end="$(_dec "$NIGHT_CURFEW_END")"
if [ "$start" -le "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
fi
}
at_home() {
[ -f "$MODE_FILE" ] && [ "$(cat "$MODE_FILE" 2>/dev/null)" = "focus" ]
}
# Whether this enforcer should be applying its restrictions right now.
should_act() {
[ "${NIGHT_CURFEW_ENABLED:-0}" = "1" ] || return 1
[ -e "$CURFEW_OVERRIDE_FILE" ] && return 1
# Forced (test hook) bypasses both the clock and the home gate so the full
# stack can be validated during the day from anywhere.
[ -e "$CURFEW_FORCE_FILE" ] && return 0
is_curfew_now && at_home
}
# ---- Layer 1: grayscale ----
apply_grayscale() {
[ "${CURFEW_GRAYSCALE_ENABLED:-0}" = "1" ] || return 0
settings put secure accessibility_display_daltonizer_enabled 1 2>/dev/null || true
# Daltonizer "0" = full monochrome (grayscale).
settings put secure accessibility_display_daltonizer 0 2>/dev/null || true
}
snapshot_grayscale() {
local en lv
en="$(settings get secure accessibility_display_daltonizer_enabled 2>/dev/null)"
lv="$(settings get secure accessibility_display_daltonizer 2>/dev/null)"
printf '%s\n%s\n' "${en:-0}" "${lv:-0}" > "$GRAYSCALE_SNAP" 2>/dev/null || true
}
restore_grayscale() {
[ "${CURFEW_GRAYSCALE_ENABLED:-0}" = "1" ] || return 0
local en lv
if [ -f "$GRAYSCALE_SNAP" ]; then
en="$(sed -n '1p' "$GRAYSCALE_SNAP")"
lv="$(sed -n '2p' "$GRAYSCALE_SNAP")"
fi
# If the snapshot is missing or "null", default to disabled (the norm).
case "$en" in ''|null) en=0 ;; esac
case "$lv" in ''|null) lv=-1 ;; esac
settings put secure accessibility_display_daltonizer_enabled "$en" 2>/dev/null || true
if [ "$lv" != "-1" ]; then
settings put secure accessibility_display_daltonizer "$lv" 2>/dev/null || true
fi
}
# ---- Layer 2: Do-Not-Disturb (alarms only) ----
apply_dnd() {
[ "${CURFEW_DND_ENABLED:-0}" = "1" ] || return 0
# alarms-only lets the morning alarm ring but silences everything else.
cmd notification set_dnd alarms >/dev/null 2>&1 || true
}
restore_dnd() {
[ "${CURFEW_DND_ENABLED:-0}" = "1" ] || return 0
cmd notification set_dnd off >/dev/null 2>&1 || true
}
# ---- Layer 3: per-UID network allow-list (default OFF) ----
# Set to 1 once the net chain has been built in this process. Used purely to
# tell an *anomalous* mid-curfew disappearance (something outside this script
# deleted the chain) apart from the legitimate first build on curfew entry.
# Because it is a process-local var, a fresh enforcer starts empty and never
# false-flags its own initial build.
NET_BUILT=""
# ---- Apply / revert orchestration ----
enter_curfew() {
if [ ! -e "$CURFEW_ENFORCER_STATE" ]; then
snapshot_grayscale
: > "$CURFEW_ENFORCER_STATE"
log "Curfew ON - locking grayscale${CURFEW_DND_ENABLED:+ + DND}${CURFEW_NET_ENABLED:+ + net}"
fi
# Re-apply every tick so manual toggles snap back.
apply_grayscale
apply_dnd
apply_net
}
exit_curfew() {
[ -e "$CURFEW_ENFORCER_STATE" ] || return 0
restore_grayscale
restore_dnd
teardown_net
NET_BUILT=""
rm -f "$CURFEW_ENFORCER_STATE" "$CURFEW_NET_UID_CACHE"
log "Curfew OFF - restored display/DND, tore down net chain"
}
cleanup() {
# On a clean stop, leave the user back in daytime state.
log "curfew_enforcer shutting down - reverting"
exit_curfew
rm -f "$PIDFILE"
exit 0
}
trap cleanup INT TERM
main() {
acquire_lock
log "curfew_enforcer started (PID=$$, window=${NIGHT_CURFEW_START}-${NIGHT_CURFEW_END}, net=${CURFEW_NET_ENABLED})"
local tick=0 act netstate rebuilds
while true; do
if should_act; then act=1; enter_curfew; else act=0; exit_curfew; fi
# Heartbeat every ~6 ticks (~30s): proves the loop is alive even when it
# is quietly re-applying. Without this, "alive but idle" and "dead" look
# identical in the log, so process death can't be inferred from silence.
tick=$((tick + 1))
if [ "$((tick % 6))" -eq 0 ]; then
if iptw iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
netstate=up
else
netstate=down
fi
log "heartbeat tick=$tick act=$act net=$netstate"
fi
rotate_log
# While curfew is active with the net layer on, hold the chain pinned
# against netd's table rewrites for the whole interval (fast watchdog),
# instead of sleeping blind. Otherwise a plain sleep is enough.
if [ "$act" = 1 ] && [ "${CURFEW_NET_ENABLED:-0}" = "1" ]; then
rebuilds="$(net_hold "$CURFEW_ENFORCER_INTERVAL")"
[ "${rebuilds:-0}" -gt 0 ] 2>/dev/null \
&& log "net watchdog re-pinned chain ${rebuilds}x in last ${CURFEW_ENFORCER_INTERVAL}s (netd flush)"
else
sleep "$CURFEW_ENFORCER_INTERVAL"
fi
done
}
main "$@"

View File

@ -1,156 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# curfew_net.sh — the network layer of the night curfew: the per-UID
# iptables allow-list that cuts every app outside $NIGHT_WHITELIST off the
# network while the curfew is open. Default OFF ($CURFEW_NET_ENABLED).
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory, which is why dns_iptables.sh and tether_iptables.sh live
# beside their enforcers too.
#
# Sourced by curfew_enforcer.sh, which owns log(), the config.sh globals
# these read ($CURFEW_NET_*, $NIGHT_WHITELIST, $STATE_DIR) and $NET_BUILT,
# the build-once flag both files write.
# Resolve the UIDs of the night-whitelisted packages. Apps not installed are
# silently skipped. Output: one numeric UID per line.
night_uids() {
local plist="$STATE_DIR/night_whitelist.txt"
[ -f "$plist" ] || return 0
# `pm list packages -U` lines look like: "package:com.foo uid:10123"
local map="$STATE_DIR/uid_map.txt"
pm list packages -U 2>/dev/null \
| sed 's/^package://' > "$map"
# Piped rather than redirected on `done`: kcov instruments a trailing
# `done < file` as a line bash never reports, which alone held this file
# below 100%. The loop only writes to stdout, so running it in the pipe's
# subshell changes nothing — unlike the pull_apks case in
# backup_capture.sh, nothing inside here consumes stdin.
cat "$plist" | while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
awk -v p="$pkg" '$1 == p { sub(/uid:/,"",$2); print $2 }' "$map"
done
rm -f "$map"
}
ensure_net_chain() {
local ipt="$1"
if ! iptw "$ipt" -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
iptw "$ipt" -N "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
fi
# De-dupe and pin exactly one OUTPUT jump at position 1.
while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done
iptw "$ipt" -I OUTPUT 1 -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
}
fill_net_chain() {
local ipt="$1" reject="$2"
iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
# Always-allowed plumbing: loopback, established flows, the OS itself, the
# daemon/ADB (root + shell), and DNS (apps resolve via netd, a different
# uid, so allow port 53 broadly or every lookup fails under the cut-off).
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -o lo -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 0 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 1000 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 2000 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p udp --dport 53 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true
# Allow each whitelisted app UID, read from the cache (refreshed once per
# main tick). Reading the cache instead of calling night_uids() here keeps
# the fast watchdog fork-free (no `pm list packages` on every rebuild).
local uid
if [ -f "$CURFEW_NET_UID_CACHE" ]; then
# Piped for the same reason as night_uids above: a trailing
# `done < file` is a line kcov counts and bash never reports. The
# loop's only effect is the iptw calls, which are external commands,
# so the pipe's subshell is immaterial here.
cat "$CURFEW_NET_UID_CACHE" | while IFS= read -r uid; do
case "$uid" in ''|*[!0-9]*) continue ;; esac
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner "$uid" -j ACCEPT 2>/dev/null || true
done
fi
# Cut off every remaining ordinary APP uid (10000-19999 = user-0 app range).
# Scoped to the app range so kernel/system sockets (no owner / low uids) are
# never touched — far safer than a blanket default-DROP.
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 10000-19999 -j REJECT \
--reject-with "$reject" 2>/dev/null || true
}
# Run iptables/ip6tables with a 2s xtables lock-wait. Android's netd runs its
# own concurrent `iptables-restore`; without -w our calls silently fail the
# instant netd holds the lock (proven on-device: partial 19-rule chains and
# multi-second outages). -w queues for the lock so our calls actually land.
# iptables 1.8.7 legacy supports it. $1 = binary (iptables/ip6tables).
iptw() {
local bin="$1"
shift
"$bin" -w 2 "$@"
}
# Refresh the cached UID list (one `pm list packages -U` fork). Called once per
# main tick so the fast watchdog can rebuild from the cache without forking.
refresh_uid_cache() {
if night_uids > "$CURFEW_NET_UID_CACHE.tmp" 2>/dev/null; then
mv "$CURFEW_NET_UID_CACHE.tmp" "$CURFEW_NET_UID_CACHE" 2>/dev/null || true
fi
}
# Rebuild the chain from cache for whichever iptables variants exist. No pm fork.
rebuild_net_from_cache() {
if command -v iptables >/dev/null 2>&1; then
ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable
fi
if command -v ip6tables >/dev/null 2>&1; then
ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable
fi
}
# Fast watchdog: for `total` seconds, every CURFEW_NET_REASSERT_INTERVAL check
# whether netd wiped our chain and, if so, re-pin it from cache. Replaces the
# plain inter-tick sleep while curfew is active so the leak window drops from
# the full 5s tick to <=1s. Echoes the number of rebuilds it performed.
net_hold() {
local total="$1" elapsed=0 rebuilds=0 step="${CURFEW_NET_REASSERT_INTERVAL:-1}"
while [ "$elapsed" -lt "$total" ]; do
sleep "$step"
elapsed=$((elapsed + step))
if command -v iptables >/dev/null 2>&1 \
&& ! iptw iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
rebuild_net_from_cache
rebuilds=$((rebuilds + 1))
fi
done
echo "$rebuilds"
}
apply_net() {
[ "${CURFEW_NET_ENABLED:-0}" = "1" ] || return 0
refresh_uid_cache
# Discriminating probe: if we already built the chain on a prior tick but it
# is gone now, an external actor wiped it (Android netd rewriting the filter
# table, or a manual flush during debugging). Log each disappearance so the
# live test reads "flush + self-heal" vs "dead process" directly, instead of
# inferring it from log silence.
if [ -n "$NET_BUILT" ] && command -v iptables >/dev/null 2>&1 \
&& ! iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
log "net chain $CURFEW_NET_IPT_CHAIN vanished since last tick - rebuilding (external flush?)"
fi
if command -v iptables >/dev/null 2>&1; then
ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable
fi
if command -v ip6tables >/dev/null 2>&1; then
ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable
fi
NET_BUILT=1
}
teardown_net() {
local ipt
for ipt in iptables ip6tables; do
command -v "$ipt" >/dev/null 2>&1 || continue
while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done
iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true
iptw "$ipt" -X "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true
done
}

View File

@ -1,165 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# daemon_apps.sh — the app layer of focus_daemon.sh: deciding whether a
# package is allowed, disabling everything that is not while focus mode is on,
# and re-enabling it afterwards.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one directory.
#
# Sourced by focus_daemon.sh, which owns log(), $CURRENT_MODE (written here
# and in init, read by main, so it stays with them) and the config.sh globals
# these read ($DISABLED_APPS_FILE, $MODE_FILE, $STATE_DIR).
#
# NOTE for anyone editing the `while IFS= read -r pkg; do ... done <file`
# loops below: they must stay redirect-driven. Every body calls pm, which
# inherits and drains the loop's stdin if the file is piped in instead — the
# measured symptom elsewhere in this repo was processing 1 package of 54.
reconcile_disabled_apps() {
[ -f "$DISABLED_APPS_FILE" ] || return
local tmp_disabled="$STATE_DIR/disabled_by_focus.tmp"
: >"$tmp_disabled"
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
if is_allowed "$pkg"; then
pm install-existing --user 0 "$pkg" >/dev/null 2>&1 || true
pm enable "$pkg" >/dev/null 2>&1 || true
log "Re-enabled allowed app during state reconciliation: $pkg"
continue
fi
echo "$pkg" >>"$tmp_disabled"
done <"$DISABLED_APPS_FILE"
mv "$tmp_disabled" "$DISABLED_APPS_FILE"
}
# ---- Check if package is allowed (whitelist or system-protected) ----
is_allowed() {
local pkg="$1"
# During the night curfew, swap the permissive day list for the strict
# night list. The sysprotect + default-handler guards below still apply on
# top of whichever list is active.
local list="$STATE_DIR/whitelist.txt"
if curfew_active; then
list="$STATE_DIR/night_whitelist.txt"
fi
# Exact match against the active whitelist file
if grep -qxF "$pkg" "$list" 2>/dev/null; then
return 0
fi
# Prefix match against system-protect file
while IFS= read -r prefix; do
[ -z "$prefix" ] && continue
case "$pkg" in
"$prefix"*) return 0 ;;
esac
done <"$STATE_DIR/sysprotect.txt"
# Hard-stop guard: refuse to disable any package that is the current
# default handler for a critical role (Dialer / SMS / Home / Contacts).
# Without this, a misconfigured WHITELIST can disable the default Phone
# app and Android falls back to com.android.settings/.FallbackHome -
# the persistent "Phone is starting..." screen with broken SystemUI
# gestures (no swipe-up recents). Recovering requires `pm enable` over
# ADB. Treat the guard as last-resort safety net independent of WHITELIST
# contents so a future config edit can never wipe these out.
is_default_handler "$pkg" && return 0
# The default browser is guarded only OUTSIDE curfew. At night the whole
# point is to disable browsers, so this guard must not re-allow it.
if ! curfew_active &&
grep -qxF "$pkg" "$STATE_DIR/default_browser.txt" 2>/dev/null; then
return 0
fi
return 1
}
enable_focus_mode() {
local first_entry=0
if [ "$CURRENT_MODE" != "focus" ]; then
first_entry=1
log "ENABLING focus mode - restricting non-whitelisted apps"
: >"$DISABLED_APPS_FILE"
fi
# Refresh default-handler list every tick. The user may switch dialer /
# SMS / launcher between sweeps; the guard in is_allowed() consults this
# list so a newly-promoted handler is never disabled.
refresh_default_handlers
# Blocked system app list is static; built once in init() (see
# build_blocked_sys_file), not rebuilt on every sweep.
local blocked_sys="$STATE_DIR/blocked_sys.txt"
# Periodic rescan catches third-party apps the user re-enabled (e.g. via
# Play Store or `pm enable` in a terminal) since the last tick.
# -e = enabled only, so we skip apps that are already disabled.
local tmp_pkgs="$STATE_DIR/pkg_list.txt"
pm list packages -3 -e 2>/dev/null | sed 's/^package://' >"$tmp_pkgs"
local newly_disabled=0
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
is_allowed "$pkg" && continue
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
grep -qxF "$pkg" "$DISABLED_APPS_FILE" 2>/dev/null ||
echo "$pkg" >>"$DISABLED_APPS_FILE"
newly_disabled=$((newly_disabled + 1))
fi
done <"$tmp_pkgs"
rm -f "$tmp_pkgs"
# 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 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"
CURRENT_MODE="focus"
echo "focus" >"$MODE_FILE"
if [ "$first_entry" -eq 1 ]; then
local count
count=$(wc -l <"$DISABLED_APPS_FILE" 2>/dev/null || echo 0)
log "Focus mode enabled - disabled $count apps"
elif [ "$newly_disabled" -gt 0 ]; then
log "Focus mode re-sweep: re-disabled $newly_disabled apps (re-enabled by user?)"
fi
reconcile_disabled_apps
}
disable_focus_mode() {
[ "$CURRENT_MODE" = "normal" ] && return
log "DISABLING focus mode - re-enabling apps"
local count=0
if [ -f "$DISABLED_APPS_FILE" ] && [ -s "$DISABLED_APPS_FILE" ]; then
# 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))
done <"$DISABLED_APPS_FILE"
: >"$DISABLED_APPS_FILE"
fi
CURRENT_MODE="normal"
echo "normal" >"$MODE_FILE"
log "Focus mode disabled - re-enabled $count apps"
}

View File

@ -1,94 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# daemon_location.sh — where-and-when for focus_daemon.sh: reading the phone's
# GPS fix, the great-circle distance from home, and whether the night curfew
# window is currently open.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one directory.
#
# Sourced by focus_daemon.sh, which owns log() and the config.sh globals these
# read ($NIGHT_CURFEW_*, $CURFEW_FORCE_FILE, $CURFEW_OVERRIDE_FILE). It also
# owns $_CURFEW_TICK_CACHED: curfew_active memoises per tick and main clears
# the memo each loop, so both files write it and it stays with main.
# ---- Location ----
get_location() {
dumpsys location 2>/dev/null |
grep -oE '[-]?[0-9]{1,3}\.[0-9]{4,},[-]?[0-9]{1,3}\.[0-9]{4,}' |
head -1
}
# ---- Distance Calculation (Haversine via awk) ----
calc_distance() {
echo "$1 $2 $3 $4" | awk '{
PI = 3.14159265358979323846
R = 6371000.0
lat1 = $1 * PI / 180.0
lon1 = $2 * PI / 180.0
lat2 = $3 * PI / 180.0
lon2 = $4 * PI / 180.0
dlat = lat2 - lat1
dlon = lon2 - lon1
sdlat = sin(dlat / 2.0)
sdlon = sin(dlon / 2.0)
a = sdlat * sdlat + cos(lat1) * cos(lat2) * sdlon * sdlon
c = 2.0 * atan2(sqrt(a), sqrt(1.0 - a))
printf "%d\n", R * c
}'
}
# ---- Night curfew time check ----
# Returns 0 (true) when the local clock is inside the curfew window.
# Fails OPEN (return 1 = not curfew) on a malformed clock so a broken `date`
# can never strand you behind the strict list — essentials stay reachable
# either way, but the day list is the less-surprising default.
_dec() {
# Strip leading zeros so a zero-padded HHMM ("0500", "0830") is not parsed
# as (sometimes invalid) octal by the shell's arithmetic. Portable across
# ash/mksh; keeps at least one digit so "0000" -> "0".
local n="$1"
while [ "${n#0}" != "$n" ] && [ "${#n}" -gt 1 ]; do n="${n#0}"; done
printf '%s' "$n"
}
is_curfew_now() {
local now start end
now="$(date +%H%M 2>/dev/null)"
case "$now" in
'' | *[!0-9]*) return 1 ;;
esac
now="$(_dec "$now")"
start="$(_dec "$NIGHT_CURFEW_START")"
end="$(_dec "$NIGHT_CURFEW_END")"
if [ "$start" -le "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else
# Window wraps past midnight (e.g. 2300 -> 0500).
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
fi
}
# Curfew is ACTIVE when enabled, not manually overridden, and either forced on
# (test hook) or inside the time window. The is_allowed() switch below consults
# this; because is_allowed() only runs during the focus-mode sweep/reconcile,
# curfew automatically takes effect only at home and is a no-op when away.
#
# Memoized once per main-loop tick (reset at the top of main()'s while loop):
# is_allowed() calls this once per enabled package in the sweep, and
# is_curfew_now forks `date` - on-device this meant up to ~N extra forks per
# 10s tick for N enabled apps, all recomputing a value that cannot change
# within a single tick. Result is cached in _CURFEW_TICK_RESULT.
curfew_active() {
if [ -n "$_CURFEW_TICK_CACHED" ]; then
[ "$_CURFEW_TICK_RESULT" = "1" ]
return
fi
_CURFEW_TICK_CACHED=1
_CURFEW_TICK_RESULT=0
if [ "${NIGHT_CURFEW_ENABLED:-0}" = "1" ] && [ ! -e "$CURFEW_OVERRIDE_FILE" ]; then
if [ -e "$CURFEW_FORCE_FILE" ] || is_curfew_now; then
_CURFEW_TICK_RESULT=1
fi
fi
[ "$_CURFEW_TICK_RESULT" = "1" ]
}

View File

@ -1,140 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# daemon_state.sh — the state files focus_daemon.sh derives from config: the
# whitelists, the system-protect and blocked-system lists, the cached default
# intent handlers, and the JSON status snapshot the companion app reads.
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one directory.
#
# Sourced by focus_daemon.sh, which owns log() and the config.sh globals these
# read ($WHITELIST, $NIGHT_WHITELIST, $SYSTEM_NEVER_DISABLE,
# $BLOCKED_SYSTEM_APPS, $STATE_DIR, $STATUS_FILE, $RADIUS). Nothing here is
# assigned, so there is no global to keep in step.
build_whitelist_file() {
echo "$WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' |
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/whitelist.txt"
# Sanity check: the WHITELIST string in config.sh is fragile - any
# literal double-quote inside a comment will close the heredoc and
# silently truncate the variable. Log the parsed line count so any
# future regression is visible in the log, and warn loudly if it
# falls below a known floor (we always have ~70+ entries).
local n
n=$(wc -l <"$STATE_DIR/whitelist.txt" 2>/dev/null | tr -d ' ')
log "Whitelist parsed: $n entries"
if [ "${n:-0}" -lt 30 ]; then
log "WARN: whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside WHITELIST string"
fi
}
build_night_whitelist_file() {
# Strict allow-list used while the night curfew is active (see config.sh
# NIGHT_WHITELIST and is_curfew_now()). Parsed exactly like the day list.
echo "$NIGHT_WHITELIST" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' |
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/night_whitelist.txt"
local n
n=$(wc -l <"$STATE_DIR/night_whitelist.txt" 2>/dev/null | tr -d ' ')
log "Night-curfew whitelist parsed: $n entries"
if [ "${n:-0}" -lt 10 ]; then
log "WARN: night whitelist suspiciously small ($n lines) - check config.sh for stray quotes inside NIGHT_WHITELIST string"
fi
}
build_sysprotect_file() {
echo "$SYSTEM_NEVER_DISABLE" | grep -v '^[[:space:]]*$' |
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/sysprotect.txt"
}
# $BLOCKED_SYSTEM_APPS is a static config value (never changes without a
# daemon restart), so - like build_sysprotect_file above - this only needs
# to run once at startup, not every enable_focus_mode() sweep.
build_blocked_sys_file() {
echo "$BLOCKED_SYSTEM_APPS" | grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' |
sed 's/^[[:space:]]*//;s/[[:space:]]*$//' >"$STATE_DIR/blocked_sys.txt"
}
# ---- Default handler detection ----
# Refreshed once per focus_daemon tick into $STATE_DIR/default_handlers.txt.
# Each line is a package name. Lookup is a cheap grep against this file.
refresh_default_handlers() {
local f="$STATE_DIR/default_handlers.txt"
local tmp="$f.tmp"
: >"$tmp"
# Default Home (launcher). resolve-activity prints "Activity Resolver Table:"
# on line 1 and "<pkg>/<.Activity>" on line 2 in --brief mode.
cmd package resolve-activity --brief \
-c android.intent.category.HOME -a android.intent.action.MAIN 2>/dev/null |
awk -F/ 'NR==2 && $1 != "" {print $1}' >>"$tmp"
# Default Dialer
local dialer
dialer="$(cmd telecom get-default-dialer 2>/dev/null | tr -d '[:space:]')"
[ -n "$dialer" ] && echo "$dialer" >>"$tmp"
# Default SMS handler (settings provider key)
local sms
sms="$(settings get secure sms_default_application 2>/dev/null | tr -d '[:space:]')"
[ -n "$sms" ] && [ "$sms" != "null" ] && echo "$sms" >>"$tmp"
# Default input method (active keyboard). Disabling the active IME with
# pm disable-user PERSISTS across reboot; a 1am reboot would then leave no
# keyboard to type any recovery command. Protect it day and night so the
# curfew can never lock you out of typing.
local ime
ime="$(settings get secure default_input_method 2>/dev/null | cut -d/ -f1)"
[ -n "$ime" ] && [ "$ime" != "null" ] && echo "$ime" >>"$tmp"
sort -u "$tmp" -o "$f"
rm -f "$tmp"
# Default Browser handler is tracked SEPARATELY and guarded only OUTSIDE
# the curfew window (see is_allowed). During curfew the whole point is to
# disable browsers, so the default-handler guard must not resurrect them.
local bf="$STATE_DIR/default_browser.txt"
cmd package resolve-activity --brief \
-a android.intent.action.VIEW -d http://example.com 2>/dev/null |
awk -F/ 'NR==2 && $1 != "" {print $1}' >"$bf.tmp" 2>/dev/null
mv "$bf.tmp" "$bf" 2>/dev/null || : >"$bf"
}
is_default_handler() {
local pkg="$1"
grep -qxF "$pkg" "$STATE_DIR/default_handlers.txt" 2>/dev/null
}
# ---- Status snapshot for companion notification app ----
# Writes a tiny JSON file that focus_status_app reads every few seconds.
# Fields: mode, lat, lon, distance_m, threshold_m, radius_m, disabled_count,
# last_check_ts (unix), last_check_iso (human).
write_status_snapshot() {
local mode="$1" lat="$2" lon="$3" dist="$4" thr="$5"
local count iso ts cf ov frc
count="$(wc -l <"$DISABLED_APPS_FILE" 2>/dev/null | tr -d ' ' || echo 0)"
[ -z "$count" ] && count=0
ts="$(date +%s)"
iso="$(date '+%Y-%m-%d %H:%M:%S')"
# Curfew state for the companion app: 1/0 so it slots into the existing
# numeric JSON path. "curfew" = restrictions active now; "curfew_override"
# = the escape-hatch file is set (curfew suspended).
if curfew_active; then cf=1; else cf=0; fi
if [ -e "$CURFEW_OVERRIDE_FILE" ]; then ov=1; else ov=0; fi
# "curfew_force" = the demo/test force file is set (curfew forced on
# regardless of clock). Lets the companion app show Start/Stop demo.
if [ -e "$CURFEW_FORCE_FILE" ]; then frc=1; else frc=0; fi
local tmp="$STATUS_FILE.tmp"
# Shell-emitted JSON — keep values numeric where possible, strings quoted.
{
printf '{'
printf '"mode":"%s",' "$mode"
printf '"lat":"%s",' "${lat:-}"
printf '"lon":"%s",' "${lon:-}"
printf '"distance_m":%s,' "${dist:-null}"
printf '"threshold_m":%s,' "${thr:-null}"
printf '"radius_m":%s,' "$RADIUS"
printf '"disabled_count":%s,' "$count"
printf '"curfew":%s,' "$cf"
printf '"curfew_override":%s,' "$ov"
printf '"curfew_force":%s,' "$frc"
printf '"last_check_ts":%s,' "$ts"
printf '"last_check_iso":"%s"' "$iso"
printf '}\n'
} >"$tmp" 2>/dev/null || return 0
mv "$tmp" "$STATUS_FILE" 2>/dev/null || true
chmod 644 "$STATUS_FILE" 2>/dev/null || true
}

View File

@ -1,241 +0,0 @@
#!/bin/bash
# ============================================================
# Focus Mode Deployment Script
# Deploys focus mode to your rooted BL-9000 via wireless ADB
#
# Usage:
# ./deploy.sh [phone_ip] - Full deploy (first time or update)
# ./deploy.sh [phone_ip] --status - Check status
# ./deploy.sh [phone_ip] --log - View log
# ./deploy.sh [phone_ip] --stop - Stop daemon
# ./deploy.sh [phone_ip] --enable - Force focus mode on
# ./deploy.sh [phone_ip] --disable - Force focus mode off
# ============================================================
set -euo pipefail
_DEPLOY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=deploy_actions.sh
. "${_DEPLOY_LIB_DIR}/deploy_actions.sh"
# shellcheck source=deploy_gps.sh
. "${_DEPLOY_LIB_DIR}/deploy_gps.sh"
# shellcheck source=deploy_magisk.sh
. "${_DEPLOY_LIB_DIR}/deploy_magisk.sh"
# shellcheck source=deploy_phases.sh
. "${_DEPLOY_LIB_DIR}/deploy_phases.sh"
# shellcheck source=deploy_install.sh
. "${_DEPLOY_LIB_DIR}/deploy_install.sh"
# shellcheck source=deploy_daemons.sh
. "${_DEPLOY_LIB_DIR}/deploy_daemons.sh"
PHONE_IP="${1:-}"
ACTION="${2:---deploy}"
REMOTE_DIR="/data/local/tmp/focus_mode"
# Deliberately NOT named SCRIPT_DIR: config.sh (sourced below, at top
# level) assigns SCRIPT_DIR itself and may repoint it at the on-device
# path /data/local/tmp/focus_mode. Every path here must stay anchored to
# the checkout deploy.sh runs from.
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
NEEDS_GPS_FETCH=0 # set to 1 by check_coords when local coords are placeholder
# Source shared config constants (BROWSER_PACKAGES, REMOTE_DIR, etc.)
# shellcheck source=config.sh
. "$DEPLOY_DIR/config.sh"
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[@]}" "$@"
}
# ---- Pre-flight checks ----
check_adb() {
if ! command -v adb >/dev/null 2>&1; then
echo "ERROR: adb not found. Install Android platform-tools first."
echo " Ubuntu/Debian: sudo apt install adb"
echo " Arch: sudo pacman -S android-tools"
exit 1
fi
}
check_ip() {
if [[ -n "${ADB_SERIAL:-}" ]]; then
return 0
fi
if [ -z "$PHONE_IP" ]; then
echo "ERROR: Phone IP not provided."
echo ""
usage
fi
}
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
if ! adb devices | grep -q "$PHONE_IP"; then
echo "ERROR: Could not connect to $PHONE_IP:5555"
echo "Make sure wireless ADB is enabled and the phone is reachable."
exit 1
fi
ADB_TARGET=(-s "$PHONE_IP:5555")
echo "Connected."
}
# Wrapper: run a root shell command on the phone.
# --mount-master was removed in Magisk v26+; plain su -c works and still
# runs in the correct mount namespace for our use cases.
adb_root() {
local command_text="$1"
printf '%s\n' "$command_text" | adb_cmd shell su -c "sh -s"
}
# ============================================================
# GPS HOME COORDINATE CAPTURE
# ============================================================
# Called when config_secrets.sh has placeholder/non-numeric coords.
# Enables Android location, waits up to GPS_MAX_WAIT_SECS for a
# network/fused fix, and prints "lat lon" on stdout. All progress
# messages go to stderr so the caller can capture only the coords.
GPS_MAX_WAIT_SECS=90
# ============================================================
# MAGISK SYSTEMLESS HOSTS AUTO-INSTALL
# ============================================================
# Creates the module dir+module.prop if absent, removes disable
# markers if disabled, then reboots the device and waits up to
# HOSTS_MODULE_REBOOT_WAIT_SECS for it to come back with the
# magic-mount active. No-ops if the module is already OK.
HOSTS_MODULE_REBOOT_WAIT_SECS=180
# ============================================================
# 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"
# ============================================================
# DEPLOY
# ============================================================
do_deploy() {
echo "=== Focus Mode Deployer ==="
echo ""
if check_coords; then
NEEDS_GPS_FETCH=1
else
NEEDS_GPS_FETCH=0
fi
echo ""
echo "[1/7] Connecting to phone..."
connect_adb
echo "[2/7] Verifying root access..."
if ! adb_root "id" | grep -q "uid=0"; then
echo "ERROR: Could not get root shell. Is Magisk installed?"
exit 1
fi
echo " Root confirmed."
echo "[2.5] Ensuring Magisk Systemless Hosts module..."
ensure_magisk_hosts_module
echo "[3/7] Creating directories on device..."
# Use world-writable staging dir so non-root adb push works
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"
_deploy_push_scripts
}
# ============================================================
# Entry point
# ============================================================
check_adb
check_ip
case "$ACTION" in
--deploy | "") do_deploy ;;
--status) do_control "status" ;;
--log)
connect_adb
adb_root "sh $REMOTE_DIR/focus_ctl.sh log 100"
;;
--stop) do_control "stop" ;;
--start) do_control "start" ;;
--restart) do_control "restart" ;;
--enable) do_control "enable" ;;
--disable) do_control "disable" ;;
--list) do_control "list-apps" ;;
--pull-log) do_pull_log ;;
--find-pkg) do_find_pkg "$@" ;;
--hosts-status) do_control "hosts-status" ;;
--hosts-log)
connect_adb
adb_root "sh $REMOTE_DIR/focus_ctl.sh hosts-log 100"
;;
--launcher-status) do_control "launcher-status" ;;
--launcher-log)
connect_adb
adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-log 100"
;;
--capture-coords) do_capture_coords ;;
--snapshot-launcher) do_snapshot_launcher ;;
--install-aurora) do_install_aurora ;;
*)
echo "Unknown action: $ACTION"
usage
;;
esac

View File

@ -1,83 +0,0 @@
#!/bin/bash
# deploy_actions.sh — deploy.sh's non-deploy actions: the help text, passing a
# control command through to focus_ctl.sh on the phone, pulling the log,
# searching installed packages, and snapshotting the launcher APK.
#
# Sourced by deploy.sh, which owns adb_cmd, adb_root and $REMOTE_DIR.
usage() {
echo "Usage: $0 <phone_ip> [action]"
echo " or: ADB_SERIAL=<serial> $0 [action]"
echo ""
echo "Actions:"
echo " (none) Full deploy"
echo " --status Show daemon status and current mode"
echo " --log Tail the daemon log"
echo " --stop Stop daemon (re-enables all apps)"
echo " --start Start daemon"
echo " --restart Restart daemon"
echo " --enable Force focus mode on"
echo " --disable Force focus mode off"
echo " --list List all third-party apps and whitelist status"
echo " --pull-log Download log file locally"
echo " --find-pkg Show installed packages matching a filter (e.g. --find-pkg pomodoro)"
echo " --capture-coords Capture current GPS as home location (run after WiFi setup)"
echo " --hosts-status Show hosts enforcer status on the phone"
echo " --hosts-log Show hosts enforcer log on the phone"
echo " --launcher-status Show launcher enforcer status on the phone"
echo " --launcher-log Show launcher enforcer log on the phone"
echo " --snapshot-launcher Snapshot installed Minimalist Phone APK + default HOME"
echo " --install-aurora Download & install Aurora Store (open-source Play Store alt)"
echo ""
echo "Examples:"
echo " $0 192.168.1.42"
echo " $0 192.168.1.42 --status"
echo " $0 192.168.1.42 --find-pkg stronglift"
exit 1
}
# ============================================================
# Control actions (post-deploy)
# ============================================================
do_control() {
local ctl_cmd="$1"
connect_adb
adb_root "sh $REMOTE_DIR/focus_ctl.sh $ctl_cmd"
}
do_pull_log() {
connect_adb
echo "Downloading log..."
adb_cmd pull "$REMOTE_DIR/focus_mode.log" "./focus_mode_$(date +%Y%m%d_%H%M%S).log"
echo "Done."
}
do_find_pkg() {
local filter="${3:-}"
if [ -z "$filter" ]; then
echo "Usage: $0 <ip> --find-pkg <search_term>"
exit 1
fi
connect_adb
echo "Packages matching '$filter':"
adb_cmd shell pm list packages | grep -i "$filter" | sed 's/^package:/ /'
}
do_snapshot_launcher() {
# Run the on-device snapshot command. This captures the APK + HOME
# activity of the already-installed Minimalist Phone launcher into
# /data/adb/focus_mode/ so the launcher enforcer can restore it later.
# The user must install the launcher once (via Aurora/Play) before
# running this command - we only back up what's already there.
connect_adb
echo "Snapshotting currently-installed launcher APK..."
adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-snapshot"
echo ""
echo "Starting launcher enforcer..."
# Kill any previous enforcer so it picks up the new snapshot.
adb_root "kill \$(cat $REMOTE_DIR/launcher_enforcer.pid 2>/dev/null) 2>/dev/null; true"
adb_root "rm -f $REMOTE_DIR/launcher_enforcer.pid"
adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/launcher_enforcer.sh </dev/null >/dev/null 2>/dev/null &'
sleep 3
adb_root "sh $REMOTE_DIR/focus_ctl.sh launcher-status"
}

View File

@ -1,148 +0,0 @@
#!/bin/bash
# deploy_daemons.sh — the last phases of a deploy: restarting the enforcers,
# installing the companion status-notification app, and printing the closing
# summary.
#
# Sourced by deploy.sh, which owns adb_cmd, adb_root and $REMOTE_DIR.
# Phase 6: stop anything already running, then start each enforcer.
_deploy_start_daemons() {
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 "for p in \$(pgrep -f '/data/local/tmp/focus_mode/curfew_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/tether_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/workout_detector.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"
adb_root "kill \$(cat $REMOTE_DIR/tether_enforcer.pid 2>/dev/null) 2>/dev/null; true"
adb_root "kill \$(cat $REMOTE_DIR/workout_detector.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"
adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/curfew_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/tether_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/workout_detector.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 $REMOTE_DIR/tether_enforcer.pid $REMOTE_DIR/workout_detector.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 $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 >/dev/null 2>/dev/null &'
fi
# Start workout detector BEFORE the hosts enforcer's first integrity check
# so the enforcer sees a non-stale workout_active flag. The detector itself
# is harmless if no workout is in progress (it just writes 0).
if adb_root "test -x $REMOTE_DIR/sqlite3" 2>/dev/null; then
adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/workout_detector.sh </dev/null >/dev/null 2>/dev/null &'
fi
# Start DNS enforcer (forces Private DNS off, blocks DoH/DoT). Always on.
adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/dns_enforcer.sh </dev/null >/dev/null 2>/dev/null &'
# Start launcher enforcer only if a snapshot APK exists. If not, warn the
# user to install Minimalist Phone + run --snapshot-launcher first.
if adb_root "test -f $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 >/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
# Start night-curfew enforcer (grayscale + DND + optional net allow-list).
# Always on; self-gates on the clock + focus mode, no-op during the day.
adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/curfew_enforcer.sh </dev/null >/dev/null 2>/dev/null &'
# Start hotspot/tethering enforcer (blocks forwarded/tethered traffic so a
# second phone on our hotspot can't bypass focus mode). Always on; self-gates
# on focus mode, no-op while away from home.
adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/tether_enforcer.sh </dev/null >/dev/null 2>/dev/null &'
adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/focus_daemon.sh </dev/null >/dev/null 2>/dev/null &'
# Wait for hosts_enforcer to apply the bind mount and restart netd.
# hosts_enforcer.sh restarts netd once at startup (takes ~4 s); we wait
# 10 s total so the network is stable before the companion-app install.
sleep 10
_deploy_install_companion
}
# Phase 7: build and install the companion status-notification app.
_deploy_install_companion() {
# ---- Companion status notification app ----
APP_DIR="$DEPLOY_DIR/focus_status_app"
APK="$APP_DIR/build/focus_status.apk"
if [ -d "$APP_DIR" ]; then
echo "[7/7] Building & installing companion status-notification app..."
needs_rebuild=0
if [ ! -f "$APK" ]; then
needs_rebuild=1
elif [ "$APP_DIR/AndroidManifest.xml" -nt "$APK" ]; then
needs_rebuild=1
elif [ "$APP_DIR/build.sh" -nt "$APK" ]; then
needs_rebuild=1
elif find "$APP_DIR/java" -name '*.java' -newer "$APK" -print -quit 2>/dev/null | grep -q .; then
# Rebuild when any Java source changed, not just the manifest.
needs_rebuild=1
fi
if [ "$needs_rebuild" -eq 1 ]; then
echo " Building APK..."
# Non-fatal: the companion UI is optional. If the Android SDK is
# missing (build.sh fails), warn and fall back to the existing APK
# rather than aborting the whole deploy and leaving the curfew core
# un-started.
if ! (cd "$APP_DIR" && bash build.sh) >/dev/null 2>&1; then
echo " WARNING: APK build failed (Android SDK missing?)."
echo " Keeping the previously-built APK if present;"
echo " the curfew daemons/enforcers are unaffected."
fi
fi
if [ -f "$APK" ]; then
echo " Installing APK..."
adb_cmd install -r "$APK" >/dev/null || true
# Grant runtime permission (Android 13+ requires it for notifications).
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_cmd shell dumpsys package com.kuhy.focusstatus 2>/dev/null |
awk 'match($0, /userId=[0-9]+/) {print substr($0, RSTART + 7, RLENGTH - 7); exit}'
)"
if [ -n "$APP_UID" ]; then
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_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"
fi
fi
_deploy_report
}
# Print the closing summary and the commands the user will want next.
_deploy_report() {
echo ""
echo "=== Deploy complete! ==="
echo ""
echo "Checking status..."
adb_root "sh $REMOTE_DIR/focus_ctl.sh status"
echo ""
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"
echo " $0 $PHONE_IP --log # View daemon log"
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)"
}

View File

@ -1,83 +0,0 @@
#!/bin/bash
# deploy_gps.sh — the home-coordinate capture: reading a fix off the phone
# when config_secrets.sh still holds placeholders, and the --capture-coords
# action that writes one deliberately.
#
# Sourced by deploy.sh, which owns adb_cmd and $DEPLOY_DIR. $NEEDS_GPS_FETCH
# is written by check_coords here and read by the deploy run, so it stays
# declared in deploy.sh with the other run-scoped state.
check_coords() {
local lat lon
lat="$(grep '^.*HOME_LAT=' "$DEPLOY_DIR/config.sh" "$DEPLOY_DIR/config_secrets.sh" 2>/dev/null | tail -1 | cut -d'"' -f2)"
lon="$(grep '^.*HOME_LON=' "$DEPLOY_DIR/config.sh" "$DEPLOY_DIR/config_secrets.sh" 2>/dev/null | tail -1 | cut -d'"' -f2)"
if [ "$lat" = "0.000000" ] && [ "$lon" = "0.000000" ]; then
echo "ERROR: Home coordinates not set (all zeros)."
exit 1
fi
# If both look like valid floats, use them; otherwise auto-capture from
# phone GPS. The answer is the EXIT STATUS rather than an assignment to
# $NEEDS_GPS_FETCH: this file would otherwise write a global it never
# reads (SC2034), and the linter runs without -x so each file has to
# stand alone. deploy.sh sets the flag from what this returns.
if [[ -n "$lat" && "$lat" =~ ^[+-]?[0-9]+\.[0-9]+$ && -n "$lon" && "$lon" =~ ^[+-]?[0-9]+\.[0-9]+$ ]]; then
echo " Home location: $lat, $lon"
return 1
fi
echo " Home location: placeholder — will be captured from phone GPS at deploy time."
return 0
}
fetch_home_coords_from_phone() {
echo " Enabling location services on phone..." >&2
adb_cmd shell settings put secure location_mode 3 2>/dev/null || true
echo " Waiting for network/fused location fix (up to ${GPS_MAX_WAIT_SECS}s)..." >&2
local waited=0 coords=""
while [[ -z "$coords" && $waited -lt $GPS_MAX_WAIT_SECS ]]; do
sleep 3
waited=$((waited + 3))
# Format on Android 10+: " last location=Location[fused LAT,LON ...]"
local raw
raw="$(adb_cmd shell dumpsys location 2>/dev/null |
grep 'last location=Location\[' |
grep -oE '[+-]?[0-9]+\.[0-9]+,[+-]?[0-9]+\.[0-9]+' |
head -1 || true)"
[[ -n "$raw" ]] && coords="$raw"
printf '.' >&2
done
printf '\n' >&2
if [[ -z "$coords" ]]; then
echo "ERROR: No location fix after ${GPS_MAX_WAIT_SECS}s." >&2
echo " Make sure the phone has cellular or WiFi data, then retry." >&2
echo " Or set HOME_LAT/HOME_LON manually in config_secrets.sh." >&2
return 1
fi
local lat="${coords%,*}"
local lon="${coords#*,}"
echo " GPS fix acquired: ${lat}, ${lon}" >&2
printf '%s %s' "$lat" "$lon"
}
do_capture_coords() {
# Standalone GPS capture for post-WiFi-setup use.
# Overwrites config_secrets.sh on the phone with the current location.
connect_adb
if ! adb_root "id" 2>/dev/null | grep -q "uid=0"; then
echo "ERROR: Root not available."
exit 1
fi
echo "Capturing home coordinates from phone GPS..."
local gps_result gps_lat gps_lon
gps_result="$(fetch_home_coords_from_phone)"
gps_lat="${gps_result% *}"
gps_lon="${gps_result#* }"
adb_root "printf '#!/system/bin/sh\n# Home coordinates auto-captured from GPS\nexport HOME_LAT=\"${gps_lat}\"\nexport HOME_LON=\"${gps_lon}\"\n' \
> $REMOTE_DIR/config_secrets.sh"
echo "Home coordinates updated on phone: ${gps_lat}, ${gps_lon}"
echo "Restarting focus daemon to apply new coordinates..."
adb_root "sh $REMOTE_DIR/focus_ctl.sh restart" 2>/dev/null || true
echo "Done."
}

View File

@ -1,151 +0,0 @@
#!/bin/bash
# deploy_install.sh — copying the staged files into $REMOTE_DIR and setting
# their permissions.
#
# Sourced by deploy.sh, which owns adb_root and $REMOTE_DIR.
#
# _deploy_install_files holds the SECOND of the two hardcoded file lists; the
# first is the push list in deploy_phases.sh. Keep them in step: a file in one
# and not the other is the failure mode this comment exists to prevent.
# Copy everything from the staging directory into $REMOTE_DIR. This is
# the SECOND of the two hardcoded lists -- a sibling added to the push
# list in _deploy_push_scripts must be added here too, or it is staged
# and never lands.
_deploy_install_files() {
adb_root "cp /data/local/tmp/focus_stage/config.sh $REMOTE_DIR/config.sh"
adb_root "cp /data/local/tmp/focus_stage/config_paths.sh $REMOTE_DIR/config_paths.sh"
adb_root "cp /data/local/tmp/focus_stage/config_dns.sh $REMOTE_DIR/config_dns.sh"
adb_root "cp /data/local/tmp/focus_stage/config_curfew.sh $REMOTE_DIR/config_curfew.sh"
adb_root "cp /data/local/tmp/focus_stage/config_tether.sh $REMOTE_DIR/config_tether.sh"
adb_root "cp /data/local/tmp/focus_stage/config_launcher.sh $REMOTE_DIR/config_launcher.sh"
adb_root "cp /data/local/tmp/focus_stage/focus_daemon.sh $REMOTE_DIR/focus_daemon.sh"
adb_root "cp /data/local/tmp/focus_stage/daemon_location.sh $REMOTE_DIR/daemon_location.sh"
adb_root "cp /data/local/tmp/focus_stage/daemon_state.sh $REMOTE_DIR/daemon_state.sh"
adb_root "cp /data/local/tmp/focus_stage/daemon_apps.sh $REMOTE_DIR/daemon_apps.sh"
adb_root "cp /data/local/tmp/focus_stage/focus_ctl.sh $REMOTE_DIR/focus_ctl.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_usage.sh $REMOTE_DIR/ctl_usage.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_daemon.sh $REMOTE_DIR/ctl_daemon.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_hosts.sh $REMOTE_DIR/ctl_hosts.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_dns.sh $REMOTE_DIR/ctl_dns.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_launcher.sh $REMOTE_DIR/ctl_launcher.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_workout.sh $REMOTE_DIR/ctl_workout.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_curfew.sh $REMOTE_DIR/ctl_curfew.sh"
adb_root "cp /data/local/tmp/focus_stage/ctl_tether.sh $REMOTE_DIR/ctl_tether.sh"
adb_root "cp /data/local/tmp/focus_stage/hosts_enforcer.sh $REMOTE_DIR/hosts_enforcer.sh"
adb_root "cp /data/local/tmp/focus_stage/hosts_magisk.sh $REMOTE_DIR/hosts_magisk.sh"
adb_root "cp /data/local/tmp/focus_stage/hosts_mount.sh $REMOTE_DIR/hosts_mount.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/dns_iptables.sh $REMOTE_DIR/dns_iptables.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/curfew_enforcer.sh $REMOTE_DIR/curfew_enforcer.sh"
adb_root "cp /data/local/tmp/focus_stage/curfew_net.sh $REMOTE_DIR/curfew_net.sh"
adb_root "cp /data/local/tmp/focus_stage/tether_enforcer.sh $REMOTE_DIR/tether_enforcer.sh"
adb_root "cp /data/local/tmp/focus_stage/tether_iptables.sh $REMOTE_DIR/tether_iptables.sh"
adb_root "cp /data/local/tmp/focus_stage/workout_detector.sh $REMOTE_DIR/workout_detector.sh"
if adb_cmd shell "test -f /data/local/tmp/focus_stage/sqlite3" 2>/dev/null; then
adb_root "cp /data/local/tmp/focus_stage/sqlite3 $REMOTE_DIR/sqlite3"
adb_root "chmod 0755 $REMOTE_DIR/sqlite3"
fi
if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$DEPLOY_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_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 $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 $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"
# ---- Workout-variant canonical (optional) ----
# Same lockdown treatment as the full canonical. Pushed by the workout
# hosts generator block above. Missing variant means workout_detector\
# will simply have no relaxed file to swap to (hosts_enforcer falls\
# back to the full canonical).
if adb_cmd shell "test -f /data/local/tmp/focus_stage/hosts.canonical.workout" 2>/dev/null; then
adb_root "chattr -i $REMOTE_DIR/hosts.canonical.workout 2>/dev/null; true"
adb_root "cp /data/local/tmp/focus_stage/hosts.canonical.workout $REMOTE_DIR/hosts.canonical.workout"
adb_root "chmod 644 $REMOTE_DIR/hosts.canonical.workout"
adb_root "chattr -i $REMOTE_DIR/hosts.sha256.workout 2>/dev/null; true"
adb_root "cp /data/local/tmp/focus_stage/hosts.sha256.workout $REMOTE_DIR/hosts.sha256.workout"
adb_root "chmod 644 $REMOTE_DIR/hosts.sha256.workout"
adb_root "chattr +i $REMOTE_DIR/hosts.canonical.workout 2>/dev/null; true"
adb_root "chattr +i $REMOTE_DIR/hosts.sha256.workout 2>/dev/null; true"
fi
# Magisk Systemless Hosts module was ensured (and rebooted if needed) in
# step [2.5] above. Sanity-assert it's still active before writing to it.
if ! adb_root "test -f /system/etc/hosts" 2>/dev/null; then
echo "ERROR: /system/etc/hosts not magic-mounted — run deploy again."
exit 1
fi
adb_root "mkdir -p /data/adb/modules/hosts/system/etc"
# Drop any +i lock the runtime hosts_enforcer may have set on the
# module dir / hosts file so we can update them. The enforcer will
# re-lock on its next poll cycle. Also pre-emptively delete any
# disable/remove markers that may exist on disk before we start.
adb_root "chattr -i /data/adb/modules/hosts /data/adb/modules/hosts/system/etc/hosts 2>/dev/null; rm -f /data/adb/modules/hosts/disable /data/adb/modules/hosts/remove /data/adb/modules/hosts/update; true"
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"
# Lock the module dir to block the Magisk app's "Disable" / "Remove"
# buttons (they create marker files inside the dir). Files already
# in the dir stay mutable so the runtime enforcer can still update
# the hosts file on workout state changes.
adb_root "chattr +i /data/adb/modules/hosts/system/etc/hosts 2>/dev/null; true"
adb_root "chattr +i /data/adb/modules/hosts 2>/dev/null; true"
echo " Magisk hosts module populated ($(adb_root "wc -l < /data/adb/modules/hosts/system/etc/hosts" 2>/dev/null | tr -d ' ') lines), locked against UI-disable. Reboot to activate /system/etc/hosts."
fi
adb_root "rm -rf /data/local/tmp/focus_stage"
# Flush in-process DNS caches of browsers. Apps like Firefox and Chrome
# cache resolved IPs internally and bypass /etc/hosts until restarted.
echo " Flushing browser DNS caches..."
# BROWSER_PACKAGES is set by the top-level `. config.sh` above and is never
# modified. shellcheck only flags it because config.sh is ALSO re-sourced
# inside a subshell further up (to re-read WORKOUT_UNBLOCK_DOMAINS from the
# freshly-staged copy); that subshell's assignments are discarded by design.
# The unquoted expansion is the intended word split over the package list.
# shellcheck disable=SC2031
for _pkg in $BROWSER_PACKAGES; do
[ -n "$_pkg" ] || continue
adb_root "am force-stop '$_pkg' 2>/dev/null; true"
echo " force-stopped $_pkg"
done
# Disable Firefox DNS-over-HTTPS via user.js. Firefox uses hardcoded
# Cloudflare bootstrap IPs (104.16.248.249, 104.16.249.249) to reach
# mozilla.cloudflare-dns.com, completely bypassing /etc/hosts even
# after a fresh start. TRR mode 5 disables DoH so Firefox falls back
# to the system resolver which sees our 0.0.0.0 blocks.
echo " Disabling Firefox DNS-over-HTTPS..."
adb_root "for _p in /data/data/org.mozilla.fenix/files/mozilla/*/; do
[ -f \"\${_p}prefs.js\" ] || continue
grep -qF '\"network.trr.mode\"' \"\${_p}user.js\" 2>/dev/null \
|| { printf 'user_pref(\"network.trr.mode\", 5);\\n' >> \"\${_p}user.js\" 2>/dev/null && echo \" Wrote DoH-disable pref to \${_p}user.js\"; }
done; true"
_deploy_set_permissions
}
# Phase 5: make the staged scripts executable and their logs writable.
_deploy_set_permissions() {
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 $REMOTE_DIR/curfew_enforcer.sh $REMOTE_DIR/tether_enforcer.sh $REMOTE_DIR/workout_detector.sh" || true
if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$DEPLOY_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 $REMOTE_DIR/tether_enforcer.log $REMOTE_DIR/workout_detector.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 $REMOTE_DIR/tether_enforcer.log $REMOTE_DIR/workout_detector.log" || true
_deploy_start_daemons
}

View File

@ -1,135 +0,0 @@
#!/bin/bash
# deploy_magisk.sh — the Magisk systemless-hosts module: creating it when
# absent, clearing its disable markers, and rebooting to bring the magic mount
# up. Also carries the Aurora Store installer and the file-hash helper, both
# of which are one-off setup rather than part of a deploy run.
#
# Sourced by deploy.sh, which owns adb_cmd and adb_root.
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}'
}
ensure_magisk_hosts_module() {
local state="absent"
if adb_root "test -d /data/adb/modules/hosts" >/dev/null 2>&1; then
if adb_root "test -f /data/adb/modules/hosts/disable -o -f /data/adb/modules/hosts/remove" >/dev/null 2>&1; then
state="disabled"
elif adb_root "test -f /system/etc/hosts" >/dev/null 2>&1; then
state="ok"
else
state="not-mounted"
fi
fi
if [[ "$state" == "ok" ]]; then
echo " Magisk Systemless Hosts: active."
return 0
fi
echo " Magisk Systemless Hosts state: ${state} — auto-installing..."
case "$state" in
absent)
adb_root "mkdir -p /data/adb/modules/hosts/system/etc"
# module.prop is required for Magisk to recognise and process the module.
adb_root "printf 'id=hosts\nname=Systemless Hosts\nversion=v1\nversionCode=1\nauthor=Magisk\ndescription=Replace /system/etc/hosts\n' \
> /data/adb/modules/hosts/module.prop"
# Seed a minimal hosts file so the mount target exists at first boot.
adb_root "printf '127.0.0.1 localhost\n::1 localhost\n' \
> /data/adb/modules/hosts/system/etc/hosts"
adb_root "chmod 644 /data/adb/modules/hosts/system/etc/hosts"
;;
disabled)
adb_root "rm -f /data/adb/modules/hosts/disable \
/data/adb/modules/hosts/remove \
/data/adb/modules/hosts/update"
;;
not-mounted)
: # module exists and enabled, just needs a reboot
;;
esac
echo " Rebooting phone to activate Magisk Hosts module..."
adb_cmd reboot
# Give the device time to actually begin shutting down before we poll.
sleep 20
echo " Waiting for device to come back (up to ${HOSTS_MODULE_REBOOT_WAIT_SECS}s)..."
local waited=0
# Re-establish wireless ADB connection if needed.
while true; do
if [[ -n "${PHONE_IP:-}" ]]; then
adb connect "${PHONE_IP}:5555" >/dev/null 2>&1 || true
fi
if adb_cmd shell echo ok 2>/dev/null | grep -q '^ok$'; then
break
fi
sleep 3
waited=$((waited + 3))
if [[ $waited -ge $HOSTS_MODULE_REBOOT_WAIT_SECS ]]; then
echo "ERROR: Device did not come back after ${HOSTS_MODULE_REBOOT_WAIT_SECS}s."
echo " Check USB connection or re-enable wireless ADB, then run deploy again."
exit 1
fi
printf '.'
done
printf '\n'
# Wait for Magisk early-init and root to be ready.
echo " Waiting for Magisk root to be available..."
waited=0
while ! adb_root "id" 2>/dev/null | grep -q "uid=0"; do
sleep 3
waited=$((waited + 3))
[[ $waited -ge 60 ]] && echo "ERROR: Root not available after reboot." && exit 1
printf '.'
done
printf '\n'
# Final assertion: the magic-mount must now be active.
if ! adb_root "test -f /system/etc/hosts" >/dev/null 2>&1; then
echo "ERROR: /system/etc/hosts is not magic-mounted after reboot."
echo " Magisk may not have applied the module correctly."
echo " Check the Magisk app for module errors and run deploy again."
exit 1
fi
echo " Magisk Systemless Hosts module is now active."
}
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"
}

View File

@ -1,163 +0,0 @@
#!/bin/bash
# deploy_phases.sh — the staging half of deploy.sh's deploy run: pushing every
# script to the device's staging directory, and preparing the generated assets
# (the sqlite3 binary and the canonical hosts files).
#
# Sourced by deploy.sh, which owns adb_cmd, adb_root and $DEPLOY_DIR.
#
# _deploy_push_scripts holds the FIRST of the two hardcoded file lists. The
# second is in deploy_install.sh. A new phone-side sibling has to be added to
# BOTH, or it is staged and never lands in $REMOTE_DIR -- and the script that
# sources it then fails to start with no obvious cause.
# Phase 4: stage every script and asset on the device. BOTH hardcoded
# lists live here -- the push list below and the cp list in
# _deploy_install_files -- and a new sibling must be added to each.
_deploy_push_scripts() {
echo "[4/7] Uploading scripts..."
adb_cmd push "$DEPLOY_DIR/config.sh" "/data/local/tmp/focus_stage/config.sh"
adb_cmd push "$DEPLOY_DIR/config_paths.sh" "/data/local/tmp/focus_stage/config_paths.sh"
adb_cmd push "$DEPLOY_DIR/config_dns.sh" "/data/local/tmp/focus_stage/config_dns.sh"
adb_cmd push "$DEPLOY_DIR/config_curfew.sh" "/data/local/tmp/focus_stage/config_curfew.sh"
adb_cmd push "$DEPLOY_DIR/config_tether.sh" "/data/local/tmp/focus_stage/config_tether.sh"
adb_cmd push "$DEPLOY_DIR/config_launcher.sh" "/data/local/tmp/focus_stage/config_launcher.sh"
adb_cmd push "$DEPLOY_DIR/focus_daemon.sh" "/data/local/tmp/focus_stage/focus_daemon.sh"
adb_cmd push "$DEPLOY_DIR/daemon_location.sh" "/data/local/tmp/focus_stage/daemon_location.sh"
adb_cmd push "$DEPLOY_DIR/daemon_state.sh" "/data/local/tmp/focus_stage/daemon_state.sh"
adb_cmd push "$DEPLOY_DIR/daemon_apps.sh" "/data/local/tmp/focus_stage/daemon_apps.sh"
adb_cmd push "$DEPLOY_DIR/focus_ctl.sh" "/data/local/tmp/focus_stage/focus_ctl.sh"
adb_cmd push "$DEPLOY_DIR/ctl_usage.sh" "/data/local/tmp/focus_stage/ctl_usage.sh"
adb_cmd push "$DEPLOY_DIR/ctl_daemon.sh" "/data/local/tmp/focus_stage/ctl_daemon.sh"
adb_cmd push "$DEPLOY_DIR/ctl_hosts.sh" "/data/local/tmp/focus_stage/ctl_hosts.sh"
adb_cmd push "$DEPLOY_DIR/ctl_dns.sh" "/data/local/tmp/focus_stage/ctl_dns.sh"
adb_cmd push "$DEPLOY_DIR/ctl_launcher.sh" "/data/local/tmp/focus_stage/ctl_launcher.sh"
adb_cmd push "$DEPLOY_DIR/ctl_workout.sh" "/data/local/tmp/focus_stage/ctl_workout.sh"
adb_cmd push "$DEPLOY_DIR/ctl_curfew.sh" "/data/local/tmp/focus_stage/ctl_curfew.sh"
adb_cmd push "$DEPLOY_DIR/ctl_tether.sh" "/data/local/tmp/focus_stage/ctl_tether.sh"
adb_cmd push "$DEPLOY_DIR/hosts_enforcer.sh" "/data/local/tmp/focus_stage/hosts_enforcer.sh"
adb_cmd push "$DEPLOY_DIR/hosts_magisk.sh" "/data/local/tmp/focus_stage/hosts_magisk.sh"
adb_cmd push "$DEPLOY_DIR/hosts_mount.sh" "/data/local/tmp/focus_stage/hosts_mount.sh"
adb_cmd push "$DEPLOY_DIR/dns_enforcer.sh" "/data/local/tmp/focus_stage/dns_enforcer.sh"
adb_cmd push "$DEPLOY_DIR/dns_iptables.sh" "/data/local/tmp/focus_stage/dns_iptables.sh"
adb_cmd push "$DEPLOY_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh"
adb_cmd push "$DEPLOY_DIR/curfew_enforcer.sh" "/data/local/tmp/focus_stage/curfew_enforcer.sh"
adb_cmd push "$DEPLOY_DIR/curfew_net.sh" "/data/local/tmp/focus_stage/curfew_net.sh"
adb_cmd push "$DEPLOY_DIR/tether_enforcer.sh" "/data/local/tmp/focus_stage/tether_enforcer.sh"
adb_cmd push "$DEPLOY_DIR/tether_iptables.sh" "/data/local/tmp/focus_stage/tether_iptables.sh"
adb_cmd push "$DEPLOY_DIR/workout_detector.sh" "/data/local/tmp/focus_stage/workout_detector.sh"
adb_cmd push "$DEPLOY_DIR/magisk_service.sh" "/data/local/tmp/focus_stage/99-focus-mode.sh"
# ---- sqlite3 binary for workout_detector.sh ----
# Stored outside the repo (binary-files policy). Built once via the NDK
# against the SQLite amalgamation; see workout_detector.sh comments for
# the recipe. ~1.6 MB stripped, aarch64, PIE, dynamically linked against
# bionic (Android 30+).
_deploy_stage_assets
}
# Stage the generated/vendored assets: the sqlite3 binary the workout
# detector needs, and the canonical hosts files built from the shared
# blocklist generator.
_deploy_stage_assets() {
SQLITE3_BIN="$DEPLOY_DIR/../../testsAndMisc_binaries/phone_focus_mode/sqlite3"
if [ -f "$SQLITE3_BIN" ]; then
echo " Uploading sqlite3 binary ($(stat -c%s "$SQLITE3_BIN") bytes)..."
adb_cmd push "$SQLITE3_BIN" "/data/local/tmp/focus_stage/sqlite3"
else
echo " WARNING: sqlite3 binary not found at $SQLITE3_BIN"
echo " workout_detector will not function until you build & place it there."
fi
# Generate and upload the canonical hosts file (StevenBlack + custom entries).
# This mirrors what linux_configuration/hosts/install.sh installs on the PC.
HOSTS_GENERATOR="$DEPLOY_DIR/../linux_configuration/scripts/periodic_background/hosts/generate_hosts_file.sh"
if [ -f "$HOSTS_GENERATOR" ]; then
chmod +x "$HOSTS_GENERATOR" 2>/dev/null || true
echo " Generating canonical hosts file..."
HOSTS_TMP="$(mktemp)"
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_cmd push "$HOSTS_TMP" "/data/local/tmp/focus_stage/hosts.canonical"
adb_cmd push "$HOSTS_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256"
# ---- Workout-variant canonical ----
# Same content as the full canonical, with all lines that block
# any of $WORKOUT_UNBLOCK_DOMAINS removed. Used by hosts_enforcer
# while a StrongLifts workout is in progress.
HOSTS_WORKOUT_TMP="$(mktemp)"
HOSTS_WORKOUT_SHA_TMP="$(mktemp)"
# Read $WORKOUT_UNBLOCK_DOMAINS from the freshly-staged config.sh
# so the generator and the runtime always agree on the domain set.
UNBLOCK_DOMAINS="$(
# shellcheck disable=SC1091
(
. "$DEPLOY_DIR/config.sh" >/dev/null 2>&1
printf '%s\n' "$WORKOUT_UNBLOCK_DOMAINS"
) |
sed 's/[[:space:]]\{1,\}/\n/g' |
grep -vE '^[[:space:]]*(#|$)' |
sort -u
)"
if [ -n "$UNBLOCK_DOMAINS" ]; then
# Build an awk regex of exact-match domains anchored as the
# *value* column of a hosts entry ("<ip> <domain>" possibly
# followed by aliases). We strip any line whose first non-IP
# token matches one of the unblock domains.
WORKOUT_UNBLOCK_DOMAINS="$UNBLOCK_DOMAINS" \
python3 "$DEPLOY_DIR/strip_workout_hosts.py" "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP" ||
cp "$HOSTS_TMP" "$HOSTS_WORKOUT_TMP"
workout_hash="$(compute_file_hash "$HOSTS_WORKOUT_TMP")"
printf '%s\n' "$workout_hash" >"$HOSTS_WORKOUT_SHA_TMP"
stripped_lines=$(($(wc -l <"$HOSTS_TMP") - $(wc -l <"$HOSTS_WORKOUT_TMP")))
echo " Uploading workout-variant hosts (stripped $stripped_lines YouTube lines)..."
adb_cmd push "$HOSTS_WORKOUT_TMP" "/data/local/tmp/focus_stage/hosts.canonical.workout"
adb_cmd push "$HOSTS_WORKOUT_SHA_TMP" "/data/local/tmp/focus_stage/hosts.sha256.workout"
fi
rm -f "$HOSTS_WORKOUT_TMP" "$HOSTS_WORKOUT_SHA_TMP"
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
echo " WARNING: $HOSTS_GENERATOR not found - skipping hosts enforcement"
fi
# Only push config_secrets.sh if phone doesn't already have one
if adb_root "test -f $REMOTE_DIR/config_secrets.sh" 2>/dev/null; then
echo " config_secrets.sh already exists on phone - skipping (preserving real coords)"
elif [[ "${NEEDS_GPS_FETCH}" -eq 1 ]]; then
# Local config_secrets.sh has placeholder coords — capture current GPS from the phone.
# The phone is assumed to be at home during setup, so current location = home location.
local gps_result="" gps_lat="" gps_lon=""
if gps_result="$(fetch_home_coords_from_phone 2>&1)"; then
gps_lat="${gps_result% *}"
gps_lon="${gps_result#* }"
adb_root "printf '#!/system/bin/sh\n# Home coordinates auto-captured from GPS at deploy time\nexport HOME_LAT=\"${gps_lat}\"\nexport HOME_LON=\"${gps_lon}\"\n' \
> $REMOTE_DIR/config_secrets.sh"
echo " Home coordinates written to phone: ${gps_lat}, ${gps_lon}"
else
# GPS unavailable (no WiFi/cellular yet on fresh phone).
# Write stub coords — focus mode stays OFF, hosts/DNS blocking still works.
# User should run: ./deploy.sh [ip] --capture-coords after configuring WiFi.
adb_root "printf '#!/system/bin/sh\n# STUB: run ./deploy.sh --capture-coords after WiFi setup\nexport HOME_LAT=\"0.000001\"\nexport HOME_LON=\"0.000001\"\n' \
> $REMOTE_DIR/config_secrets.sh"
echo " WARNING: GPS capture failed — focus mode location enforcement is DISABLED."
echo " Hosts/DNS blocking is active. After configuring WiFi, run:"
echo " ADB_SERIAL=${ADB_SERIAL:-\$PHONE_IP:5555} ./deploy.sh --capture-coords"
fi
else
echo " Pushing config_secrets.sh (first install)..."
adb_cmd push "$DEPLOY_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
# Move staged files into place with root
_deploy_install_files
}

View File

@ -1,162 +0,0 @@
#!/usr/bin/env bash
# ============================================================
# Distraction purge — remove YouTube et al. for the primary user
#
# `pm uninstall --user 0` unregisters a package for one user while
# leaving the APK on the read-only system image. It needs no root, no
# Device Owner and no factory reset, and — unlike `pm suspend` and
# `pm disable-user`, both measured to be cleared by a reboot on this
# device — it SURVIVES A REBOOT. Verified on the Pixel 6a on
# 2026-08-09: uninstalled, rebooted, still `installed=false`.
#
# It is fully reversible: `pm install-existing --user 0 <pkg>` brings
# the app back with no download, which `--restore` does.
#
# This exists because focus_owner cannot do the job. Its sweep filters
# to third-party packages, and YouTube ships at /product/app/YouTube
# with FLAG_SYSTEM — so the Device Owner path would skip it even after
# the wipe. See docs/youtube-block-unrooted.md.
#
# Usage:
# ./distraction_purge.sh # purge (default)
# ./distraction_purge.sh --list # dry run, change nothing
# ./distraction_purge.sh --status # report current state
# ./distraction_purge.sh --restore # put every package back
# ============================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# shellcheck source=lib/adb_common.sh
source "${SCRIPT_DIR}/lib/adb_common.sh"
# Packages removed for user 0. Keep this list narrow and deliberate:
# every entry is an app the user has decided they do not want reachable,
# not general debloating (batch3_bloatware_uninstall.sh does that).
readonly PURGE_PACKAGES=(
com.google.android.youtube
com.google.android.apps.youtube.music
# Chrome closes the web route to youtube.com, which removing the app
# alone leaves wide open. It is the only browser on this device, so
# this also removes in-app links, OAuth flows and anything else that
# wants a browser -- accepted deliberately, and the reason --restore
# exists and is a single command.
com.android.chrome
)
MODE="purge"
usage() {
sed -n '2,26p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0
}
for arg in "$@"; do
case "${arg}" in
--list) MODE="list" ;;
--status) MODE="status" ;;
--restore) MODE="restore" ;;
-h | --help) usage ;;
*)
_error "Unknown flag: ${arg}"
exit 1
;;
esac
done
# Reports "installed", "absent", or "missing" (not on the image at all).
package_state() {
local pkg="$1" line
line="$(adb_cmd shell "dumpsys package ${pkg} 2>/dev/null \
| sed -n '/User 0:/,/^ *User /p' \
| grep -oE 'installed=[a-z]+' | head -1" | tr -d '\r')"
case "${line}" in
installed=true) printf 'installed' ;;
installed=false) printf 'absent' ;;
*) printf 'missing' ;;
esac
}
report_state() {
local pkg state
for pkg in "${PURGE_PACKAGES[@]}"; do
state="$(package_state "${pkg}")"
printf ' %-44s %s\n' "${pkg}" "${state}"
done
}
# Exits non-zero if any package is still reachable, so the caller (or a
# timer) learns about a Play Store reinstall instead of assuming success.
purge_packages() {
local pkg state failed=0
for pkg in "${PURGE_PACKAGES[@]}"; do
state="$(package_state "${pkg}")"
if [[ "${state}" == "missing" ]]; then
_warn "${pkg}: not present on this device, skipping"
continue
fi
if [[ "${state}" == "absent" ]]; then
_info "${pkg}: already absent"
continue
fi
if adb_cmd shell pm uninstall --user 0 "${pkg}" >/dev/null 2>&1; then
_info "${pkg}: removed for user 0"
else
_error "${pkg}: uninstall failed"
fi
# Trust the device, not the exit code: `pm uninstall` has been
# observed printing Success while leaving the package in place.
if [[ "$(package_state "${pkg}")" == "installed" ]]; then
_error "${pkg}: STILL INSTALLED after uninstall"
failed=1
fi
done
return "${failed}"
}
restore_packages() {
local pkg
for pkg in "${PURGE_PACKAGES[@]}"; do
if adb_cmd shell pm install-existing --user 0 "${pkg}" >/dev/null 2>&1; then
_info "${pkg}: restored"
else
_error "${pkg}: restore failed"
fi
done
}
main() {
adb_select_device
adb_verify_trusted_identity
case "${MODE}" in
list)
_info "Would remove for user 0 (nothing changed):"
printf ' %s\n' "${PURGE_PACKAGES[@]}"
;;
status)
_info "Current state:"
report_state
;;
restore)
_box "RESTORING DISTRACTION APPS" \
"Every package below becomes reachable again."
restore_packages
_info "Final state:"
report_state
;;
purge)
purge_packages || {
_error "One or more packages are still installed."
_info "Final state:"
report_state
exit 1
}
_info "Final state:"
report_state
;;
esac
}
main "$@"

View File

@ -1,171 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# DNS enforcer for rooted Android.
#
# Why this exists:
# /etc/hosts only works for lookups done by the *system* resolver
# using classic DNS (UDP/TCP 53). Two bypass channels defeat it:
# 1. DNS-over-TLS (DoT, port 853) - used by Android when Private
# DNS is "automatic" or set to a specific provider.
# 2. DNS-over-HTTPS (DoH, port 443) - used by Chrome/Brave's
# "Use secure DNS" feature and some apps directly.
#
# Strategy:
# 1. Force `settings global private_dns_mode off` so the OS stops
# doing DoT (there is no public DoT-by-hostname toggle).
# 2. Drop outbound traffic to a fixed list of well-known DoH/DoT
# endpoints via iptables / ip6tables so apps' fallback logic
# has to use the regular resolver, which consults /etc/hosts.
#
# Limitations:
# * A custom app that hardcodes an obscure DoH IP is not caught.
# * A root user can `iptables -F` or re-enable private DNS - but
# this loop re-asserts every $DNS_CHECK_INTERVAL seconds and
# leaves tamper logs.
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=config.sh
. "$SCRIPT_DIR/config.sh"
# Sourced after config.sh because the chain functions read its DNS_* values.
# They also call log(), defined below; that is fine because nothing in either
# file runs until main() does. A sibling, not a lib/ member: lib/ does not
# exist on the phone, and deploy.sh copies these scripts flat into $REMOTE_DIR.
# shellcheck source=dns_iptables.sh
. "$SCRIPT_DIR/dns_iptables.sh"
PIDFILE="$STATE_DIR/dns_enforcer.pid"
mkdir -p "$STATE_DIR"
touch "$DNS_LOG"
chmod 666 "$DNS_LOG" 2>/dev/null || true
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >>"$DNS_LOG"
}
rotate_log() {
local lines
lines="$(wc -l <"$DNS_LOG" 2>/dev/null || echo 0)"
if [ "$lines" -gt 500 ]; then
local tmp="$DNS_LOG.tmp"
tail -n 500 "$DNS_LOG" >"$tmp"
mv "$tmp" "$DNS_LOG"
fi
}
acquire_lock() {
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
local cmdline
cmdline="$(tr '\0' ' ' <"/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "dns_enforcer"; then
echo "dns_enforcer already running (PID $old_pid)"
exit 0
fi
fi
rm -f "$PIDFILE"
fi
echo $$ >"$PIDFILE"
}
# ---- Private DNS ----
ensure_private_dns_off() {
# Two modes, selected by DNS_TRUSTED_DOT_HOST:
#
# empty - the original behaviour. DoT is a bypass channel, so Private
# DNS is forced off and every 853 connection is rejected.
# set - DoT is the *enforcement* channel. Private DNS is pinned to
# our own filtering resolver, and pinning it is what stops the
# user picking an unfiltered one from Settings.
#
# The pinned case is what an unrooted phone needs, since it has no hosts
# file to fall back to.
if [ -n "${DNS_TRUSTED_DOT_HOST:-}" ]; then
ensure_private_dns_pinned
return
fi
local mode
mode="$(settings get global private_dns_mode 2>/dev/null)"
# Possible values: "off", "opportunistic", "hostname", null (default=opportunistic)
if [ "$mode" != "off" ]; then
settings put global private_dns_mode off 2>/dev/null
log "Private DNS was '$mode' - forced to 'off'"
fi
# Clear any pinned DoT hostname so the "hostname" mode cannot be
# re-enabled silently by Settings UI.
local spec
spec="$(settings get global private_dns_specifier 2>/dev/null)"
if [ -n "$spec" ] && [ "$spec" != "null" ]; then
settings delete global private_dns_specifier 2>/dev/null
log "Cleared private_dns_specifier (was '$spec')"
fi
}
ensure_private_dns_pinned() {
# Hold Private DNS on our own resolver. Re-asserted every check, so a
# change made in Settings is reverted within DNS_CHECK_INTERVAL.
local mode spec
spec="$(settings get global private_dns_specifier 2>/dev/null)"
if [ "$spec" != "$DNS_TRUSTED_DOT_HOST" ]; then
settings put global private_dns_specifier "$DNS_TRUSTED_DOT_HOST" 2>/dev/null
log "private_dns_specifier was '$spec' - pinned to '$DNS_TRUSTED_DOT_HOST'"
fi
# Set the specifier before the mode: switching to "hostname" while the
# specifier still names someone else would briefly route lookups through
# that other resolver.
mode="$(settings get global private_dns_mode 2>/dev/null)"
if [ "$mode" != "hostname" ]; then
settings put global private_dns_mode hostname 2>/dev/null
log "Private DNS was '$mode' - forced to 'hostname'"
fi
}
# ---- iptables chain management ----
# ---- State-change gate ----
#
# fill_chain_v4/v6 flush and reinsert ~50-100 iptables/ip6tables rules each
# call. Running that unconditionally every $DNS_CHECK_INTERVAL was measured
# to peg netd at ~50% CPU on-device (contending the xtables lock) even
# though the rule set never changes at runtime. Only rebuild when the chain
# is actually missing, untethered from OUTPUT, or the wrong size (i.e.
# someone/something - netd, a root shell, a flush - tampered with it).
# Self-healing behavior described in the header comment is preserved; only
# redundant identical rebuilds are skipped.
cleanup() {
# We intentionally leave the iptables chain in place on SIGTERM so
# stopping the enforcer for maintenance does not immediately re-open
# the DoH hole. `focus_ctl.sh dns-stop` does the explicit teardown.
log "dns_enforcer shutting down"
rm -f "$PIDFILE"
exit 0
}
trap cleanup INT TERM
main() {
acquire_lock
log "dns_enforcer started (PID=$$)"
# Initial arm-up
ensure_private_dns_off
enforce_iptables
while true; do
ensure_private_dns_off
enforce_iptables
rotate_log
sleep "$DNS_CHECK_INTERVAL"
done
}
main "$@"

View File

@ -1,170 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# dns_iptables.sh — the iptables chain half of dns_enforcer.sh.
#
# Builds and maintains the DNS_IPT_CHAIN block chain: creating it and
# pinning a single OUTPUT jump, filling it for IPv4 and IPv6, computing
# the rule count that proves it is complete, and re-asserting it when it
# has drifted.
#
# Sourced by dns_enforcer.sh after config.sh, whose DNS_* values these
# functions read. They also call log(), which dns_enforcer.sh defines;
# nothing here runs until main() calls it, so definition order is fine.
# A sibling rather than a lib/ member: lib/ does not exist on the phone,
# and deploy.sh copies these scripts flat into $REMOTE_DIR.
# ============================================================
ensure_chain() {
local ipt="$1"
# Create the chain if missing.
if ! "$ipt" -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
"$ipt" -N "$DNS_IPT_CHAIN" 2>/dev/null || {
log "ERROR: could not create $ipt chain $DNS_IPT_CHAIN"
return 1
}
log "Created $ipt chain $DNS_IPT_CHAIN"
fi
# Remove ALL existing OUTPUT -> chain jumps (handles duplicates from
# previous iptables lock races where -C returned error but -I succeeded).
local removed=0
while "$ipt" -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null; do
removed=$((removed + 1))
done
# Insert exactly one jump at position 1 of OUTPUT.
if "$ipt" -I OUTPUT 1 -j "$DNS_IPT_CHAIN" 2>/dev/null; then
if [ "$removed" -gt 1 ]; then
log "De-duped $removed -> 1 OUTPUT jump for $ipt chain $DNS_IPT_CHAIN"
fi
else
log "ERROR: could not insert OUTPUT -> $DNS_IPT_CHAIN for $ipt"
return 1
fi
}
trusted_dot_ips() {
# Echo the trusted resolver's addresses of the given family, one per line.
# $1 is "4" or "6". Literals come from DNS_TRUSTED_DOT_IPS; anything else
# is ignored, because resolving the resolver's own name here would be
# circular once Private DNS is pinned to it.
local want="$1" ip
[ -z "${DNS_TRUSTED_DOT_HOST:-}" ] && return 0
for ip in ${DNS_TRUSTED_DOT_IPS:-}; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
case "$ip" in
*:*) [ "$want" = "6" ] && printf '%s\n' "$ip" ;;
*) [ "$want" = "4" ] && printf '%s\n' "$ip" ;;
esac
done
}
fill_chain_v4() {
# Flush and refill so we always converge to the intended rule set.
iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
# Allow the one DoT resolver we trust, BEFORE the blanket reject below.
# Order matters: iptables takes the first match, so an ACCEPT appended
# after the REJECT would never be reached.
local trusted
for trusted in $(trusted_dot_ips 4); do
iptables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \
-j ACCEPT 2>/dev/null || true
done
# Drop DoT everywhere else. This is a narrow port rule - there's no legit
# reason for arbitrary apps to talk 853/tcp on Android.
iptables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \
--reject-with icmp-port-unreachable 2>/dev/null || true
local ip
for ip in $DNS_DOH_IPV4; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
# Reject 443/tcp (DoH) and 53 (classic DNS) to well-known resolvers.
# We also block 53 so apps that try to talk to 1.1.1.1:53 directly
# (ignoring /etc/resolv.conf) still fall back to the system resolver.
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \
--reject-with icmp-port-unreachable 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53 -j REJECT \
--reject-with icmp-port-unreachable 2>/dev/null || true
iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
done
}
fill_chain_v6() {
ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
local trusted
for trusted in $(trusted_dot_ips 6); do
ip6tables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \
-j ACCEPT 2>/dev/null || true
done
ip6tables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \
--reject-with icmp6-port-unreachable 2>/dev/null || true
local ip
for ip in $DNS_DOH_IPV6; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \
--reject-with icmp6-port-unreachable 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53 -j REJECT \
--reject-with icmp6-port-unreachable 2>/dev/null || true
ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53 -j REJECT \
--reject-with tcp-reset 2>/dev/null || true
done
}
expected_rule_count() {
# 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP,
# plus 1 ACCEPT per trusted-resolver address of this family. $1 is the
# family ("4"/"6"); the remaining arguments are the DoH/DNS IPs.
#
# This has to match fill_chain_* exactly. chain_intact() compares the
# live rule count against this number and rebuilds on any mismatch, so
# an undercount here means a rebuild every DNS_CHECK_INTERVAL seconds --
# a silent fork storm that also drops the connection each time.
local family="$1"
shift
local n=2 ip
for ip in "$@"; do
[ -z "$ip" ] && continue
[ "${ip#\#}" != "$ip" ] && continue
n=$((n + 4))
done
for ip in $(trusted_dot_ips "$family"); do
n=$((n + 1))
done
echo "$n"
}
chain_intact() {
local ipt="$1" expected="$2" actual
"$ipt" -C OUTPUT -j "$DNS_IPT_CHAIN" >/dev/null 2>&1 || return 1
actual="$("$ipt" -S "$DNS_IPT_CHAIN" 2>/dev/null | grep -c '^-A')"
[ "$actual" = "$expected" ]
}
enforce_iptables() {
if command -v iptables >/dev/null 2>&1; then
# Intentional word split: expected_rule_count() iterates "$@" over the
# configured IP list. This runs under /system/bin/sh on the phone, where
# arrays do not exist, so an unquoted expansion is the POSIX way to do it.
# shellcheck disable=SC2086
if chain_intact iptables "$(expected_rule_count 4 $DNS_DOH_IPV4)"; then
:
elif ensure_chain iptables && fill_chain_v4; then
log "iptables (v4) DNS chain rebuilt (was missing/tampered)"
fi
fi
if command -v ip6tables >/dev/null 2>&1; then
# Intentional word split, as above.
# shellcheck disable=SC2086
if chain_intact ip6tables "$(expected_rule_count 6 $DNS_DOH_IPV6)"; then
:
elif ensure_chain ip6tables && fill_chain_v6; then
log "ip6tables (v6) DNS chain rebuilt (was missing/tampered)"
fi
fi
}

View File

@ -1,150 +0,0 @@
# Night curfew and the hotspot/tethering block
## Night curfew (after 23:00 at home)
On top of the location-based focus mode, a **time-gated curfew** makes the phone
boring and largely unusable late at night so you go to sleep instead of doom-
scrolling. It activates only when focus mode is already ON (i.e. you are at
home) **and** the local clock is inside the curfew window (default 23:00–05:00).
Out of that window, or away from home, nothing changes.
While the curfew is active it applies three allow-list layers — _block
everything except a short essential list_:
1. **Apps.** The daemon swaps the permissive `WHITELIST` for the strict
`NIGHT_WHITELIST` (banking, maps, calendar, clock, authenticators, gov ID,
workout/diet). Everything else — browsers, social, messaging, email, media,
manga, stores — is `pm disable-user`'d and re-enabled automatically at
05:00. Same proven mechanism as location focus; no new disable path.
2. **Display + notifications.** `curfew_enforcer.sh` forces the screen to
**grayscale** and DND to **alarms-only**, re-applying every 5s so toggling
them off in Settings snaps back. (Snap-back is the realistic lock; truly
blocking Settings risks system instability, so it is deliberately avoided.)
3. **Internet (optional, default OFF).** A per-UID `iptables` allow-list that
gives network only to the `NIGHT_WHITELIST` apps (plus root/system/shell +
DNS) and cuts off every other app. Enable `CURFEW_NET_ENABLED=1` in
`config.sh` only after validating it on-device (see test hook below).
### Configuration (`config.sh`)
```sh
NIGHT_CURFEW_ENABLED=1 # master switch
NIGHT_CURFEW_START="2300" # local HHMM; window wraps past midnight
NIGHT_CURFEW_END="0500"
CURFEW_GRAYSCALE_ENABLED=1 # force monochrome
CURFEW_DND_ENABLED=1 # force DND alarms-only
CURFEW_NET_ENABLED=0 # per-UID internet allow-list (prove first!)
```
Edit `NIGHT_WHITELIST` (right below `WHITELIST`) to choose what stays usable at
night. Allow-list by design: when in doubt, leave it out. The active keyboard
and the core dialer/SMS/home apps are always protected automatically (a 1am
reboot can never strand you without a keyboard), and the default browser is
intentionally _not_ protected at night so it can be disabled.
### Control
```bash
# On-device (root shell):
focus_ctl.sh curfew-status # window, enforcer state, what's applied
focus_ctl.sh curfew-test-on # FORCE curfew now (daytime validation)
focus_ctl.sh curfew-test-off # clear the force
focus_ctl.sh curfew-off # escape hatch: suspend curfew now
focus_ctl.sh curfew-on # re-arm (clear the override)
focus_ctl.sh curfew-log # enforcer log
```
### Opting out at 2am (no PC)
The companion status notification grows a **"Suspend curfew till morning"**
action while the curfew is active. Tapping it drops the override file (curfew
off until you re-arm); the label flips to **"Re-arm curfew"**. The action is
hidden during the day so it is not a casual temptation. Without the PC this is
the only on-device opt-out — by design. From the PC you can always
`./deploy.sh <ip> --restart` or run `focus_ctl.sh curfew-off` over ADB.
### Validating before you trust it overnight
Because a misconfigured curfew can lock apps at 2am, validate it during the day
with the force hook, **not** by waiting for 23:00:
```bash
focus_ctl.sh curfew-test-on # mBank + keyboard work, Firefox gone, gray, DND
focus_ctl.sh curfew-test-off # blocked apps come BACK (the reconcile path)
```
The clock parser fails **open** (treated as daytime) on a malformed time, so a
broken `date` can never trap you behind the strict list.
## Hotspot / tethering block (closes the "second phone" bypass)
Every other network layer only filters **this** phone's own traffic:
`/system/etc/hosts` is consulted by the phone's system resolver, and both
`dns_enforcer` and the curfew net layer only touch the **OUTPUT** chain. When
the phone shares its mobile data as a WiFi hotspot, a tethered second phone's
packets are **FORWARDed + NAT'd** through this phone on a path none of that
covers — so the second phone browses freely and defeats focus mode.
`tether_enforcer.sh` closes that hole. It is always-on but **only acts while
focus mode is ON** (i.e. you are at home, `current_mode.txt == focus`), and
converges three levers every `TETHER_CHECK_INTERVAL` seconds:
1. **Disable tether offload** — sets `settings global tether_offload_disabled 1`
so forwarded traffic is actually seen by netfilter instead of being shunted
around it by the hardware/BPF fast path. Snapshotted on entry, restored on
exit.
2. **FORWARD blanket REJECT** — an `iptables`/`ip6tables` chain
(`FOCUS_TETHER_BLOCK`) pinned at position 1 of `FORWARD`, rejecting all
forwarded packets. This is the version-independent catch-all and covers
**WiFi, USB and Bluetooth** tethering. The phone's own traffic uses
OUTPUT/INPUT, never FORWARD, so normal connectivity is untouched. Rebuilt
only when tampered (chain-intact gate), so it does not fork an `iptables -L`
every second (that pegged netd and overheated the phone during curfew-net
tuning).
3. **Stop the softAP** (best-effort, WiFi only, Android 11+) — `cmd wifi
stop-softap` each tick so the hotspot toggle visibly flips back off.
On the transition away from home it restores the offload snapshot and tears the
FORWARD chain down, leaving tethering usable.
### Configuration (`config.sh`)
```sh
TETHER_ENFORCER_ENABLED=1 # master switch
TETHER_CHECK_INTERVAL=5 # re-assert cadence (seconds)
TETHER_STOP_SOFTAP_ENABLED=1 # also actively kill a running softAP
```
### Control
```bash
focus_ctl.sh tether-status # enforcer state, offload flag, FORWARD chain
focus_ctl.sh tether-test-on # FORCE the block now (daytime validation)
focus_ctl.sh tether-test-off # clear the force
focus_ctl.sh tether-stop # escape hatch: stop it, restore tethering
focus_ctl.sh tether-start # (re)start the enforcer
focus_ctl.sh tether-log # enforcer log
```
The escape hatch is `tether-stop` over ADB (or a fresh `deploy.sh`), consistent
with `dns_enforcer`/`launcher_enforcer` — there is no companion-app button.
`tether_override` (created manually) suspends the block without stopping the
daemon.
### Validating before you trust it
Because Android's tether offload can silently skip the FORWARD rule on some
ROMs, validate on-device with a **real second phone** — do not trust rule
counts:
```bash
focus_ctl.sh tether-test-on # hotspot on + second phone browsing → it loses
# internet within one interval; this phone stays
# online. Watch: focus_ctl.sh tether-log
focus_ctl.sh tether-test-off # forwarding restored, second phone browses again
```
If the second phone keeps browsing with the block applied, offload is being
bypassed: confirm the exact global key with
`adb shell su -c 'settings list global | grep -i offload'` and update
`TETHER_OFFLOAD_KEY` in `config.sh`.

View File

@ -1,101 +0,0 @@
# Policy list rationale
Prose moved out of `phone_focus_mode/config.sh` to keep that file under
the repo's 250-line cap. The lists themselves stay in `config.sh` and
must: `python_pkg/focus_policy/loader.py` finds them by regex-scanning
that file's text alone, so a list moved elsewhere silently parses as
empty.
## Allowed Package Prefixes
ALLOWED PACKAGE PREFIXES
Matched as prefixes on whole labels, exactly like $SYSTEM_NEVER_DISABLE:
"eu.kanade.tachiyomi" covers "eu.kanade.tachiyomi.sy" and
"eu.kanade.tachiyomi.extension.all.mangadex", but not
"eu.kanade.tachiyomisomething".
This exists because Tachiyomi installs every source as its OWN apk. Listing
them individually means each newly installed extension is invisible until
this file is edited and the policy regenerated -- a recurring chore that
looks exactly like a bug from the phone.
Weaker than the exact list by construction: a prefix allows packages that do
not exist yet. Keep the prefixes narrow and vendor-specific for that reason.
## Night Curfew Whitelist
NIGHT CURFEW WHITELIST
These are the ONLY third-party apps that stay enabled during the curfew
window (see NIGHT_CURFEW_* above). Everything else in $WHITELIST — browsers,
social, messaging, email, stores, transit — is disabled.
Allow-list by design: when in doubt, leave it OUT.
EXCEPTION: $NIGHT_ALLOWED_PREFIXES is applied on top of this list, and it
currently carries "eu.kanade.tachiyomi" — so manga IS available during the
curfew, deliberately (chosen 2026-08-14). This paragraph used to say manga
was disabled at night; it was true until that change. Do not "restore" it
without also emptying $NIGHT_ALLOWED_PREFIXES, or the comment and the
behaviour disagree again.
Parsed exactly like $WHITELIST (one package per line, '#' comments ignored).
The sysprotect prefixes ($SYSTEM_NEVER_DISABLE) and the default-handler guard
(dialer/SMS/home/browser/IME) still apply on TOP of this list, so the active
keyboard and core system apps are protected even if omitted here.
## System packages that must never be disabled
--- System / essential packages that must NEVER be disabled ---
These are matched as prefixes (startswith).
You generally don't need to edit this list.
pl.infakt.infakt is the one non-system entry. Allowlisting it is weaker:
that depends on it staying in BOTH the day and night lists, and dropping it
from either would silently make it hideable. It is device-paired to a bank
over SMS, so losing access to it strands the same re-authentication chain a
hidden Messages app would. isAllowed() checks this list first, before the
curfew split, so it holds under every condition.
## Whitelisted apps
WHITELISTED APPS
These apps will ALWAYS remain enabled, even in focus mode.
Package names verified against installed packages on 2026-02-22.
## Night curfew
NIGHT CURFEW (time-gated strict allow-list)
When focus mode is ON (i.e. you are at home) AND the local clock is inside
the curfew window, the daemon switches from the permissive $WHITELIST to the
strict $NIGHT_WHITELIST: every app not on that short list is disabled. This
is the "stop using the phone after 23:00 at home" layer. The companion
enforcer (curfew_enforcer.sh) adds grayscale + DND + an optional per-UID
network allow-list on top. Times are local 24h "HHMM"; the window wraps past
midnight when START > END (e.g. 2300 -> 0500).
## Why dev.kuhy.todo is in the night list
Capture-only notes app. Added 2026-08-14 after a curfew-window deploy
installed it and the enforcer removed the package ~80ms later: it was in
the day list but not here, so any build shipped after 23:00 was silently
uninstalled. This is a deliberate loosening of the answer-the-phone /
reach-a-bank / handle-an-emergency rule above -- writing an idea down at
night is the one thing this app does, and losing the deploy path for six
hours a day cost more than the distraction risk.
## Why the Play Store is in the day list
--- App store (day only; deliberately absent from the night list) ---
NB: never write a dollar-sign variable reference inside this list. WHITELIST
is a double-quoted string, so even a comment line expands, and deploy.sh
runs under set -u, where an undefined name aborts the whole deploy. A
reference to the night list sat here and did exactly that (it is defined
below this point, so it was still unset), blocking every focus-mode deploy.
Same rule for double quotes: one in a comment ends the string early.
infakt cannot be installed or updated without Play, and it is device-paired
to a bank, so losing the ability to update it strands a re-authentication
chain. This does NOT reopen YouTube: the sweep is default-deny for
third-party packages, so anything installed from Play is hidden on the next
at-home pass, and the always-blocked set (YouTube, Chrome) is never restored
by the AWAY branch either. Reinstalling YouTube from Play is UNTESTED as a
bypass:
the sweep would re-hide it within one pass regardless, but treat the claim
that Play cannot resurrect a blocked app as unverified until someone tries.
NOTE: never use a double-quote character anywhere inside these export
blocks, not even in a comment. The policy loader matches a quoted value up
to the next quote, so one stray quote terminates the string early and
silently truncates the allowlist -- which drops apps with no error at all.

View File

@ -1,36 +0,0 @@
# Updating and troubleshooting
## Updating
After editing `config.sh` (e.g. changing whitelist):
```bash
./deploy.sh <ip> # re-pushes all files
# or just the config:
adb push config.sh /data/local/tmp/focus_mode/config.sh
./deploy.sh <ip> --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 <ip> --find-pkg <keyword>` to find the exact package name
- Package names are case-sensitive

View File

@ -1,166 +0,0 @@
# Blocking YouTube on an unrooted Pixel 6a
Applied and verified 2026-08-09 on `23181JEGR08034` (Android 16, SDK 36).
No root, no Device Owner, no factory reset.
## What actually works: `pm uninstall --user 0`
Unregisters a package for one user while leaving the APK on the read-only
system image.
| Mechanism | Survives reboot? |
| --------------------------- | ----------------------------------------- |
| `pm suspend` | No — measured |
| `pm disable-user` | No — measured |
| `dpm setApplicationHidden` | Yes — but needs Device Owner, i.e. a wipe |
| **`pm uninstall --user 0`** | **Yes — measured, twice** |
Reversible with `pm install-existing --user 0 <pkg>`: no download, no data
loss, because the APK never left the device.
## Why focus_owner cannot do this job
`EnforcementRunner.installedThirdPartyPackages()` filters to packages without
`FLAG_SYSTEM`. YouTube ships at `/product/app/YouTube` **with** `FLAG_SYSTEM`,
so the Device Owner sweep would skip it — even after the factory reset. The
apps most worth blocking are preinstalled, which is exactly the set that sweep
excludes. Worth fixing there separately; it does not block anything today.
## Current state
Removed for user 0, all reboot-verified:
- `com.google.android.youtube`
- `com.google.android.apps.youtube.music`
- `com.android.chrome`
Driven by `phone_focus_mode/distraction_purge.sh` (`--list`, `--status`,
`--restore`). It re-checks each package with `dumpsys` after acting rather
than trusting the `pm` exit code, and exits non-zero if anything is still
installed — so a Play Store reinstall surfaces as a failure instead of a
silent no-op.
## Layer 2: uBlock Origin in Firefox
Independent of the VPN, and that is the point: stopping RethinkDNS does
**not** restore YouTube in Firefox. Verified by force-stopping
`com.celzero.bravedns` and loading youtube.com, which produced _"uBlock
Origin has prevented the following page from loading — `||youtube.com^` —
found in: My filters"_.
The filters, in uBlock's dashboard → **My filters**:
```
! YouTube block
||youtube.com^
||youtu.be^
||googlevideo.com^
||ytimg.com^
||youtube-nocookie.com^
||ggpht.com^
||yt3.ggpht.com^
||m.youtube.com^
```
Add them by writing that list to a file, pushing it to
`/sdcard/Download/`, triggering a media scan (`am broadcast -a
android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file:///sdcard/...`,
or the picker will not see it), then using uBlock's **import** button.
Do NOT type them with `adb shell input text`: it silently strips `|` and
`^`, leaving bare domains that do not block. Press the checkmark to
apply, and confirm it greys out.
These live in the Firefox profile, so they are **not** captured by
`phone_backup.sh` and must be re-added by hand after a wipe.
The block page has a **Proceed** button — one tap. This layer raises the
cost of bypassing; it does not remove the bypass.
## What is NOT blocked
**Firefox (`org.mozilla.fenix`) is still installed and still reaches
youtube.com.** It is the only remaining browser after Chrome's removal.
Unlike the three packages above, Firefox is user-installed
(`/data/app/...`, no `FLAG_SYSTEM`), so `pm uninstall --user 0` would
**permanently delete it and its data** — no `install-existing` recovery, a
full reinstall from the Play Store. That asymmetry is why it was left alone
rather than swept up with the rest. Decide deliberately.
`com.google.android.webview` was checked and left in place: it is the system
WebView provider, so removing it would break in-app browsers everywhere.
Chrome and WebView are separate packages on this device, which is why
removing Chrome is safe.
## The web path, and why DNS is parked
Removing the app kills the recommendation feed, notifications and the
account-linked session — most of the pull. It does not stop typing
`youtube.com` into a browser.
Previously rejected approaches, from `focus_owner/docs/`:
- **Home-hosted DoT resolver.** Android's Private DNS fails _closed_, so PC
off = phone has no internet at all. Rejected: the phone must work with the
PC off.
- **Hosted DNS (NextDNS, AdGuard, ControlD).** No provider ingests the 185k
domain blocklist; free tiers fail _open_ past ~300k queries/month.
The remaining candidate is a **local-VPN content blocker** — an on-device
`VpnService` that filters without any network dependency, so it works with the
PC off and does not fail closed on network loss. Not yet scoped; the VPN slot
was going to be checked when the device disconnected. Note Android allows only
one active VPN at a time, so this would conflict with any real VPN.
## How strong this actually is
Measured, not assumed:
| Layer | Undo cost |
| ---------------- | ------------------------------------------------------------------------------------------------------------------- |
| App removal | **One tap.** Google Play shows a live **Install** button for YouTube (checked on device 2026-08-09; not installed). |
| RethinkDNS rules | A few taps — the VPN toggle in Settings or the quick-settings tile. |
| uBlock filters | **Proceed** on the block page, or disabling the extension. |
Nothing here is tamper-resistant, and nothing here can be: on an
unrooted device without Device Owner, no app can prevent the user
disabling a VPN or reinstalling from Play. The layers are independent —
undoing one does not undo the others — so the cost is "three separate
places" rather than one. That is the honest ceiling.
`DISALLOW_CONFIG_VPN` plus `setAlwaysOnVpnPackage(..., lockdown)` under
Device Owner is the only tier that removes the off switch, and it needs
the factory reset in `docs/device-owner-wipe-checklist.md`. See also the
FLAG_SYSTEM note above: `focus_owner` would need fixing before Device
Owner could touch YouTube at all.
Also note nothing re-asserts the purge automatically.
`phone-auto-sync.timer` is inactive and its `ExecStart` points at a path
that does not exist, so `distraction_purge.sh` is manual today.
## infakt, before any factory reset
Checked 2026-08-09 in the web panel and on the device, because infakt was
the one app whose loss would have blocked the Device Owner wipe.
- **2FA is SMS-only.** "Aplikacja weryfikacyjna" reads _Skonfiguruj_, i.e.
not configured, so **no TOTP seed exists** and a wipe cannot destroy
one. The registered number survives on the SIM.
- **"Logowanie" is unchecked** under _Akcje objęte weryfikacją_ — only
_Wrażliwe_ actions prompt. Plain login needs the password alone.
- The app uses Firebase push (`FirebaseInstanceIdReceiver`) for the
browser-login approval tap. Push tokens are per-install, so a reinstall
re-registers and prompts arrive at the new install.
So login is safe: reinstall, sign in with the password.
What does **not** survive: the banking pairing (_Usuń powiązanie z
telefonem_) and the local app PIN. Re-pair via SMS after the wipe.
Do not save anything on that settings page casually — it warns that
changing it logs out all current mobile sessions.
## Redoing this after a factory reset
The purge is per-user state and does not survive a wipe. Re-run
`./distraction_purge.sh` after re-enrolling the device.

View File

@ -1,193 +0,0 @@
#!/bin/bash
# ============================================================================
# Make adb-over-Wi-Fi permanent on the phone (port 5555).
#
# Android's "Wireless debugging" is deliberately ephemeral: the listener dies
# when you leave the settings screen or the device sleeps, and it picks a new
# random port every time it comes back. That is why every deploy so far has
# needed someone to tap through the phone first.
#
# This script waits for ANY transient wireless-debugging window, then uses it
# to switch adbd to a fixed port 5555 and installs a Magisk boot script so the
# same thing happens on every boot. After one successful run the phone is
# reachable at $PHONE_IP:5555 forever, with no tapping.
#
# Requires: root (Magisk) on the device, and the host already paired.
#
# Usage:
# ./enable_persistent_adb.sh [PHONE_IP] [KNOWN_PORT]
# PHONE_IP defaults to 192.168.1.64.
# KNOWN_PORT, if given, is tried first before falling back to a port scan.
# ============================================================================
set -uo pipefail
PHONE_IP="${1:-192.168.1.64}"
readonly KNOWN_PORT="${2:-}"
readonly FIXED_PORT=5555
# The phone is on DHCP, so its address can move. Its MAC cannot, so that is
# what we actually identify it by; PHONE_IP is only a first guess.
readonly PHONE_MAC="00:08:22:68:81:fc"
# Android allocates the wireless-debugging port from the ephemeral range.
readonly SCAN_RANGE="30000-65535"
# Give up rather than spin forever if nobody ever wakes the phone.
readonly DEADLINE_SECONDS=1800
readonly BOOT_SCRIPT=/data/adb/service.d/99-adb-tcp-5555.sh
log() { printf '[persistent-adb] %s\n' "$1"; }
require() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: $1 is required but not installed" >&2
exit 1
fi
}
# Echo the serial iff THIS endpoint is connected and not offline.
#
# Deliberately matches the exact ip:port rather than the first `device` line.
# After pairing, adb also auto-connects an mDNS `_adb-tls-connect._tcp` entry
# for the same phone; taking the first line made `try_connect 5555` report
# success while port 5555 was in fact closed.
connected_serial() {
local endpoint="$1"
adb devices 2>/dev/null |
awk -v want="$endpoint" '$1 == want && $2 == "device" {print $1; exit}'
}
# Try to reach adbd. Returns 0 and sets REPLY to the serial when connected.
try_connect() {
local port="$1" endpoint
endpoint="${PHONE_IP}:${port}"
adb connect "$endpoint" >/dev/null 2>&1 || return 1
# `connect` succeeds optimistically; the device can still be offline.
sleep 2
REPLY="$(connected_serial "$endpoint")"
[[ -n "$REPLY" ]]
}
# Locate the phone on the current subnet by MAC and echo its IP.
#
# DHCP can hand the phone a different address at any time, which would break a
# hardcoded PHONE_IP. A ping sweep populates the neighbour table, then we look
# the MAC up in it. Prefer this over a router DHCP reservation because it needs
# no router access and keeps working on any network the laptop joins.
discover_ip_by_mac() {
local subnet
subnet="$(ip -4 route show dev "$(ip -4 route show default |
awk '/default/ {print $5; exit}')" 2>/dev/null |
awk '/proto kernel/ {print $1; exit}')"
[[ -n "$subnet" ]] || return 1
nmap -sn -T4 "$subnet" >/dev/null 2>&1
ip neigh show 2>/dev/null |
awk -v mac="$PHONE_MAC" 'tolower($5) == tolower(mac) {print $1; exit}'
}
# Find the current ephemeral wireless-debugging port, if one is listening.
scan_for_port() {
nmap -p "$SCAN_RANGE" --open -T4 -Pn --min-rate 3000 "$PHONE_IP" 2>/dev/null |
awk -F/ '/^[0-9]+\/tcp[[:space:]]+open/ {print $1}' | head -1
}
# Switch adbd to the fixed port and persist it across reboots.
make_permanent() {
local serial="$1"
if ! adb -s "$serial" shell 'su -c id' 2>/dev/null | grep -q 'uid=0'; then
echo "Error: no root on the device; cannot install the boot script." >&2
echo " adb tcpip $FIXED_PORT alone would not survive a reboot." >&2
return 1
fi
log "installing Magisk boot script at $BOOT_SCRIPT"
# service.d runs late in boot, after the network is up, which is what adbd
# needs. Restarting adbd is what actually makes it re-read the port.
adb -s "$serial" shell "su -c 'cat > $BOOT_SCRIPT'" <<EOF
#!/system/bin/sh
# Installed by phone_focus_mode/enable_persistent_adb.sh.
# Pin adb-over-Wi-Fi to a fixed port so the laptop can always connect without
# anyone tapping through Settings -> Wireless debugging first.
setprop service.adb.tcp.port $FIXED_PORT
stop adbd
start adbd
EOF
adb -s "$serial" shell "su -c 'chmod 755 $BOOT_SCRIPT'"
log "switching adbd to port $FIXED_PORT now"
adb -s "$serial" shell "su -c 'setprop service.adb.tcp.port $FIXED_PORT; stop adbd; start adbd'"
# adbd restarting drops every current connection, including this one.
sleep 4
}
main() {
require adb
require nmap
log "waiting for a wireless-debugging window on $PHONE_IP (up to $((DEADLINE_SECONDS / 60))m)"
log "wake the phone and open Settings -> Developer options -> Wireless debugging"
local deadline=$((SECONDS + DEADLINE_SECONDS))
local serial=""
while ((SECONDS < deadline)); do
# Cheapest first: the fixed port may already be live from a prior run.
if try_connect "$FIXED_PORT"; then
serial="$REPLY"
log "already reachable on $FIXED_PORT - nothing to do"
printf '%s\n' "$serial"
return 0
fi
# Not at the expected address — DHCP may have moved it. Re-find the
# phone by MAC before concluding that adbd is down.
local moved
moved="$(discover_ip_by_mac)"
if [[ -n "$moved" && "$moved" != "$PHONE_IP" ]]; then
log "phone moved: $PHONE_IP -> $moved"
PHONE_IP="$moved"
if try_connect "$FIXED_PORT"; then
serial="$REPLY"
log "reachable on $FIXED_PORT at the new address"
printf '%s\n' "$serial"
return 0
fi
fi
if [[ -n "$KNOWN_PORT" ]] && try_connect "$KNOWN_PORT"; then
serial="$REPLY"
break
fi
local found
found="$(scan_for_port)"
if [[ -n "$found" ]] && try_connect "$found"; then
serial="$REPLY"
break
fi
sleep 5
done
if [[ -z "$serial" ]]; then
echo "Error: no wireless-debugging window appeared before the deadline." >&2
return 1
fi
log "connected as $serial"
make_permanent "$serial" || return 1
if try_connect "$FIXED_PORT"; then
log "SUCCESS: phone is now reachable at ${PHONE_IP}:${FIXED_PORT}"
log "it will come back on this port after every reboot"
printf '%s\n' "${PHONE_IP}:${FIXED_PORT}"
return 0
fi
echo "Error: adbd did not come back on $FIXED_PORT after the switch." >&2
return 1
}
main "$@"

View File

@ -1,171 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# Focus Mode Control Utility
# Run on the phone via: su --mount-master -c /data/local/tmp/focus_mode/focus_ctl.sh <command>
# Or from PC via: adb shell su --mount-master -c '/data/local/tmp/focus_mode/focus_ctl.sh <command>'
# --mount-master is required so this script (and any daemon it spawns) joins
# the global mount namespace; otherwise the hosts bind mount is invisible and
# /data/adb/focus_mode/* checks fail due to per-session SELinux isolation.
# ============================================================
SCRIPT_DIR="/data/local/tmp/focus_mode"
. "$SCRIPT_DIR/config.sh"
# shellcheck source=ctl_hosts.sh
. "$SCRIPT_DIR/ctl_hosts.sh"
# shellcheck source=ctl_dns.sh
. "$SCRIPT_DIR/ctl_dns.sh"
# shellcheck source=ctl_launcher.sh
. "$SCRIPT_DIR/ctl_launcher.sh"
# shellcheck source=ctl_workout.sh
. "$SCRIPT_DIR/ctl_workout.sh"
# shellcheck source=ctl_curfew.sh
. "$SCRIPT_DIR/ctl_curfew.sh"
# shellcheck source=ctl_tether.sh
. "$SCRIPT_DIR/ctl_tether.sh"
# shellcheck source=ctl_usage.sh
. "$SCRIPT_DIR/ctl_usage.sh"
# shellcheck source=ctl_daemon.sh
. "$SCRIPT_DIR/ctl_daemon.sh"
# ---- Logging ----
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >>"$LOG_FILE"
}
HOSTS_PIDFILE="$STATE_DIR/hosts_enforcer.pid"
# ---- DNS enforcer ----
# Hosts file only works for the system resolver. Apps using DoH/DoT bypass
# /etc/hosts entirely. The DNS enforcer forces Private DNS off and blocks
# well-known DoH/DoT endpoints so /etc/hosts is actually consulted.
DNS_PIDFILE="$STATE_DIR/dns_enforcer.pid"
# ---- Launcher enforcer ----
LAUNCHER_PIDFILE="$STATE_DIR/launcher_enforcer.pid"
DISABLED_COMPETITORS_FILE="$STATE_DIR/disabled_competitors.txt"
# ---- Workout detector ----
WORKOUT_PIDFILE="$STATE_DIR/workout_detector.pid"
# ============================================================
# Night-curfew control (see curfew_enforcer.sh / focus_daemon.sh)
# ============================================================
CURFEW_PIDFILE="$STATE_DIR/curfew_enforcer.pid"
cmd_curfew_log() { tail -n "${1:-50}" "$CURFEW_ENFORCER_LOG" 2>/dev/null || echo "No curfew log yet."; }
# ============================================================
# Hotspot / tethering block control (see tether_enforcer.sh)
# ============================================================
TETHER_PIDFILE="$STATE_DIR/tether_enforcer.pid"
case "$1" in
start) cmd_start ;;
stop) cmd_stop ;;
status) cmd_status ;;
enable) cmd_enable ;;
disable) cmd_disable ;;
log) cmd_log "${2:-50}" ;;
list-apps) cmd_list_apps ;;
whitelist) cmd_whitelist ;;
restart)
cmd_stop
sleep 2
cmd_start
;;
hosts-status) cmd_hosts_status ;;
hosts-start) cmd_hosts_start ;;
hosts-stop) cmd_hosts_stop ;;
hosts-log) cmd_hosts_log "${2:-50}" ;;
dns-status) cmd_dns_status ;;
dns-start) cmd_dns_start ;;
dns-stop) cmd_dns_stop ;;
dns-log) cmd_dns_log "${2:-50}" ;;
launcher-status) cmd_launcher_status ;;
launcher-start) cmd_launcher_start ;;
launcher-stop) cmd_launcher_stop ;;
launcher-log) cmd_launcher_log "${2:-50}" ;;
launcher-snapshot) cmd_launcher_snapshot ;;
workout-status) cmd_workout_status ;;
workout-start) cmd_workout_start ;;
workout-stop) cmd_workout_stop ;;
workout-log) cmd_workout_log "${2:-50}" ;;
recheck) cmd_recheck ;;
notif-status) cmd_notif_status ;;
curfew-status) cmd_curfew_status ;;
curfew-start) cmd_curfew_start ;;
curfew-stop) cmd_curfew_stop ;;
curfew-log) cmd_curfew_log "${2:-50}" ;;
curfew-test-on) cmd_curfew_test_on ;;
curfew-test-off) cmd_curfew_test_off ;;
curfew-demo-on) cmd_curfew_demo_on ;;
curfew-demo-off) cmd_curfew_demo_off ;;
curfew-off) cmd_curfew_off ;;
curfew-on) cmd_curfew_on ;;
tether-status) cmd_tether_status ;;
tether-start) cmd_tether_start ;;
tether-stop) cmd_tether_stop ;;
tether-log) cmd_tether_log "${2:-50}" ;;
tether-test-on) cmd_tether_test_on ;;
tether-test-off) cmd_tether_test_off ;;
*) usage ;;
esac

View File

@ -1,206 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# Focus Mode Daemon
# Runs on rooted Android device. Periodically checks GPS
# location and restricts non-whitelisted apps when near home.
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/config.sh"
# shellcheck source=daemon_location.sh
. "$SCRIPT_DIR/daemon_location.sh"
# shellcheck source=daemon_state.sh
. "$SCRIPT_DIR/daemon_state.sh"
# shellcheck source=daemon_apps.sh
. "$SCRIPT_DIR/daemon_apps.sh"
PIDFILE="$STATE_DIR/daemon.pid"
# ---- PID lock: exit if already running ----
acquire_lock() {
mkdir -p "$STATE_DIR"
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
# Verify the PID is actually a focus_daemon, not a reused PID
local cmdline
cmdline="$(tr '\0' ' ' <"/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "focus_daemon"; then
echo "Daemon already running (PID $old_pid), exiting."
exit 0
fi
fi
# Stale or reused pidfile
rm -f "$PIDFILE"
fi
echo $$ >"$PIDFILE"
}
# ---- Logging ----
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >>"$LOG_FILE"
}
rotate_log() {
local lines
lines="$(wc -l <"$LOG_FILE" 2>/dev/null || echo 0)"
if [ "$lines" -gt "$LOG_MAX_LINES" ]; then
local tmp="$LOG_FILE.tmp"
tail -n "$LOG_MAX_LINES" "$LOG_FILE" >"$tmp"
mv "$tmp" "$LOG_FILE"
fi
}
# ---- Build helper files for fast package checks ----
# ---- Initialization ----
init() {
mkdir -p "$STATE_DIR"
touch "$LOG_FILE"
touch "$DISABLED_APPS_FILE"
# Ensure state files are writable (survives reboot / permission drift)
chmod 666 "$LOG_FILE" "$DISABLED_APPS_FILE" "$PIDFILE" 2>/dev/null
# Status file must be world-readable (companion app reads it).
# State dir must be world-writable+executable so the companion app can
# drop the recheck trigger file (it runs as a normal app UID).
chmod 777 "$STATE_DIR" 2>/dev/null
if [ "$HOME_LAT" = "0.000000" ] && [ "$HOME_LON" = "0.000000" ]; then
log "ERROR: Home coordinates not set! Edit config_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
build_whitelist_file
build_night_whitelist_file
build_sysprotect_file
build_blocked_sys_file
refresh_default_handlers
rotate_log
if [ -f "$MODE_FILE" ]; then
CURRENT_MODE="$(cat "$MODE_FILE")"
else
CURRENT_MODE="normal"
fi
if [ "$CURRENT_MODE" = "focus" ]; then
reconcile_disabled_apps
fi
log "Focus mode daemon started (PID=$$, mode=$CURRENT_MODE, home=$HOME_LAT,$HOME_LON, radius=${RADIUS}m)"
log "Intervals: focus=${CHECK_INTERVAL_FOCUS}s normal=${CHECK_INTERVAL_NORMAL}s"
}
# ---- Focus Mode Control ----
# ---- Sleep with early-wake on recheck trigger ----
# Polls for $RECHECK_TRIGGER every second; if found, consumes it and returns
# early. The file can be touched by the companion app (via "Re-check now"
# button) or by `focus_ctl.sh recheck` from a shell.
sleep_with_recheck() {
local total="$1"
local elapsed=0
while [ "$elapsed" -lt "$total" ]; do
if [ -e "$RECHECK_TRIGGER" ]; then
rm -f "$RECHECK_TRIGGER" 2>/dev/null
log "Manual re-check triggered"
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
}
# ---- Signal handlers ----
cleanup() {
log "Daemon shutting down - re-enabling all apps"
disable_focus_mode
rm -f "$PIDFILE"
exit 0
}
# HUP is intentionally NOT trapped so the daemon survives ADB disconnects.
# Only SIGTERM/SIGINT trigger a clean shutdown.
trap cleanup INT TERM
# ---- Main Loop ----
main() {
acquire_lock
init
while true; do
# Invalidate the per-tick curfew_active() memo (see its definition).
_CURFEW_TICK_CACHED=""
location="$(get_location)"
if [ -n "$location" ]; then
lat="$(echo "$location" | cut -d',' -f1)"
lon="$(echo "$location" | cut -d',' -f2)"
distance="$(calc_distance "$lat" "$lon" "$HOME_LAT" "$HOME_LON")"
if [ "$CURRENT_MODE" = "focus" ]; then
threshold=$((RADIUS + HYSTERESIS))
else
threshold=$((RADIUS - HYSTERESIS))
fi
if [ "$distance" -le "$threshold" ] 2>/dev/null; then
enable_focus_mode
else
disable_focus_mode
fi
curfew_state="day"
curfew_active && curfew_state="CURFEW"
log "Location: $lat,$lon | Distance: ${distance}m | Threshold: ${threshold}m | Mode: $CURRENT_MODE | Curfew: $curfew_state"
write_status_snapshot "$CURRENT_MODE" "$lat" "$lon" "$distance" "$threshold"
else
log "Location unavailable - defaulting to focus mode (restrictions ON)"
enable_focus_mode
write_status_snapshot "$CURRENT_MODE" "" "" "null" "null"
fi
# Dynamic interval: shorter at home (can charge), longer away (save battery).
# sleep_with_recheck returns early if the companion app requests a recheck.
if [ "$CURRENT_MODE" = "focus" ]; then
sleep_with_recheck "$CHECK_INTERVAL_FOCUS"
else
sleep_with_recheck "$CHECK_INTERVAL_NORMAL"
fi
rotate_log
done
}
main "$@"

View File

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

View File

@ -1,111 +0,0 @@
#!/usr/bin/env bash
# ============================================================
# Focus Status App builder (no Gradle, no Kotlin).
# Compiles a minimal Java APK with aapt2 + javac + d8 + apksigner.
# Produces: build/focus_status.apk (debug-signed)
# ============================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
SDK="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}"
# Pick highest-numbered build-tools directory.
# A glob into an array rather than parsing ls: a build-tools path containing a
# space or newline would otherwise be split into garbage.
_bt_dirs=("$SDK"/build-tools/*/)
BUILD_TOOLS_DIR="$(printf '%s\n' "${_bt_dirs[@]}" | sort -V | tail -1 | sed 's:/$::')"
# Pick highest-numbered platform.
_plat_dirs=("$SDK"/platforms/android-*/)
PLATFORM_DIR="$(printf '%s\n' "${_plat_dirs[@]}" | sort -V | tail -1 | sed 's:/$::')"
PLATFORM_JAR="$PLATFORM_DIR/android.jar"
[ -d "$BUILD_TOOLS_DIR" ] || {
echo "ERROR: build-tools not found under $SDK" >&2
exit 1
}
[ -f "$PLATFORM_JAR" ] || {
echo "ERROR: android.jar not found at $PLATFORM_JAR" >&2
exit 1
}
AAPT2="$BUILD_TOOLS_DIR/aapt2"
D8="$BUILD_TOOLS_DIR/d8"
ZIPALIGN="$BUILD_TOOLS_DIR/zipalign"
APKSIGNER="$BUILD_TOOLS_DIR/apksigner"
for tool in "$AAPT2" "$D8" "$ZIPALIGN" "$APKSIGNER"; do
[ -x "$tool" ] || {
echo "ERROR: missing build tool: $tool" >&2
exit 1
}
done
BUILD="$SCRIPT_DIR/build"
rm -rf "$BUILD"
mkdir -p "$BUILD/compiled-res" "$BUILD/classes" "$BUILD/dex"
# ---- Compile resources (none for now, but aapt2 requires the dir) ----
mkdir -p "$SCRIPT_DIR/res"
if find "$SCRIPT_DIR/res" -type f -print -quit | grep -q .; then
"$AAPT2" compile --dir "$SCRIPT_DIR/res" -o "$BUILD/compiled-res"
fi
# ---- Link resources + manifest into base APK ----
LINK_ARGS=(
--manifest "$SCRIPT_DIR/AndroidManifest.xml"
-I "$PLATFORM_JAR"
--java "$BUILD"
--min-sdk-version 29
--target-sdk-version 35
--version-code 1
--version-name 1.0.0
-o "$BUILD/base.apk"
)
# Include any compiled res archives.
for rfile in "$BUILD"/compiled-res/*.flat; do
[ -e "$rfile" ] && LINK_ARGS+=("$rfile")
done
"$AAPT2" link "${LINK_ARGS[@]}"
# ---- Compile Java ----
# Collect .java files (including generated R.java if resources exist).
JAVA_SRCS=()
while IFS= read -r -d '' f; do JAVA_SRCS+=("$f"); done < <(find "$SCRIPT_DIR/java" "$BUILD" -name '*.java' -print0)
javac -source 11 -target 11 \
-classpath "$PLATFORM_JAR" \
-d "$BUILD/classes" \
"${JAVA_SRCS[@]}"
# ---- Dex ----
CLASS_FILES=()
while IFS= read -r -d '' f; do CLASS_FILES+=("$f"); done < <(find "$BUILD/classes" -name '*.class' -print0)
"$D8" --min-api 29 --output "$BUILD/dex" "${CLASS_FILES[@]}" --lib "$PLATFORM_JAR"
# ---- Add classes.dex into the APK ----
cp "$BUILD/base.apk" "$BUILD/unsigned.apk"
(cd "$BUILD/dex" && zip -q "$BUILD/unsigned.apk" classes.dex)
# ---- Align ----
"$ZIPALIGN" -f -p 4 "$BUILD/unsigned.apk" "$BUILD/aligned.apk"
# ---- Sign with debug key (auto-generated on first build) ----
KEYSTORE="$SCRIPT_DIR/debug.keystore"
if [ ! -f "$KEYSTORE" ]; then
echo "Generating debug keystore..."
keytool -genkeypair -v \
-keystore "$KEYSTORE" -storepass android -keypass android \
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Focus Status Debug, OU=Dev, O=Dev, L=NA, ST=NA, C=NA" \
>/dev/null 2>&1
fi
"$APKSIGNER" sign \
--ks "$KEYSTORE" --ks-pass pass:android \
--key-pass pass:android \
--out "$BUILD/focus_status.apk" \
"$BUILD/aligned.apk"
echo ""
echo "Built: $BUILD/focus_status.apk"
ls -l "$BUILD/focus_status.apk"

View File

@ -1,27 +0,0 @@
package com.kuhy.focusstatus;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/**
* Autostart the StatusService when the device finishes booting so the
* persistent notification reappears without the user having to launch
* the app manually. BOOT_COMPLETED is delivered after the user unlocks
* the phone for the first time (if Direct Boot is enabled we also
* handle LOCKED_BOOT_COMPLETED to fire earlier).
*/
public final class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (action == null) {
return;
}
if (!"android.intent.action.BOOT_COMPLETED".equals(action)
&& !"android.intent.action.LOCKED_BOOT_COMPLETED".equals(action)) {
return;
}
context.startForegroundService(new Intent(context, StatusService.class));
}
}

View File

@ -1,41 +0,0 @@
package com.kuhy.focusstatus;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/**
* Fired when the user taps the demo-curfew action on the status notification.
* Toggles the curfew FORCE file the daemon + enforcer poll, which makes the
* full curfew engage immediately regardless of clock or location ("demo"):
* - if the force file is absent, create it (and clear any override) -> demo ON;
* - if it is present, delete it -> demo OFF.
* Then writes the recheck trigger so the daemon re-evaluates within ~1s.
*
* Lets you experience the real curfew on demand with a one-tap off switch.
* Safe because the companion app, launcher and keyboard are night-whitelisted,
* so this notification stays tappable throughout the demo.
*/
public final class CurfewDemoReceiver extends BroadcastReceiver {
private static final String FORCE =
"/data/local/tmp/focus_mode/curfew_force_on";
private static final String OVERRIDE =
"/data/local/tmp/focus_mode/curfew_override";
private static final String TRIGGER =
"/data/local/tmp/focus_mode/trigger_recheck";
@Override
public void onReceive(Context context, Intent intent) {
// Toggle the force file atomically; starting a demo also clears any
// override (which would otherwise suppress the curfew). Always nudge
// the daemon afterwards.
RootShell.run(
"if [ -e " + FORCE + " ]; then rm -f " + FORCE + "; "
+ "else touch " + FORCE + " && chmod 666 " + FORCE
+ " && rm -f " + OVERRIDE + "; fi; "
+ "touch " + TRIGGER + " && chmod 666 " + TRIGGER);
Intent refresh = new Intent(context, StatusService.class);
context.startForegroundService(refresh);
}
}

View File

@ -1,39 +0,0 @@
package com.kuhy.focusstatus;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/**
* Fired when the user taps the curfew action on the status notification.
* Toggles the night-curfew escape-hatch file the daemon + enforcer poll:
* - if the override file is absent, create it -> curfew SUSPENDED;
* - if it is present, delete it -> curfew RE-ARMED.
* Then writes the recheck trigger so the daemon re-evaluates within ~1s and
* the app's next refresh reflects the new state.
*
* This is the on-device "2am opt-out" (no PC needed). It is intentionally the
* only easy way to suspend curfew; everything else is locked. The action is
* shown on the notification only while curfew is active, so it is not a
* day-time temptation.
*/
public final class CurfewToggleReceiver extends BroadcastReceiver {
private static final String OVERRIDE =
"/data/local/tmp/focus_mode/curfew_override";
private static final String TRIGGER =
"/data/local/tmp/focus_mode/trigger_recheck";
@Override
public void onReceive(Context context, Intent intent) {
// Toggle atomically in one root shell: if the file exists remove it,
// else create it world-writable. Always nudge the daemon afterwards.
RootShell.run(
"if [ -e " + OVERRIDE + " ]; then rm -f " + OVERRIDE + "; "
+ "else touch " + OVERRIDE + " && chmod 666 " + OVERRIDE + "; fi; "
+ "touch " + TRIGGER + " && chmod 666 " + TRIGGER);
// Immediate service refresh so the notification flips label/state now.
Intent refresh = new Intent(context, StatusService.class);
context.startForegroundService(refresh);
}
}

View File

@ -1,18 +0,0 @@
package com.kuhy.focusstatus;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
/**
* Tiny invisible activity so the app is launchable from the Minimalist
* Phone app list. Starts the foreground service and finishes immediately.
*/
public final class LaunchActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
startForegroundService(new Intent(this, StatusService.class));
finish();
}
}

View File

@ -1,32 +0,0 @@
package com.kuhy.focusstatus;
import android.app.NotificationManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/**
* Fired when the user taps the "Re-check now" notification action.
* Writes the trigger file the daemon polls; the daemon will break its
* sleep and perform a fresh location check within ~1 second.
*/
public final class RecheckReceiver extends BroadcastReceiver {
private static final String TRIGGER = "/data/local/tmp/focus_mode/trigger_recheck";
@Override
public void onReceive(Context context, Intent intent) {
RootShell.run("touch " + TRIGGER + " && chmod 666 " + TRIGGER);
// Cause an immediate service refresh so the notification reflects
// the new reading as soon as the daemon writes it.
Intent refresh = new Intent(context, StatusService.class);
context.startForegroundService(refresh);
NotificationManager nm =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm != null) {
// No separate toast-channel — just nudge the ongoing notif
// (next tick will show the updated "Last check" timestamp).
nm.cancel(9999);
}
}
}

View File

@ -1,49 +0,0 @@
package com.kuhy.focusstatus;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Locale;
/**
* Tiny root shell helper. Runs commands via Magisk's `su` binary and
* returns stdout. All IO is clamped so one misbehaving command cannot
* stall the notification UI loop indefinitely.
*/
final class RootShell {
private RootShell() {}
/** Run a one-shot root command and return its stdout (trimmed). */
static String run(String cmd) {
Process p = null;
try {
p = Runtime.getRuntime().exec(new String[] { "su", "-c", cmd });
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(p.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append('\n');
}
}
// Bound wait to avoid ANR when root is denied.
p.waitFor();
return sb.toString().trim();
} catch (IOException | InterruptedException e) {
return "";
} finally {
if (p != null) {
p.destroy();
}
}
}
/** Return true if a PID file contains a live process. */
static boolean pidAlive(String pidFilePath) {
String out = run(String.format(Locale.US,
"pid=$(cat %s 2>/dev/null); [ -n \"$pid\" ] && kill -0 \"$pid\" 2>/dev/null && echo yes",
pidFilePath));
return "yes".equals(out);
}
}

View File

@ -1,101 +0,0 @@
package com.kuhy.focusstatus;
/**
* Parsed snapshot of /data/local/tmp/focus_mode/status.json.
* Minimal hand-rolled JSON reader to avoid pulling in a library.
*/
final class Status {
String mode = "unknown";
String lat = "";
String lon = "";
long distanceM = -1;
long thresholdM = -1;
long radiusM = -1;
long disabledCount = 0;
long lastCheckTs = 0;
String lastCheckIso = "";
boolean curfewActive = false;
boolean curfewOverride = false;
boolean curfewForce = false;
boolean daemonAlive = false;
boolean hostsAlive = false;
boolean dnsAlive = false;
boolean launcherAlive = false;
boolean curfewAlive = false;
/** Extract a JSON string or numeric value by key. Returns "" if missing. */
static String extract(String json, String key) {
if (json == null) {
return "";
}
// Match either "key":"value" or "key":NUMBER or "key":null
String needle = "\"" + key + "\"";
int i = json.indexOf(needle);
if (i < 0) {
return "";
}
int j = json.indexOf(':', i + needle.length());
if (j < 0) {
return "";
}
int k = j + 1;
// Skip whitespace
while (k < json.length() && Character.isWhitespace(json.charAt(k))) {
k++;
}
if (k >= json.length()) {
return "";
}
if (json.charAt(k) == '"') {
int end = json.indexOf('"', k + 1);
if (end < 0) {
return "";
}
return json.substring(k + 1, end);
}
int end = k;
while (end < json.length()
&& "0123456789-.nul".indexOf(json.charAt(end)) >= 0) {
end++;
}
String v = json.substring(k, end);
return "null".equals(v) ? "" : v;
}
static Status parse(String json) {
Status s = new Status();
if (json == null || json.isEmpty()) {
return s;
}
s.mode = nonEmpty(extract(json, "mode"), "unknown");
s.lat = extract(json, "lat");
s.lon = extract(json, "lon");
s.distanceM = parseLongOr(extract(json, "distance_m"), -1);
s.thresholdM = parseLongOr(extract(json, "threshold_m"), -1);
s.radiusM = parseLongOr(extract(json, "radius_m"), -1);
s.disabledCount = parseLongOr(extract(json, "disabled_count"), 0);
s.curfewActive = parseLongOr(extract(json, "curfew"), 0) == 1;
s.curfewOverride = parseLongOr(extract(json, "curfew_override"), 0) == 1;
s.curfewForce = parseLongOr(extract(json, "curfew_force"), 0) == 1;
s.lastCheckTs = parseLongOr(extract(json, "last_check_ts"), 0);
s.lastCheckIso = extract(json, "last_check_iso");
return s;
}
private static String nonEmpty(String v, String fallback) {
return (v == null || v.isEmpty()) ? fallback : v;
}
private static long parseLongOr(String v, long fallback) {
if (v == null || v.isEmpty()) {
return fallback;
}
try {
return Long.parseLong(v);
} catch (NumberFormatException ignored) {
return fallback;
}
}
}

View File

@ -1,247 +0,0 @@
package com.kuhy.focusstatus;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
/**
* Foreground service that keeps a persistent notification showing the
* current state of the focus-mode daemon on the rooted phone. Re-reads
* /data/local/tmp/focus_mode/status.json via root shell every ~5s and
* updates the notification text. Notification carries a "Re-check now"
* action that broadcasts to RecheckReceiver.
*/
public final class StatusService extends Service {
private static final String CHANNEL_ID = "focus_status";
private static final int NOTIF_ID = 1042;
private static final long REFRESH_MS = 5_000L;
private static final String STATUS_FILE = "/data/local/tmp/focus_mode/status.json";
private static final String DAEMON_PID = "/data/local/tmp/focus_mode/daemon.pid";
private static final String HOSTS_PID = "/data/local/tmp/focus_mode/hosts_enforcer.pid";
private static final String DNS_PID = "/data/local/tmp/focus_mode/dns_enforcer.pid";
private static final String LAUNCHER_PID = "/data/local/tmp/focus_mode/launcher_enforcer.pid";
private static final String CURFEW_PID = "/data/local/tmp/focus_mode/curfew_enforcer.pid";
private Handler handler;
private final Runnable tick = new Runnable() {
@Override
public void run() {
refresh();
handler.postDelayed(this, REFRESH_MS);
}
};
@Override
public void onCreate() {
super.onCreate();
handler = new Handler(Looper.getMainLooper());
ensureChannel();
startForeground(NOTIF_ID, buildNotification(null));
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// Immediate refresh so the user's launch / action feels responsive.
handler.removeCallbacks(tick);
handler.post(tick);
return START_STICKY;
}
@Override
public void onDestroy() {
handler.removeCallbacks(tick);
super.onDestroy();
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private void refresh() {
String json = RootShell.run("cat " + STATUS_FILE + " 2>/dev/null");
Status s = Status.parse(json);
s.daemonAlive = RootShell.pidAlive(DAEMON_PID);
s.hostsAlive = RootShell.pidAlive(HOSTS_PID);
s.dnsAlive = RootShell.pidAlive(DNS_PID);
s.launcherAlive = RootShell.pidAlive(LAUNCHER_PID);
s.curfewAlive = RootShell.pidAlive(CURFEW_PID);
NotificationManager nm =
(NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (nm != null) {
nm.notify(NOTIF_ID, buildNotification(s));
}
}
private void ensureChannel() {
NotificationManager nm =
(NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (nm == null) {
return;
}
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "Focus Mode Status",
NotificationManager.IMPORTANCE_LOW);
ch.setDescription("Persistent status of the focus-mode daemon");
ch.setShowBadge(false);
ch.setSound(null, null);
ch.enableVibration(false);
nm.createNotificationChannel(ch);
}
private Notification buildNotification(Status s) {
String title;
String summary;
String big;
int icon;
if (s == null) {
title = "Focus Mode: starting...";
summary = "Reading status";
big = "Contacting root daemon\u2026";
icon = android.R.drawable.ic_menu_compass;
} else {
boolean focus = "focus".equals(s.mode);
title = focus ? "\uD83C\uDFE0 Focus: HOME" : "\u2708 Focus: AWAY";
if (!s.daemonAlive) {
title = "\u26A0\uFE0F Focus: DAEMON DOWN";
}
String dist = (s.distanceM < 0)
? "?"
: (s.distanceM + "m");
summary = "dist " + dist
+ " \u00B7 disabled " + s.disabledCount
+ " \u00B7 " + shortTime(s.lastCheckIso);
big = buildBigText(s);
icon = focus
? android.R.drawable.ic_lock_idle_lock
: android.R.drawable.ic_menu_mylocation;
}
PendingIntent recheck = PendingIntent.getBroadcast(
this, 0,
new Intent(this, RecheckReceiver.class)
.setAction("com.kuhy.focusstatus.RECHECK"),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Notification.Builder b = new Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(icon)
.setContentTitle(title)
.setContentText(summary)
.setStyle(new Notification.BigTextStyle().bigText(big))
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setCategory(Notification.CATEGORY_STATUS)
.setVisibility(Notification.VISIBILITY_PUBLIC)
.addAction(new Notification.Action.Builder(
android.R.drawable.ic_popup_sync,
"Re-check now", recheck).build());
// One contextual curfew action:
// - demo running -> Stop demo curfew (force toggle)
// - real curfew active -> Suspend till morning (override toggle)
// - override set, idle -> Re-arm curfew (override toggle)
// - idle / daytime -> Start demo curfew (force toggle)
// Demo lets you experience the full curfew on demand with a one-tap
// off switch; the companion app/launcher/keyboard stay whitelisted so
// the notification is always reachable to stop it.
if (s != null) {
String label;
int actionIcon;
PendingIntent pi;
if (s.curfewForce) {
label = "Stop demo curfew";
actionIcon = android.R.drawable.ic_menu_close_clear_cancel;
pi = demoIntent();
} else if (s.curfewActive) {
label = "Suspend curfew till morning";
actionIcon = android.R.drawable.ic_menu_close_clear_cancel;
pi = overrideIntent();
} else if (s.curfewOverride) {
label = "Re-arm curfew";
actionIcon = android.R.drawable.ic_lock_idle_lock;
pi = overrideIntent();
} else {
label = "Start demo curfew";
actionIcon = android.R.drawable.ic_media_play;
pi = demoIntent();
}
b.addAction(new Notification.Action.Builder(actionIcon, label, pi).build());
}
return b.build();
}
private PendingIntent overrideIntent() {
return PendingIntent.getBroadcast(this, 1,
new Intent(this, CurfewToggleReceiver.class)
.setAction("com.kuhy.focusstatus.CURFEW_TOGGLE"),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
private PendingIntent demoIntent() {
return PendingIntent.getBroadcast(this, 2,
new Intent(this, CurfewDemoReceiver.class)
.setAction("com.kuhy.focusstatus.CURFEW_DEMO"),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
private static String buildBigText(Status s) {
StringBuilder sb = new StringBuilder();
if ("focus".equals(s.mode)) {
sb.append("At home \u2014 restrictions active\n");
} else if ("normal".equals(s.mode)) {
sb.append("Away from home \u2014 normal mode\n");
} else {
sb.append("Mode: ").append(s.mode).append('\n');
}
if (s.distanceM >= 0) {
sb.append("Distance: ").append(s.distanceM).append('m');
if (s.thresholdM >= 0) {
sb.append(" (threshold ").append(s.thresholdM).append("m)");
}
sb.append('\n');
}
if (!s.lat.isEmpty() && !s.lon.isEmpty()) {
sb.append("GPS: ").append(s.lat).append(", ").append(s.lon).append('\n');
}
sb.append("Disabled apps: ").append(s.disabledCount).append('\n');
if (s.curfewForce) {
sb.append("Demo curfew: RUNNING — strict list, grayscale, DND (tap Stop)\n");
} else if (s.curfewOverride) {
sb.append("Night curfew: SUSPENDED (tap to re-arm)\n");
} else if (s.curfewActive) {
sb.append("Night curfew: ACTIVE — strict list, grayscale, DND\n");
}
sb.append("Last check: ").append(
s.lastCheckIso.isEmpty() ? "never" : s.lastCheckIso).append('\n');
sb.append("Daemons: ")
.append(tag("focus", s.daemonAlive)).append(' ')
.append(tag("hosts", s.hostsAlive)).append(' ')
.append(tag("dns", s.dnsAlive)).append(' ')
.append(tag("launcher", s.launcherAlive)).append(' ')
.append(tag("curfew", s.curfewAlive));
return sb.toString();
}
private static String tag(String name, boolean ok) {
return (ok ? "\u2713" : "\u2717") + name;
}
private static String shortTime(String iso) {
// Expect "YYYY-MM-DD HH:MM:SS"; show HH:MM:SS.
if (iso == null || iso.length() < 19) {
return iso == null ? "" : iso;
}
return iso.substring(11, 19);
}
}

View File

@ -1,207 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# Hosts file enforcer for rooted Android.
#
# Mirrors the PC-side guard in linux_configuration/hosts/ but
# for /system/etc/hosts on Android, which has no chattr, no
# systemd, and where /system is read-only.
#
# Strategy (defense in depth):
# 1. Canonical hosts file lives at HOSTS_CANONICAL and is
# chattr +i (best-effort; ignored if kernel/fs rejects).
# 2. Bind-mount HOSTS_CANONICAL read-only over HOSTS_TARGET so
# that even `echo > /system/etc/hosts` fails for everyone,
# including root-in-a-terminal-app, without re-mounting.
# 3. A watchdog loop re-asserts the bind mount and verifies
# sha256 every HOSTS_CHECK_INTERVAL seconds.
#
# Known limitation: a user with root *and* willingness to run
# `umount /system/etc/hosts; mount -o remount,rw /system ...`
# can still bypass this. Making it "impossible without USB" is
# not achievable on a rooted phone with a local terminal.
# This enforcer closes the one-liner gap and adds logging so
# tampering leaves an audit trail.
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/config.sh"
# shellcheck source=hosts_magisk.sh
. "$SCRIPT_DIR/hosts_magisk.sh"
# shellcheck source=hosts_mount.sh
. "$SCRIPT_DIR/hosts_mount.sh"
PIDFILE="$STATE_DIR/hosts_enforcer.pid"
mkdir -p "$STATE_DIR" "$(dirname "$HOSTS_CANONICAL")"
touch "$HOSTS_LOG"
chmod 666 "$HOSTS_LOG" 2>/dev/null || true
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >> "$HOSTS_LOG"
}
rotate_log() {
local lines
lines="$(wc -l < "$HOSTS_LOG" 2>/dev/null || echo 0)"
if [ "$lines" -gt 500 ]; then
local tmp="$HOSTS_LOG.tmp"
tail -n 500 "$HOSTS_LOG" > "$tmp"
mv "$tmp" "$HOSTS_LOG"
fi
}
acquire_lock() {
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
local cmdline
cmdline="$(tr '\0' ' ' < "/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "hosts_enforcer"; then
echo "hosts_enforcer already running (PID $old_pid)"
exit 0
fi
fi
rm -f "$PIDFILE"
fi
echo $$ > "$PIDFILE"
}
sha256_of() {
# Android's toybox has sha256sum; fall back to md5sum if missing.
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" 2>/dev/null | awk '{print $1}'
else
md5sum "$1" 2>/dev/null | awk '{print $1}'
fi
}
# ---- Workout-aware canonical selection ----
# When workout_detector.sh writes "1" to $WORKOUT_ACTIVE_FILE, switch to
# the YouTube-relaxed canonical. Any other value (including missing file or
# unreadable) falls back to the full-block canonical (fail-closed).
workout_active() {
[ -f "$WORKOUT_ACTIVE_FILE" ] || return 1
local v
v="$(tr -d '[:space:]' < "$WORKOUT_ACTIVE_FILE" 2>/dev/null)"
[ "$v" = "1" ]
}
current_canonical() {
if workout_active && [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then
echo "$HOSTS_CANONICAL_WORKOUT"
else
echo "$HOSTS_CANONICAL"
fi
}
current_sha_file() {
if workout_active && [ -f "$HOSTS_SHA_FILE_WORKOUT" ]; then
echo "$HOSTS_SHA_FILE_WORKOUT"
else
echo "$HOSTS_SHA_FILE"
fi
}
verify_and_restore() {
local canonical sha_file
canonical="$(current_canonical)"
sha_file="$(current_sha_file)"
if [ ! -f "$canonical" ]; then
log "ERROR: canonical hosts missing at $canonical"
return 1
fi
local expected
expected="$(cat "$sha_file" 2>/dev/null)"
if [ -z "$expected" ]; then
expected="$(sha256_of "$canonical")"
echo "$expected" > "$sha_file"
chmod 644 "$sha_file" 2>/dev/null || true
chattr +i "$sha_file" 2>/dev/null || true
fi
# Canonical integrity check
local actual_canonical
actual_canonical="$(sha256_of "$canonical")"
if [ "$actual_canonical" != "$expected" ]; then
log "TAMPER: $(basename "$canonical") hash mismatch (expected $expected, got $actual_canonical)"
# We cannot fix the canonical from here - it is the source of truth.
# Just log and continue; deploy.sh must re-push.
return 1
fi
# Live target integrity check. Mismatch can mean either tampering OR a
# legitimate workout-state transition that swapped the active canonical.
# In both cases the fix is the same: re-assert the bind mount with the
# currently-active canonical.
local actual_target
actual_target="$(sha256_of "$HOSTS_TARGET")"
if [ "$actual_target" != "$expected" ]; then
if workout_active; then
log "Workout-active swap: $HOSTS_TARGET differs from workout canonical - re-mounting"
else
log "TAMPER or post-workout swap: $HOSTS_TARGET hash mismatch - restoring"
fi
assert_bind_mount
flush_browser_dns_caches
fi
}
cleanup() {
log "hosts_enforcer shutting down"
rm -f "$PIDFILE"
exit 0
}
trap cleanup INT TERM
main() {
acquire_lock
log "hosts_enforcer started (PID=$$)"
ensure_canonical_immutable
protect_magisk_module
# Initial assertion
assert_bind_mount || true
# Restart netd so its in-memory hosts cache picks up the bind mount.
# Android 13 caches /etc/hosts at netd startup and never re-reads it;
# without this restart every DNS query bypasses our block list.
restart_netd_for_hosts_cache
flush_browser_dns_caches
# Seed sha files if missing — one per canonical variant.
if [ ! -f "$HOSTS_SHA_FILE" ] && [ -f "$HOSTS_CANONICAL" ]; then
sha256_of "$HOSTS_CANONICAL" > "$HOSTS_SHA_FILE"
chmod 644 "$HOSTS_SHA_FILE" 2>/dev/null || true
chattr +i "$HOSTS_SHA_FILE" 2>/dev/null || true
fi
if [ ! -f "$HOSTS_SHA_FILE_WORKOUT" ] && [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then
sha256_of "$HOSTS_CANONICAL_WORKOUT" > "$HOSTS_SHA_FILE_WORKOUT"
chmod 644 "$HOSTS_SHA_FILE_WORKOUT" 2>/dev/null || true
chattr +i "$HOSTS_SHA_FILE_WORKOUT" 2>/dev/null || true
fi
while true; do
verify_and_restore
protect_magisk_module
rotate_log
sleep "$HOSTS_CHECK_INTERVAL"
done
}
main "$@"

View File

@ -1,166 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# hosts_magisk.sh — the persistence and cache-invalidation half of
# hosts_enforcer.sh: keeping the Magisk systemless-hosts module in step with
# the canonical blocklist, holding the canonical file immutable, and making
# already-running resolvers notice a change (Firefox DoH, browser DNS caches,
# netd).
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one directory,
# which is why dns_iptables.sh, tether_iptables.sh and curfew_net.sh live
# beside their enforcers too.
#
# Sourced by hosts_enforcer.sh, which owns log() and the config.sh globals
# these read ($HOSTS_MAGISK_MODULE_FILE, $HOSTS_CANONICAL,
# $HOSTS_CANONICAL_WORKOUT, $BROWSER_PACKAGES, $STATE_DIR). Nothing is
# assigned here, so there is no global to keep in step.
# Keep the Magisk Systemless Hosts module file in sync with the currently
# active canonical so that a future reboot mounts the correct variant. We
# only rewrite when the contents differ (cheap hash compare) to avoid
# touching the module dir on every loop iteration.
sync_magisk_module() {
local canonical="$1"
[ -n "$canonical" ] && [ -f "$canonical" ] || return 0
local module_dir
module_dir="$(dirname "$(dirname "$(dirname "$HOSTS_MAGISK_MODULE_FILE")")")"
[ -d "$module_dir" ] || return 0
local module_hash canonical_hash
module_hash="$(sha256_of "$HOSTS_MAGISK_MODULE_FILE")"
canonical_hash="$(sha256_of "$canonical")"
if [ "$module_hash" != "$canonical_hash" ]; then
# Drop +i on the module dir + file long enough to update, then re-lock.
chattr -i "$module_dir" 2>/dev/null || true
chattr -i "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true
cp "$canonical" "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || return 0
chmod 644 "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true
chattr +i "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true
log "Synced Magisk module hosts to $(basename "$canonical")"
fi
# Always re-assert dir-level lock at the end so a partial earlier failure
# leaves us in the locked state on the next iteration.
protect_magisk_module
}
# Defense against the user disabling the Magisk Systemless Hosts module via
# the Magisk app UI. The "Disable" / "Remove" buttons work by creating a
# marker file inside the module directory:
#
# /data/adb/modules/hosts/disable # disable on next reboot
# /data/adb/modules/hosts/remove # uninstall on next reboot
#
# We do TWO things every poll cycle:
# 1. Delete those markers if they appeared (so a reboot still loads us)
# 2. Set chattr +i on the module directory itself so the Magisk app cannot
# create those markers in the first place. The +i flag on a directory
# blocks adding/removing/renaming entries inside it, which is exactly
# what `touch disable` does. Files already inside remain mutable, so
# sync_magisk_module() can still rewrite the hosts file (it briefly
# drops the lock above to handle the rare case where it must).
#
# To intentionally disable the module for maintenance, run from a root
# shell on the phone:
# focus_ctl.sh hosts-stop
# chattr -i /data/adb/modules/hosts
# touch /data/adb/modules/hosts/disable
protect_magisk_module() {
local module_dir
module_dir="$(dirname "$(dirname "$(dirname "$HOSTS_MAGISK_MODULE_FILE")")")"
[ -d "$module_dir" ] || return 0
# Step 1: nuke any disable/remove markers that may have been created
# since the last poll. We have to chattr -i first because either of the
# two locks below may already be in effect.
local removed=0
for marker in disable remove update; do
local f="$module_dir/$marker"
if [ -e "$f" ]; then
chattr -i "$module_dir" 2>/dev/null || true
chattr -i "$f" 2>/dev/null || true
if rm -f "$f" 2>/dev/null; then
removed=$((removed + 1))
log "TAMPER: removed Magisk module marker $f"
fi
fi
done
# Step 2: re-lock the module dir so the Magisk app cannot recreate them
# via its UI. Best-effort - if the kernel/fs rejects +i, the runtime
# delete loop above is still our safety net.
chattr +i "$module_dir" 2>/dev/null || true
return $removed
}
# Write user.js to every Firefox profile to hard-disable DNS-over-HTTPS.
# Firefox uses hardcoded Cloudflare bootstrap IPs (104.16.248.249 etc.) to
# reach mozilla.cloudflare-dns.com, bypassing /etc/hosts entirely.
# TRR mode 5 = DoH disabled; the pref is re-applied on every flush so it
# survives Firefox's automatic pref-reset logic.
disable_firefox_doh() {
local profile_dir
for profile_dir in /data/data/org.mozilla.fenix/files/mozilla/*/; do
# Only write to real profile directories (they contain prefs.js).
[ -f "${profile_dir}prefs.js" ] || continue
grep -qF '"network.trr.mode"' "${profile_dir}user.js" 2>/dev/null \
|| { printf 'user_pref("network.trr.mode", 5);\n' >> "${profile_dir}user.js" 2>/dev/null \
&& log "Wrote DoH-disable pref to ${profile_dir}user.js"; }
done
}
# Force-stop browsers so their in-process DNS caches are cleared.
# Apps like Firefox and Chrome cache resolved IPs internally; without
# a fresh start they continue reaching blocked domains despite hosts.
# Called at daemon startup and after every detected restore/tamper.
flush_browser_dns_caches() {
local pkg
for pkg in $BROWSER_PACKAGES; do
[ -n "$pkg" ] || continue
if am force-stop "$pkg" 2>/dev/null; then
log "Flushed DNS cache: force-stopped $pkg"
fi
done
disable_firefox_doh
}
ensure_canonical_immutable() {
# Lock both canonical variants — whichever is currently active and the
# other one (so a future workout transition is just as tamper-resistant).
chmod 644 "$HOSTS_CANONICAL" 2>/dev/null || true
chattr +i "$HOSTS_CANONICAL" 2>/dev/null || true
if [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then
chmod 644 "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null || true
chattr +i "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null || true
fi
}
# Restart netd so it re-reads the bind-mounted hosts file from disk.
# Android 13's DNS resolver (libnetd_resolv.so) caches /etc/hosts entirely
# in memory when netd starts. Our bind mount updates the on-disk file but
# netd's in-memory cache stays stale until netd restarts.
#
# We use a PID-stamp file: if netd's PID hasn't changed since our last
# restart, we already restarted it in this boot session and skip the work.
# This avoids a network blip on every enforcer restart, while still
# triggering a reload if netd itself has been cycled.
restart_netd_for_hosts_cache() {
local stamp_file="$STATE_DIR/netd_restart.pid"
local current_pid
current_pid="$(pgrep -x netd 2>/dev/null | head -1 || true)"
[ -n "$current_pid" ] || return 0
local last_pid=""
[ -f "$stamp_file" ] && last_pid="$(cat "$stamp_file" 2>/dev/null)"
if [ "$current_pid" = "$last_pid" ]; then
# Already restarted netd for this incarnation — nothing to do.
return 0
fi
log "Restarting netd (PID $current_pid) to reload hosts file cache (~3s network pause)..."
stop netd 2>/dev/null || true
sleep 2
start netd 2>/dev/null || true
sleep 2
local new_pid
new_pid="$(pgrep -x netd 2>/dev/null | head -1 || true)"
echo "${new_pid:-$current_pid}" > "$stamp_file" 2>/dev/null || true
log "netd restarted (new PID ${new_pid:-unknown}) — hosts cache is now live"
}

View File

@ -1,91 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# hosts_mount.sh — the bind-mount half of hosts_enforcer.sh: proving the
# blocklist is actually mounted over $HOSTS_TARGET, clearing a stale or wrong
# mount, and making the target writable once on ROMs that ship it read-only.
#
# A flat sibling, not lib/, for the same reason as hosts_magisk.sh: deploy.sh
# pushes phone scripts into one directory.
#
# Sourced by hosts_enforcer.sh, which owns log() and $HOSTS_TARGET, the only
# config.sh global these read. Nothing is assigned here.
is_bind_mounted_correctly() {
# Android devices often already have /system/etc/hosts as its own mount
# point (OEM overlay / f2fs block). A mere "path is in /proc/self/mounts"
# check is not enough - we must verify the mounted content matches our
# currently-active canonical by hash (which depends on workout state).
# No early -f guard on the target: sha256_of returns empty for a file
# that does not exist, so the -n check below already rejects that case.
# The guard was provably equivalent — a mutation removing it could not be
# distinguished by any test — and equivalent code is a claim of a check
# that is not actually being made.
local target_hash canonical_hash canonical
canonical="$(current_canonical)"
target_hash="$(sha256_of "$HOSTS_TARGET")"
canonical_hash="$(sha256_of "$canonical")"
[ -n "$target_hash" ] && [ "$target_hash" = "$canonical_hash" ]
}
unmount_existing_hosts_mount() {
# If anything else is already mounted on /system/etc/hosts (OEM overlay
# or a previous failed bind), unmount it so we can take its place.
local attempts=0
while grep -qE "[[:space:]]${HOSTS_TARGET}[[:space:]]" /proc/self/mounts 2>/dev/null; do
if [ "$attempts" -ge 5 ]; then
log "Could not fully unmount $HOSTS_TARGET after 5 attempts"
return 1
fi
umount "$HOSTS_TARGET" 2>/dev/null \
|| umount -l "$HOSTS_TARGET" 2>/dev/null \
|| break
attempts=$((attempts + 1))
done
return 0
}
make_target_writable_once() {
# /system is usually mounted read-only. Make it rw just long enough
# to overwrite HOSTS_TARGET with the canonical content, then remount ro.
local system_mount canonical
canonical="$(current_canonical)"
system_mount="$(awk '$2=="/system"{print $2; exit}' /proc/self/mounts)"
if [ -z "$system_mount" ]; then
system_mount="/system"
fi
mount -o remount,rw "$system_mount" 2>/dev/null || true
chattr -i "$HOSTS_TARGET" 2>/dev/null || true
cp "$canonical" "$HOSTS_TARGET" 2>/dev/null || true
chmod 644 "$HOSTS_TARGET" 2>/dev/null || true
chattr +i "$HOSTS_TARGET" 2>/dev/null || true
mount -o remount,ro "$system_mount" 2>/dev/null || true
}
assert_bind_mount() {
if is_bind_mounted_correctly; then
return 0
fi
# Something is in the way (OEM overlay or previous partial mount).
unmount_existing_hosts_mount
local canonical
canonical="$(current_canonical)"
# Try plain bind mount - no remount-rw of /system needed.
if mount --bind "$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 $canonical over $HOSTS_TARGET"
sync_magisk_module "$canonical"
return 0
fi
log "Bind mount reported success but target still mismatches - unmounting"
umount "$HOSTS_TARGET" 2>/dev/null || true
fi
# Bind failed - fall back to direct overwrite of /system/etc/hosts.
log "Bind mount failed - falling back to direct overwrite"
make_target_writable_once
if is_bind_mounted_correctly; then
sync_magisk_module "$canonical"
return 0
fi
return 1
}

View File

@ -1,179 +0,0 @@
#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# Launcher enforcer for rooted Android.
#
# Goal:
# 1. Keep $LAUNCHER_PACKAGE installed at all times. If it is
# uninstalled (with or without `-k`), reinstall from
# $LAUNCHER_APK snapshot within $LAUNCHER_CHECK_INTERVAL.
# 2. Keep it pinned as the default HOME activity. If the user
# switches launchers via Settings or the picker, restore it.
# 3. Prevent competing launchers ($LAUNCHER_COMPETITORS) from
# being offered by `pm disable-user`-ing them.
#
# Known limitation: a user with root in a terminal can still
# stop this daemon and change HOME. That's the same threat model
# as hosts_enforcer.sh - this closes the "tap to uninstall / pick
# a new launcher" gap and leaves a tamper trail in the log.
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=config.sh
. "$SCRIPT_DIR/config.sh"
PIDFILE="$STATE_DIR/launcher_enforcer.pid"
# Tracks competitors we disabled ourselves, so `launcher-stop` can undo them.
DISABLED_COMPETITORS_FILE="$STATE_DIR/disabled_competitors.txt"
mkdir -p "$STATE_DIR" "$(dirname "$LAUNCHER_APK")"
touch "$LAUNCHER_LOG" "$DISABLED_COMPETITORS_FILE"
chmod 666 "$LAUNCHER_LOG" "$DISABLED_COMPETITORS_FILE" 2>/dev/null || true
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
echo "[$ts] $1" >> "$LAUNCHER_LOG"
}
rotate_log() {
local lines
lines="$(wc -l < "$LAUNCHER_LOG" 2>/dev/null || echo 0)"
if [ "$lines" -gt 500 ]; then
local tmp="$LAUNCHER_LOG.tmp"
tail -n 500 "$LAUNCHER_LOG" > "$tmp"
mv "$tmp" "$LAUNCHER_LOG"
fi
}
acquire_lock() {
if [ -f "$PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PIDFILE")"
if kill -0 "$old_pid" 2>/dev/null; then
local cmdline
cmdline="$(tr '\0' ' ' < "/proc/$old_pid/cmdline" 2>/dev/null)"
if echo "$cmdline" | grep -q "launcher_enforcer"; then
echo "launcher_enforcer already running (PID $old_pid)"
exit 0
fi
fi
rm -f "$PIDFILE"
fi
echo $$ > "$PIDFILE"
}
# ---- Package state helpers ----
pkg_installed() {
# `pm path` exits 0 and prints package: line when present.
pm path "$1" >/dev/null 2>&1
}
pkg_enabled() {
# `pm list packages -e` lists enabled packages. Use exact match.
pm list packages -e 2>/dev/null | grep -qxE "package:$1"
}
current_home_component() {
# Returns "pkg/Activity" of the current default HOME, or "" if ambiguous.
cmd package resolve-activity --brief -c android.intent.category.HOME \
-a android.intent.action.MAIN 2>/dev/null \
| awk 'NR==2{print}'
}
# ---- Enforcement actions ----
reinstall_launcher() {
if [ ! -f "$LAUNCHER_APK" ]; then
log "ERROR: cannot reinstall - APK snapshot missing at $LAUNCHER_APK"
return 1
fi
local expected actual
expected="$(cat "$LAUNCHER_SHA_FILE" 2>/dev/null)"
actual="$(sha256sum "$LAUNCHER_APK" 2>/dev/null | awk '{print $1}')"
if [ -n "$expected" ] && [ "$expected" != "$actual" ]; then
log "ERROR: APK snapshot hash mismatch (expected $expected, got $actual) - refusing to install"
return 1
fi
log "REINSTALL: $LAUNCHER_PACKAGE missing - installing from snapshot"
# -g grants all runtime permissions so the launcher starts clean
# without blocking on a permission dialog that itself may need the
# launcher to be usable.
if pm install -r -g "$LAUNCHER_APK" >/dev/null 2>&1; then
log "REINSTALL: $LAUNCHER_PACKAGE installed successfully"
return 0
fi
# Fallback: `pm install` without -g on older Androids
if pm install -r "$LAUNCHER_APK" >/dev/null 2>&1; then
log "REINSTALL: $LAUNCHER_PACKAGE installed (without -g)"
return 0
fi
log "ERROR: pm install failed"
return 1
}
ensure_home_pinned() {
local desired actual
desired="$(cat "$LAUNCHER_ACTIVITY_FILE" 2>/dev/null)"
if [ -z "$desired" ]; then
return 0 # not armed yet; deploy.sh --snapshot-launcher writes this
fi
actual="$(current_home_component)"
if [ "$actual" = "$desired" ]; then
return 0
fi
log "HOME: default is '$actual' not '$desired' - restoring"
cmd package set-home-activity "$desired" >/dev/null 2>&1 || \
log "ERROR: set-home-activity failed for $desired"
}
disable_competitors() {
# Disable every competitor that is still enabled. Remember what we
# disabled so `launcher-stop` can re-enable.
echo "$LAUNCHER_COMPETITORS" | while read -r pkg; do
[ -z "$pkg" ] && continue
[ "${pkg#\#}" != "$pkg" ] && continue # skip comments
if pkg_installed "$pkg" && pkg_enabled "$pkg"; then
if pm disable-user --user 0 "$pkg" >/dev/null 2>&1; then
log "Disabled competing launcher: $pkg"
grep -qxE "$pkg" "$DISABLED_COMPETITORS_FILE" 2>/dev/null \
|| echo "$pkg" >> "$DISABLED_COMPETITORS_FILE"
fi
fi
done
}
# ---- Main loop ----
cleanup() {
log "launcher_enforcer shutting down"
rm -f "$PIDFILE"
exit 0
}
trap cleanup INT TERM
main() {
acquire_lock
log "launcher_enforcer started (PID=$$)"
# Initial arm-up
if ! pkg_installed "$LAUNCHER_PACKAGE"; then
reinstall_launcher || true
fi
ensure_home_pinned
disable_competitors
while true; do
if ! pkg_installed "$LAUNCHER_PACKAGE"; then
reinstall_launcher || true
fi
ensure_home_pinned
disable_competitors
rotate_log
sleep "$LAUNCHER_CHECK_INTERVAL"
done
}
main "$@"

View File

@ -1,160 +0,0 @@
#!/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"
# One file per enrolled device, so a second phone does not evict the first.
# The legacy single-device record above is still honoured when present.
TRUSTED_DEVICE_DIR="${_PHONE_STATE_DIR}/trusted_devices"
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 }'
}
# Reads one enrolled-device record into TRUSTED_*_LOADED.
# Takes the path explicitly, defaulting to the legacy single-device record.
# Callers that need a specific record pass it rather than shadowing the global,
# which kept the value from leaking into later calls without relying on a
# `local` of a name declared in another file.
_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
}
# The enrolled-device record. Sourced from this file's own directory so every
# existing consumer keeps getting the whole API from one source line.
# shellcheck source=adb_trusted.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/adb_trusted.sh"
# Run lock and cooldown helpers. Sourced last, from this file's own directory,
# so every existing consumer keeps getting the whole API from one source line.
# shellcheck source=adb_locking.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/adb_locking.sh"

View File

@ -1,76 +0,0 @@
#!/usr/bin/env bash
# lib/adb_locking.sh — single-instance run lock and per-marker cooldowns.
# Sourced by adb_common.sh; do not execute directly, and do not source it
# on its own: it reads LOCK_DIR, LAST_RUN_DIR and LOCK_FILE, and calls the
# _info/_warn/_fatal helpers, all of which adb_common.sh defines first.
_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}"
}

View File

@ -1,135 +0,0 @@
#!/usr/bin/env bash
# lib/adb_trusted.sh — the enrolled-device record: saving, listing, forgetting,
# and refusing to act on a phone that is not enrolled.
# Sourced by adb_common.sh; do not execute directly, and do not source it on
# its own: it uses TRUSTED_DEVICE_DIR/TRUSTED_DEVICE_FILE, the DEVICE_* vars and
# the _info/_warn/_error/_fatal helpers that adb_common.sh defines first.
ADB_ENROLLED_DEVICE_FILE=""
# Path of the per-device record for a serial. Serials are sanitised to
# [A-Za-z0-9._-] by _sanitize_device_string, so this cannot escape the dir.
_trusted_device_path() {
printf '%s/%s.sh' "${TRUSTED_DEVICE_DIR}" "$1"
}
adb_save_trusted_device() {
local record=""
adb_collect_identity
mkdir -p "${TRUSTED_DEVICE_DIR}"
chmod 700 "${TRUSTED_DEVICE_DIR}"
record="$(_trusted_device_path "${DEVICE_SERIAL}")"
{
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}"
} >"${record}"
chmod 600 "${record}"
ADB_ENROLLED_DEVICE_FILE="${record}"
export ADB_ENROLLED_DEVICE_FILE
_info "Enrolled device: model='${DEVICE_MODEL}' serial='${DEVICE_SERIAL}'"
}
# Serials with a per-device record, newest first is not meaningful here so
# the order is whatever the glob yields.
adb_list_trusted_serials() {
local record=""
[[ -d "${TRUSTED_DEVICE_DIR}" ]] || return 0
for record in "${TRUSTED_DEVICE_DIR}"/*.sh; do
[[ -e "${record}" ]] || continue
basename "${record}" .sh
done
}
adb_forget_trusted_device() {
local serial="${1:?serial required}"
local record=""
record="$(_trusted_device_path "$(_sanitize_device_string "${serial}")")"
if [[ -f "${record}" ]]; then
rm -f "${record}"
_info "Removed trusted device record for '${serial}'"
else
_warn "No trusted device record for '${serial}'"
fi
}
# Compares the connected device against one record. Returns 0 on a match,
# 1 when the record is for a different device, and 2 when the record names
# this serial but its model or fingerprint has changed -- which is the case
# worth shouting about, since a serial is the easiest field to spoof and a
# changed fingerprint means the device was reflashed.
_matches_trusted_record() {
local record="${1:?record required}"
local saved_serial="" saved_model="" saved_fingerprint=""
# Passed explicitly rather than shadowing the global, so a per-record value
# cannot change which file later callers read.
if ! _load_trusted_device_values "${record}"; then
_fatal "Trusted device record exists but could not be read: ${record}"
fi
saved_serial="${TRUSTED_SERIAL_LOADED:-}"
saved_model="${TRUSTED_MODEL_LOADED:-}"
saved_fingerprint="${TRUSTED_FINGERPRINT_LOADED:-}"
[[ -n "${saved_serial}" && "${DEVICE_SERIAL}" != "${saved_serial}" ]] && return 1
if [[ -n "${saved_model}" && "${DEVICE_MODEL}" != "${saved_model}" ]]; then
_error "Device '${DEVICE_SERIAL}' is enrolled but its model changed: expected '${saved_model}', got '${DEVICE_MODEL}'."
return 2
fi
if [[ -n "${saved_fingerprint}" && "${DEVICE_FINGERPRINT}" != "${saved_fingerprint}" ]]; then
_error "Device '${DEVICE_SERIAL}' is enrolled but its build fingerprint changed."
_error " expected: ${saved_fingerprint}"
_error " got: ${DEVICE_FINGERPRINT}"
return 2
fi
return 0
}
# Fails closed: an unknown device aborts. Any ENROLLED device is accepted,
# so several phones can be managed from one PC without re-enrolling on
# every switch. Only a completely empty store is permissive, and that is
# the first-run case where there is nothing yet to contradict.
adb_verify_trusted_identity() {
local record="" rc=0
local -a records=()
if [[ -d "${TRUSTED_DEVICE_DIR}" ]]; then
for record in "${TRUSTED_DEVICE_DIR}"/*.sh; do
[[ -e "${record}" ]] && records+=("${record}")
done
fi
# Legacy single-device record stays authoritative until it is migrated,
# so an existing install keeps its guarantee without any manual step.
[[ -f "${_PHONE_STATE_DIR}/trusted_device.sh" ]] &&
records+=("${_PHONE_STATE_DIR}/trusted_device.sh")
if [[ ${#records[@]} -eq 0 ]]; then
_warn "No trusted device record found. Run 'fresh-phone' to enroll this device."
return 0
fi
adb_collect_identity
for record in "${records[@]}"; do
_matches_trusted_record "${record}" && {
_info "Device identity verified: ${DEVICE_SERIAL} (${DEVICE_MODEL})"
return 0
}
rc=$?
# A tampered/reflashed match must abort rather than fall through to
# the next record, which would silently downgrade the check.
[[ "${rc}" -eq 2 ]] && _fatal "Refusing to proceed automatically."
done
_error "Device '${DEVICE_SERIAL}' (${DEVICE_MODEL}) is not enrolled."
_error "Enrolled: $(adb_list_trusted_serials | tr '\n' ' ')"
_fatal "Refusing to proceed automatically. Enroll it first if this is expected."
}

View File

@ -1,220 +0,0 @@
#!/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}"
}

View File

@ -1,119 +0,0 @@
#!/usr/bin/env bash
# lib/backup_capture.sh — the capture half of phone_backup.sh: package lists,
# third-party APKs, shared-storage directories, settings dumps and the
# focus-mode state. Each function is handed the output directory to fill.
#
# Sourced by phone_backup.sh, which owns adb_common.sh (adb_cmd, adb_shell,
# _info, _warn). SDCARD_DIRS lives here rather than in the entry script
# because pull_sdcard is its only reader, and a file must not assign a global
# it never reads (SC2034) — shellcheck runs without -x, so each file stands
# alone.
# Directories pulled wholesale from shared storage. Anything not
# listed here is not backed up, so keep the list honest.
# Written on one line rather than one entry per line: kcov instruments a
# multi-line literal's continuation lines but bash only ever reports the
# statement at its first line, so a list spanning N lines is permanently
# stuck at 1/N covered however thoroughly it is tested.
readonly SDCARD_DIRS=(DCIM Pictures Download Documents Movies Music Signal RunnerUp)
dump_packages() {
local dir="$1"
adb_cmd shell pm list packages | sed 's/^package://' | tr -d '\r' |
sort >"${dir}/packages-all.txt"
adb_cmd shell pm list packages -3 | sed 's/^package://' | tr -d '\r' |
sort >"${dir}/packages-third-party.txt"
# Packages removed for user 0 read as uninstalled here; recorded
# separately so a restore knows what was deliberately purged.
adb_cmd shell pm list packages -u | sed 's/^package://' | tr -d '\r' |
sort >"${dir}/packages-including-removed.txt"
comm -13 "${dir}/packages-all.txt" "${dir}/packages-including-removed.txt" \
>"${dir}/packages-removed-for-user0.txt"
_info "Packages: $(wc -l <"${dir}/packages-third-party.txt") third-party, $(wc -l <"${dir}/packages-removed-for-user0.txt") removed for user 0"
}
pull_apks() {
local dir="$1" pkg="" path="" n=0 base=""
local -a pkgs=() paths=()
mkdir -p "${dir}/apks"
# Read the list into an array FIRST. Iterating the file with
# `while read < file` breaks after one package, because `adb`
# inside the loop inherits and drains that same stdin -- measured:
# it pulled 1 of 54 before this was fixed.
mapfile -t pkgs <"${dir}/packages-third-party.txt"
for pkg in "${pkgs[@]}"; do
[[ -n "${pkg}" ]] || continue
# A split app reports several paths (base + split_config.*).
# All of them are needed for a working offline reinstall, so
# each package gets its own directory rather than one file.
mapfile -t paths < <(adb_cmd shell pm path "${pkg}" 2>/dev/null | sed 's/^package://' | tr -d '\r')
[[ "${#paths[@]}" -gt 0 ]] || {
_warn "no APK path for ${pkg}"
continue
}
mkdir -p "${dir}/apks/${pkg}"
for path in "${paths[@]}"; do
[[ -n "${path}" ]] || continue
base="$(basename "${path}")"
adb_cmd pull "${path}" "${dir}/apks/${pkg}/${base}" >/dev/null 2>&1 ||
_warn "could not pull ${base} for ${pkg}"
done
n=$((n + 1))
done
_info "APKs pulled: ${n} packages, $(find "${dir}/apks" -name '*.apk' | wc -l) files"
}
pull_sdcard() {
local dir="$1" d=""
mkdir -p "${dir}/sdcard"
for d in "${SDCARD_DIRS[@]}"; do
if adb_cmd shell "test -d /sdcard/${d}" 2>/dev/null; then
_info "Pulling /sdcard/${d}"
adb_cmd pull "/sdcard/${d}" "${dir}/sdcard/" >/dev/null 2>&1 ||
_warn "partial or failed pull: ${d}"
fi
done
# Same stdin caveat as pull_apks: collect first, pull second.
local -a kdbx_paths=()
mapfile -t kdbx_paths < <(adb_cmd shell 'find /sdcard -iname "*.kdbx" 2>/dev/null' | tr -d '\r')
local kdbx=""
for kdbx in "${kdbx_paths[@]}"; do
[[ -n "${kdbx}" ]] || continue
_info "Pulling KeePass database: ${kdbx}"
mkdir -p "${dir}/sdcard/keepass"
adb_cmd pull "${kdbx}" "${dir}/sdcard/keepass/" >/dev/null 2>&1 || true
done
}
dump_settings() {
local dir="$1" ns=""
for ns in system secure global; do
adb_cmd shell settings list "${ns}" | tr -d '\r' | sort \
>"${dir}/settings-${ns}.txt"
done
_info "Settings captured (system/secure/global)"
}
dump_focus_state() {
local dir="$1"
local out="${dir}/focus-state.md" rethink="no" purged=""
# Built as one printf rather than a `{ ... } >file` group of many: kcov
# counts the group's closing brace as an instrumented line that bash never
# reports, and a run of individual redirects instead draws SC2129. One
# statement satisfies both, and the repo forbids suppressing either.
grep -qx 'com.celzero.bravedns' "${dir}/packages-all.txt" && rethink="yes"
purged="$(cat "${dir}/packages-removed-for-user0.txt")"
printf '%s\n%s\n\n%s\n%s\n\n%s%s\n%s\n%s\n' \
'# Focus-mode state at backup time' \
'# Restore with: phone_focus_mode/distraction_purge.sh' \
'## Packages removed for user 0' \
"${purged}" \
'## RethinkDNS installed: ' "${rethink}" \
'## uBlock filters live in the Firefox profile and CANNOT be' \
'## backed up from adb; see docs/youtube-block-unrooted.md' \
>"${out}"
}

View File

@ -1,216 +0,0 @@
#!/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
# Directory of this library, used to locate sibling helper scripts (BASH_SOURCE
# resolves to monitor.sh even when sourced, so the path is stable).
_MONITOR_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly _MONITOR_LIB_DIR
# Constants read here or by both probe libraries. Those read by exactly one of
# them live in that file instead: a file must not assign a global it never
# reads (SC2034), and shellcheck runs without -x so each file stands alone.
readonly _MONITOR_REMOTE_DIR="/data/local/tmp/focus_mode"
readonly _MONITOR_HOSTS_TARGET="/system/etc/hosts"
readonly _MONITOR_LAUNCHER_PACKAGE="com.qqlabs.minimalistlauncher"
readonly _MONITOR_LAUNCHER_ACTIVITY_FILE="/data/local/tmp/focus_mode/minimalist_launcher.activity"
readonly _MONITOR_HOSTS_CANDIDATES="/system/etc/hosts /etc/hosts /vendor/etc/hosts /system/system/etc/hosts"
# Sourced after the constants above, which the probes read. Both libraries call
# helpers defined below (_mon_check, _trim_output, _safe_adb_root_output); that
# is fine because nothing here runs until monitor_collect_snapshot is called.
# shellcheck source=monitor_checks_health.sh
. "${_MONITOR_LIB_DIR}/monitor_checks_health.sh"
# shellcheck source=monitor_checks_policy.sh
. "${_MONITOR_LIB_DIR}/monitor_checks_policy.sh"
_mon_escape_json() {
local escaped="$1"
escaped=${escaped//\\/\\\\}
# The quote must be backslash-escaped in the pattern. Written bare as
# ${escaped//"/\\"} bash reads the quote as opening a quoted string, so the
# pattern silently becomes `/\` with an empty replacement and no quote is
# ever escaped — which emitted invalid JSON for any message containing one.
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' ' ' </proc/${pid}/cmdline | grep -q ${script_name}" >/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 ))
}
# Each array element is appended on its own statement rather than listed
# across a multi-line literal: kcov instruments the continuation lines but
# bash only ever reports the statement's first line, so a literal spanning N
# lines is permanently stuck at 1/N covered however thoroughly it is tested.
monitor_print_format_warning() {
local -a missing_indicators=("$@")
local -a box_lines=()
local indicator=""
box_lines+=("")
box_lines+=("The following expected components were NOT found:")
for indicator in "${missing_indicators[@]}"; do
box_lines+=(" ✗ ${indicator}")
done
box_lines+=("")
box_lines+=("This strongly suggests the phone was factory-reset or formatted.")
box_lines+=("")
box_lines+=("Next step: run the full recovery workflow:")
box_lines+=(" ./scripts/run_all/run_phone.sh fresh-phone")
box_lines+=("")
box_lines+=("Do NOT run 'auto' mode — it will not restore anything.")
_box "PHONE APPEARS TO HAVE BEEN WIPED" "${box_lines[@]}"
}
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}"
# Redirected per statement rather than as a `{ ... } >file` group: kcov
# counts the group's closing brace as an instrumented line that bash never
# reports, which alone held this file below 100%.
printf '{"timestamp":"%s","device":"%s","checks":[\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(_mon_escape_json "${ADB_SERIAL}")" >"${report_path}"
paste -sd ',' "${tmp_checks}" >>"${report_path}"
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 "${_MONITOR_LIB_DIR}/monitor_report.py" summary "${report_path}"
}
monitor_severity_exit() {
local snapshot_dir="$1"
local report_path="${snapshot_dir}/report.json"
[[ -f "${report_path}" ]] || return 0
python "${_MONITOR_LIB_DIR}/monitor_report.py" severity "${report_path}"
}

View File

@ -1,138 +0,0 @@
#!/usr/bin/env bash
# lib/monitor_checks_health.sh — device-health probes behind monitor.sh:
# format indicators, battery, storage, and the three enforcer daemons.
# Each _check_* appends one JSON object to the checks file it is given.
#
# Sourced by monitor.sh, which defines every _MONITOR_* constant these read
# and the _mon_check/_trim_output/_monitor_read_pidfile helpers they call.
# Nothing is assigned here, so there is no constant to keep in step.
_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}"
}

View File

@ -1,136 +0,0 @@
#!/usr/bin/env bash
# lib/monitor_checks_policy.sh — enforcement-integrity probes behind monitor.sh:
# the hosts blocklist, DNS chain, launcher, companion app and boot persistence.
# Each _check_* appends one JSON object to the checks file it is given.
#
# Sourced by monitor.sh, which defines _MONITOR_LAUNCHER_PACKAGE and
# _MONITOR_LAUNCHER_ACTIVITY_FILE (shared with the health probes) plus the
# _mon_check/_trim_output/_safe_adb_root_output helpers these call.
# The constants below are read only here, so they are assigned only here.
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_BOOT_SCRIPT="/data/adb/service.d/99-focus-mode.sh"
readonly _MONITOR_COMPANION_PACKAGE="com.kuhy.focusstatus"
readonly _MONITOR_DNS_CHAIN="FOCUS_DNS_BLOCK"
_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
}

View File

@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""Render or grade a phone-monitoring report produced by ``monitor.sh``.
Two modes, selected by ``argv[1]``:
* ``summary`` — print a human-readable status summary to stdout (always exit 0).
* ``severity`` — exit 1 if any check is ``fatal``/``error``, else exit 0 (no output).
``argv[2]`` is the path to the ``report.json`` snapshot. Kept as a standalone
module (not inline ``python <<PY`` in ``monitor.sh``) so the repository's Python
tooling applies; see CLAUDE.md "Shell Style".
"""
from __future__ import annotations
import json
from pathlib import Path
import sys
# Status buckets shown in the summary header, in display order.
_COUNT_KEYS = ("ok", "warn", "error", "fatal")
# Statuses considered problems worth listing / failing on.
_ISSUE_STATUSES = frozenset({"warn", "error", "fatal"})
_SEVERE_STATUSES = frozenset({"error", "fatal"})
def _load_checks(report_path: Path) -> list[object]:
"""Return the ``checks`` array from the report, or [] if absent/malformed."""
data: object = json.loads(report_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return []
checks: object = data.get("checks", [])
return checks if isinstance(checks, list) else []
def _field(check: object, key: str, default: str) -> str:
"""Read a string field from a check object, falling back to ``default``."""
if isinstance(check, dict):
value = check.get(key, default)
if isinstance(value, str):
return value
return default
def _render_summary(checks: list[object]) -> str:
"""Build the multi-line monitoring summary string for the given checks."""
counts = dict.fromkeys(_COUNT_KEYS, 0)
issues: list[tuple[str, str, str]] = []
for check in checks:
status = _field(check, "status", "warn")
counts[status] = counts.get(status, 0) + 1
if status in _ISSUE_STATUSES:
issues.append(
(
status,
_field(check, "check", "unknown"),
_field(check, "message", ""),
),
)
lines = [
"",
"=== Monitoring Summary ===",
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:
lines.append("")
lines.append("Issues found:")
lines.extend(
f" [{status}] {name}: {message}" for status, name, message in issues
)
lines.append("==========================")
lines.append("")
return "\n".join(lines) + "\n"
def _has_severe(checks: list[object]) -> bool:
"""Return True if any check has a fatal/error status."""
return any(_field(check, "status", "warn") in _SEVERE_STATUSES for check in checks)
def main() -> int:
"""Dispatch on ``argv[1]`` (summary|severity) and ``argv[2]`` (report path)."""
args = sys.argv[1:]
expected_args = 2
if len(args) != expected_args or args[0] not in {"summary", "severity"}:
sys.stderr.write("usage: monitor_report.py {summary|severity} <report.json>\n")
return 2
mode, report_path = args[0], Path(args[1])
checks = _load_checks(report_path)
if mode == "summary":
sys.stdout.write(_render_summary(checks))
return 0
return 1 if _has_severe(checks) else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,162 +0,0 @@
#!/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
}

View File

@ -1,76 +0,0 @@
#!/usr/bin/env bash
# lib/tests/adb_test_harness.sh — shared fixtures for the adb_common test files.
# Sourced by test_adb_common.sh and test_adb_trusted.sh; not a test file itself.
#
# Provides: the PASS/FAIL counters, run_test, a TEST_TMPDIR with an EXIT trap,
# an XDG_STATE_HOME redirect so no test touches the real state dir, the adb()
# mock, and the two injection payloads the sanitiser is tested against.
# 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'
# Single quotes are the whole point: this is an injection payload that must
# reach the code under test LITERALLY. Expanding $evil / `cmd` here would
# execute them in the test harness and test nothing.
# shellcheck disable=SC2016
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
}

View File

@ -1,107 +0,0 @@
#!/usr/bin/env bash
# lib/tests/backup_capture_harness.sh — the fake device behind the
# backup_capture.sh tests.
#
# Sourced, not executed. Every function in backup_capture.sh reaches the
# phone through adb_cmd, so mocking that one function covers the library
# without pulling in adb_common.sh's device-detection surface. The mock
# records what it was asked to pull, which is what makes the two
# stdin-draining bugs the comments in pull_apks/pull_sdcard describe
# testable: a regression there pulls 1 package instead of all of them.
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"
}
_t_eq() {
local want="$1" got="$2" what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
TEST_TMPDIR="$(mktemp -d)"
trap 'rm -rf "${TEST_TMPDIR}"' EXIT
readonly DEV="${TEST_TMPDIR}/device"
mkdir -p "${DEV}"
_info() { :; }
_warn() { printf 'warn: %s\n' "$*" >>"${DEV}/warnings"; }
# The fake device. Dispatch is by pattern because the real calls carry
# trailing redirections and quoted find expressions.
adb_cmd() {
local args="$*"
case "${args}" in
'shell pm list packages')
printf 'package:com.android.systemui\npackage:com.example.one\npackage:com.example.two\r\n'
;;
'shell pm list packages -3')
printf 'package:com.example.one\npackage:com.example.two\r\n'
;;
'shell pm list packages -u')
printf 'package:com.android.systemui\npackage:com.example.one\npackage:com.example.two\npackage:com.purged.app\r\n'
;;
'shell pm path com.example.one')
printf 'package:/data/app/one/base.apk\npackage:/data/app/one/split_config.xxhdpi.apk\r\n'
;;
'shell pm path com.example.two')
printf 'package:/data/app/two/base.apk\r\n'
;;
'shell pm path '*)
return 1
;;
'pull '*)
local src="${args#pull }"
local dest="${src#* }"
src="${src%% *}"
printf '%s\n' "${src}" >>"${DEV}/pulled"
[[ -f "${DEV}/fail_pull" ]] && return 1
# A directory pull lands a directory; an APK pull lands a file.
if [[ "${dest}" == */ ]]; then
mkdir -p "${dest}"
else
mkdir -p "$(dirname "${dest}")"
printf 'apk\n' >"${dest}"
fi
;;
'shell test -d /sdcard/'*)
local d="${args##*/sdcard/}"
[[ -f "${DEV}/nodir_${d}" ]] && return 1
return 0
;;
*'iname "*.kdbx"'*)
cat "${DEV}/kdbx" 2>/dev/null || printf ''
;;
'shell settings list '*)
local ns="${args##* }"
printf 'a_key=1\nb_key=2\r\n' | sed "s/^/${ns}_/"
;;
*)
printf 'unexpected adb_cmd: %s\n' "${args}" >&2
return 1
;;
esac
}
# shellcheck source=../backup_capture.sh
. "${SCRIPT_DIR}/../backup_capture.sh"
OUT="${TEST_TMPDIR}/out"
mkdir -p "${OUT}"

View File

@ -1,250 +0,0 @@
#!/usr/bin/env bash
# lib/tests/ctl_cases_commands.sh — assertions for focus_ctl.sh's subcommands,
# against the device stubs in test_ctl_libs.sh.
#
# The start/stop/status trio is the same shape in all seven enforcer
# libraries, so it is driven through tables rather than seven near-identical
# blocks: the duplication is in the subject, and repeating it here would add
# nothing and trip the jscpd gate. The per-enforcer specifics that tables
# cannot express get their own cases below.
set -euo pipefail
# Run a subcommand for its side effects only. These are CLI entry points whose
# exit status is not part of their contract, and an unguarded nonzero return
# aborts the whole file under set -e.
_run_quiet() {
"$1" >/dev/null 2>&1 || return 0
}
# _start_case <cmd> <pid-fn> <pidfile-var> <label>
#
# A start command must refuse when already running, and must report the
# failure rather than claim success when the daemon does not come up. The
# stubbed setsid never actually starts anything, so "came up" is modelled by
# the pidfile appearing.
_start_case() {
local cmd="$1" pidvar="$2" label="$3"
local file="${!pidvar}"
printf '%s\n' "$$" >"${file}"
_reset_calls
case "$(${cmd} || true)" in
*"already running"*) _t_pass "${label} refuses to start a second copy" ;;
*) _t_fail "${label} must not start when already running" ;;
esac
if _calls | grep -q "^setsid"; then
_t_fail "${label} launched a daemon that was already running"
else
_t_pass "${label} launches nothing when already running"
fi
rm -f "${file}"
_reset_calls
case "$(${cmd} 2>&1 || true)" in
*ERROR*) _t_pass "${label} reports a daemon that failed to come up" ;;
*) _t_fail "${label} must report a failed start, not claim success" ;;
esac
if _calls | grep -q "^setsid"; then
_t_pass "${label} does try to launch when nothing is running"
else
_t_fail "${label} should have launched a daemon"
fi
}
# _stop_case <cmd> <pidfile-var> <label>
#
# The live pid must NOT be $$: these commands call `kill -TERM "$pid"`, so
# seeding the test's own pid makes the suite signal itself and die silently
# mid-run. A disposable background process is used instead.
_stop_case() {
local cmd="$1" pidvar="$2" label="$3"
local file="${!pidvar}" victim=""
rm -f "${file}"
_reset_calls
case "$(${cmd} 2>&1 || true)" in
*"not running"*) _t_pass "${label} reports nothing to stop" ;;
*) _t_fail "${label} must report that nothing is running" ;;
esac
/usr/bin/sleep 30 &
victim=$!
printf '%s\n' "${victim}" >"${file}"
_reset_calls
_run_quiet "${cmd}"
# kill is a shell builtin, so no PATH stub can observe it. The effect is
# what is asserted: the victim must no longer be alive afterwards.
/usr/bin/sleep 0.2
if kill -0 "${victim}" 2>/dev/null; then
_t_fail "${label} left the running daemon alive"
kill "${victim}" 2>/dev/null || true
else
_t_pass "${label} signals the running daemon"
fi
wait "${victim}" 2>/dev/null || true
rm -f "${file}"
}
# _status_case <cmd> <pidfile-var> <label>
_status_case() {
local cmd="$1" pidvar="$2" label="$3"
local file="${!pidvar}"
rm -f "${file}"
case "$(${cmd} 2>&1 || true)" in
*STOPPED*) _t_pass "${label} reports STOPPED with no daemon" ;;
*) _t_fail "${label} must report STOPPED when nothing is running" ;;
esac
printf '%s\n' "$$" >"${file}"
case "$(${cmd} 2>&1 || true)" in
*RUNNING*) _t_pass "${label} reports RUNNING with a live daemon" ;;
*) _t_fail "${label} must report RUNNING when the daemon is up" ;;
esac
rm -f "${file}"
}
# --- the four enforcers whose trio is uniform -------------------------------
_start_case cmd_hosts_start HOSTS_PIDFILE "hosts start"
_stop_case cmd_hosts_stop HOSTS_PIDFILE "hosts stop"
_status_case cmd_hosts_status HOSTS_PIDFILE "hosts status"
_start_case cmd_dns_start DNS_PIDFILE "dns start"
_stop_case cmd_dns_stop DNS_PIDFILE "dns stop"
_status_case cmd_dns_status DNS_PIDFILE "dns status"
_start_case cmd_launcher_start LAUNCHER_PIDFILE "launcher start"
_stop_case cmd_launcher_stop LAUNCHER_PIDFILE "launcher stop"
_status_case cmd_launcher_status LAUNCHER_PIDFILE "launcher status"
_start_case cmd_workout_start WORKOUT_PIDFILE "workout start"
# The workout detector needs the sqlite3 binary deploy.sh pushes alongside it.
# Missing, it must refuse rather than launch a detector that cannot read the
# StrongLifts database.
rm -f "${WORKOUT_PIDFILE}"
_saved_sqlite="${WORKOUT_SQLITE3_BIN}"
WORKOUT_SQLITE3_BIN="${RUN}/state/no-such-sqlite3"
_reset_calls
case "$(cmd_workout_start 2>&1 || true)" in
*"missing or not executable"*) _t_pass "workout start refuses without sqlite3" ;;
*) _t_fail "workout start must refuse when sqlite3 is missing" ;;
esac
if _calls | grep -q "^setsid"; then
_t_fail "workout start launched a detector with no sqlite3"
else
_t_pass "workout start launches nothing without sqlite3"
fi
WORKOUT_SQLITE3_BIN="${_saved_sqlite}"
_stop_case cmd_workout_stop WORKOUT_PIDFILE "workout stop"
_start_case cmd_curfew_start CURFEW_PIDFILE "curfew start"
_stop_case cmd_curfew_stop CURFEW_PIDFILE "curfew stop"
_start_case cmd_tether_start TETHER_PIDFILE "tether start"
_stop_case cmd_tether_stop TETHER_PIDFILE "tether stop"
_status_case cmd_tether_status TETHER_PIDFILE "tether status"
_start_case cmd_start PIDFILE "daemon start"
_stop_case cmd_stop PIDFILE "daemon stop"
# --- dns status: the chain report -------------------------------------------
# A missing firewall chain is the whole point of the check, so it must be
# reported as MISSING rather than as a zero-rule chain.
rm -f "${DNS_PIDFILE}"
_clear_chain iptables
_clear_chain ip6tables
out="$(cmd_dns_status 2>&1)"
case "${out}" in
*"iptables ${DNS_IPT_CHAIN}: MISSING"*) _t_pass "dns status reports a missing v4 chain" ;;
*) _t_fail "dns status must report a missing v4 chain" ;;
esac
case "${out}" in
*"ip6tables ${DNS_IPT_CHAIN}: MISSING"*) _t_pass "dns status reports a missing v6 chain" ;;
*) _t_fail "dns status must report a missing v6 chain" ;;
esac
_seed_chain iptables "$(printf -- '-A one\n-A two\n-A three')"
_seed_chain ip6tables "-A one"
out="$(cmd_dns_status 2>&1)"
case "${out}" in
*"iptables ${DNS_IPT_CHAIN}: 3 rules"*) _t_pass "dns status counts the v4 rules" ;;
*) _t_fail "dns status must count the v4 rules" ;;
esac
case "${out}" in
*"ip6tables ${DNS_IPT_CHAIN}: 1 rules"*) _t_pass "dns status counts the v6 rules" ;;
*) _t_fail "dns status must count the v6 rules" ;;
esac
# Private DNS is the setting the enforcer exists to hold down, so an unset
# value must read as <unset> rather than as an empty line.
_seed_out settings ""
case "$(cmd_dns_status 2>&1)" in
*"private_dns_mode: <unset>"*) _t_pass "dns status renders an unset mode explicitly" ;;
*) _t_fail "dns status must render an unset private DNS mode" ;;
esac
rm -f "${DEV}/out_settings"
# --- curfew status and the override/force hooks -----------------------------
_set_now "0000"
rm -f "${CURFEW_PIDFILE}" "${CURFEW_OVERRIDE_FILE}" "${CURFEW_FORCE_FILE}"
cmd_curfew_test_on >/dev/null 2>&1
if [[ -e "${CURFEW_FORCE_FILE}" ]]; then
_t_pass "curfew-test-on creates the force file"
else
_t_fail "curfew-test-on must create the force file"
fi
cmd_curfew_test_off >/dev/null 2>&1
if [[ -e "${CURFEW_FORCE_FILE}" ]]; then
_t_fail "curfew-test-off left the force file behind"
else
_t_pass "curfew-test-off clears the force file"
fi
# The escape hatch: this is the 2am opt-out, so it must actually take effect.
cmd_curfew_off >/dev/null 2>&1
if [[ -e "${CURFEW_OVERRIDE_FILE}" ]]; then
_t_pass "curfew-off creates the override that suspends the curfew"
else
_t_fail "curfew-off must create the override file"
fi
cmd_curfew_on >/dev/null 2>&1
if [[ -e "${CURFEW_OVERRIDE_FILE}" ]]; then
_t_fail "curfew-on left the override in place"
else
_t_pass "curfew-on clears the override and re-arms the curfew"
fi
# --- tether test hooks ------------------------------------------------------
rm -f "${TETHER_FORCE_FILE}"
cmd_tether_test_on >/dev/null 2>&1
if [[ -e "${TETHER_FORCE_FILE}" ]]; then
_t_pass "tether-test-on creates the force file"
else
_t_fail "tether-test-on must create the force file"
fi
cmd_tether_test_off >/dev/null 2>&1
if [[ -e "${TETHER_FORCE_FILE}" ]]; then
_t_fail "tether-test-off left the force file behind"
else
_t_pass "tether-test-off clears the force file"
fi
# --- usage ------------------------------------------------------------------
usage_text="$(usage 2>&1)"
for sub in start stop status hosts-status dns-status launcher-status \
workout-status curfew-status tether-status curfew-off; do
case "${usage_text}" in
*"${sub}"*) _t_pass "usage documents ${sub}" ;;
*) _t_fail "usage must document ${sub}" ;;
esac
done

View File

@ -1,144 +0,0 @@
#!/usr/bin/env bash
# lib/tests/ctl_cases_curfew.sh — assertions for the pure logic in the ctl
# libraries: the zero-stripping helper, the night-curfew window, and the
# seven pidfile helpers.
#
# Sourced by test_ctl_libs.sh after ctl_libs_harness.sh, which owns the stubs,
# the subjects and the PASS/FAIL counters this file adds to.
set -euo pipefail
# --- _ctl_dec ---------------------------------------------------------------
# Zero-padded times must not reach shell arithmetic as octal: "0830" is not a
# valid octal number and would abort the comparison mid-check.
_t_eq "830" "$(_ctl_dec 0830)" "_ctl_dec strips the leading zero from 0830"
_t_eq "500" "$(_ctl_dec 0500)" "_ctl_dec strips the leading zero from 0500"
_t_eq "0" "$(_ctl_dec 0000)" "_ctl_dec keeps a single digit for midnight"
_t_eq "2300" "$(_ctl_dec 2300)" "_ctl_dec leaves an unpadded time alone"
_t_eq "9" "$(_ctl_dec 0009)" "_ctl_dec strips several leading zeros"
# --- ctl_is_curfew_now ------------------------------------------------------
# The window wraps past midnight, which a naive start<=now<end comparison gets
# exactly backwards: it would report curfew all day and never at night.
NIGHT_CURFEW_START="2300"
NIGHT_CURFEW_END="0500"
for t in 2300 2359 0000 0430; do
_set_now "${t}"
if ctl_is_curfew_now; then
_t_pass "curfew is open at ${t} inside the wrapping window"
else
_t_fail "curfew should be open at ${t}"
fi
done
for t in 0500 1200 2259; do
_set_now "${t}"
if ! ctl_is_curfew_now; then
_t_pass "curfew is closed at ${t} outside the wrapping window"
else
_t_fail "curfew should be closed at ${t}"
fi
done
# A same-day window includes its start and excludes its end.
NIGHT_CURFEW_START="0900"
NIGHT_CURFEW_END="1700"
_set_now "0900"
if ctl_is_curfew_now; then
_t_pass "a same-day window includes its start minute"
else
_t_fail "0900 should be inside a 0900-1700 window"
fi
_set_now "1700"
if ! ctl_is_curfew_now; then
_t_pass "a same-day window excludes its end minute"
else
_t_fail "1700 should be outside a 0900-1700 window"
fi
_set_now "1200"
if ctl_is_curfew_now; then
_t_pass "a same-day window is open midway through"
else
_t_fail "1200 should be inside a 0900-1700 window"
fi
NIGHT_CURFEW_START="2300"
NIGHT_CURFEW_END="0500"
_t_eq "2300-0500" "${NIGHT_CURFEW_START}-${NIGHT_CURFEW_END}" \
"the wrapping window is restored for the clock cases"
# A broken clock must fail OPEN: reporting permanent curfew because `date`
# misbehaved would lock the phone down with no way to see why.
for bad in "not-a-time" "" "12:00"; do
_set_now "${bad}"
if ! ctl_is_curfew_now; then
_t_pass "a malformed clock ('${bad}') reads as no curfew"
else
_t_fail "a malformed clock ('${bad}') must not enable the curfew"
fi
done
# --- the two copies must agree ---------------------------------------------
# daemon_location.sh carries its own is_curfew_now. The phone's daemon and its
# control script disagreeing about whether it is night is a silent, confusing
# failure, so the copies are compared directly rather than trusted to match.
_curfew_table() {
local answers="" t=""
for t in 2259 2300 2359 0000 0430 0500 1200; do
_set_now "${t}"
if "$1"; then answers="${answers}1"; else answers="${answers}0"; fi
done
printf '%s' "${answers}"
}
ctl_answers="$(_curfew_table ctl_is_curfew_now)"
(
# Sourced in a subshell: daemon_location.sh defines is_curfew_now and
# curfew_active, and only the former is wanted here.
# shellcheck source=../../daemon_location.sh
. "${PHONE_DIR}/daemon_location.sh"
_curfew_table is_curfew_now
) >"${RUN}/state/daemon_answers"
_t_eq "${ctl_answers}" "$(cat "${RUN}/state/daemon_answers")" \
"ctl_is_curfew_now and daemon is_curfew_now agree on every boundary"
# 2259 closed, 2300/2359/0000/0430 open, 0500 closed (end is exclusive),
# 1200 closed. Spelled out so the agreement check above cannot pass by
# both copies being wrong in the same way.
_t_eq "0111100" "${ctl_answers}" "the shared window answers match the 2300-0500 spec"
# --- the seven pid helpers --------------------------------------------------
# One table rather than seven near-identical blocks: the duplication is in the
# subject, and repeating it here would add nothing and trip the jscpd gate.
_pid_case() {
local fn="$1" var="$2"
local file="${RUN}/state/${fn}.pid"
# printf -v assigns to a name held in a variable without eval, which the
# repo's shell rules forbid.
printf -v "${var}" '%s' "${file}"
rm -f "${file}"
_t_eq "" "$(${fn})" "${fn} is empty with no pidfile"
printf '999999\n' >"${file}"
_t_eq "" "$(${fn})" "${fn} is empty when the recorded pid is dead"
printf '%s\n' "$$" >"${file}"
_t_eq "$$" "$(${fn})" "${fn} returns a live pid"
}
_pid_case hosts_enforcer_pid HOSTS_PIDFILE
_pid_case dns_enforcer_pid DNS_PIDFILE
_pid_case launcher_enforcer_pid LAUNCHER_PIDFILE
_pid_case workout_detector_pid WORKOUT_PIDFILE
_pid_case curfew_enforcer_pid CURFEW_PIDFILE
_pid_case tether_enforcer_pid TETHER_PIDFILE
_pid_case daemon_pid PIDFILE

View File

@ -1,178 +0,0 @@
#!/usr/bin/env bash
# lib/tests/ctl_cases_daemon.sh — assertions for focus_ctl.sh's own commands:
# status, disable, recheck, log, notif-status and the whitelist iterator.
#
# Sourced by test_ctl_libs.sh after ctl_cases_commands.sh, which owns the
# table helpers and the counters. Split out to stay under the 250-line cap.
set -euo pipefail
# --- cmd_status --------------------------------------------------------------
# The location block is the only part that talks to the device; the rest is a
# report of files the tests control.
rm -f "${PIDFILE}"
printf 'focus\n' >"${MODE_FILE}"
printf 'com.a\ncom.b\n' >"${DISABLED_APPS_FILE}"
_seed_out dumpsys 'last location=Location[fused 52.2297,21.0122 hAcc=12]'
out="$(cmd_status 2>&1 || true)"
case "${out}" in
*"Mode: focus"*) _t_pass "status reports the recorded mode" ;;
*) _t_fail "status must report the mode from MODE_FILE" ;;
esac
case "${out}" in
*"Location: 52.2297, 21.0122"*) _t_pass "status reports the current fix" ;;
*) _t_fail "status must report the current location" ;;
esac
# The distance is the THIRD copy of the Haversine in this repo. Asserting only
# that some number is printed lets a wrong Earth radius through, so the value
# is checked: HOME_LAT/HOME_LON are Warsaw and the seeded fix is ~4.3 km away.
case "${out}" in
*"Distance: "*"m from home"*) _t_pass "status reports a distance from home" ;;
*) _t_fail "status must report the distance from home" ;;
esac
status_dist="$(printf '%s\n' "${out}" | sed -n 's/^Distance: \([0-9]*\)m from home$/\1/p')"
if [[ "${status_dist}" -ge 4000 && "${status_dist}" -le 4600 ]]; then
_t_pass "status computes a correct distance (${status_dist} m)"
else
_t_fail "status distance should be ~4300 m, got '${status_dist}'"
fi
case "${out}" in
*com.a*) _t_pass "status lists the apps focus mode disabled" ;;
*) _t_fail "status must list the disabled apps" ;;
esac
# No fix available must say so rather than print an empty distance.
rm -f "${DEV}/out_dumpsys"
case "$(cmd_status 2>&1 || true)" in
*"Location: unavailable"*) _t_pass "status reports an unavailable location" ;;
*) _t_fail "status must report when no fix is available" ;;
esac
# An empty disabled list reads as a sentence, not as blank output.
: >"${DISABLED_APPS_FILE}"
case "$(cmd_status 2>&1 || true)" in
*"No apps currently disabled"*) _t_pass "status states when nothing is disabled" ;;
*) _t_fail "status must state when nothing is disabled" ;;
esac
# An unset mode file is reported as unknown, not as an empty field.
rm -f "${MODE_FILE}"
case "$(cmd_status 2>&1 || true)" in
*"Mode: unknown"*) _t_pass "status reports an unknown mode explicitly" ;;
*) _t_fail "status must report an unknown mode explicitly" ;;
esac
# --- cmd_disable -------------------------------------------------------------
# The restore pass must re-enable every recorded package and then clear the
# record, or the next run would try to re-enable them all again.
printf 'com.one\ncom.two\n' >"${DISABLED_APPS_FILE}"
_reset_calls
_run_quiet cmd_disable
_t_eq "2" "$(_calls | grep -c '^pm enable')" "disable re-enables every recorded app"
if [[ -s "${DISABLED_APPS_FILE}" ]]; then
_t_fail "disable left the record of disabled apps in place"
else
_t_pass "disable clears the record once everything is restored"
fi
: >"${DISABLED_APPS_FILE}"
case "$(cmd_disable 2>&1 || true)" in
*"No apps to re-enable"*) _t_pass "disable states when there is nothing to restore" ;;
*) _t_fail "disable must state when there is nothing to restore" ;;
esac
# --- cmd_recheck / cmd_log / cmd_notif_status --------------------------------
# A recheck with no daemon has nothing to wake, so it must refuse rather than
# leave a trigger file that nothing will ever consume.
rm -f "${RECHECK_TRIGGER}" "${PIDFILE}"
case "$(cmd_recheck 2>&1 || true)" in
*"not running"*) _t_pass "recheck refuses when the daemon is not running" ;;
*) _t_fail "recheck must refuse when the daemon is not running" ;;
esac
if [[ -e "${RECHECK_TRIGGER}" ]]; then
_t_fail "recheck left a trigger nothing will consume"
else
_t_pass "recheck writes no trigger when the daemon is down"
fi
/usr/bin/sleep 30 &
_recheck_victim=$!
printf '%s\n' "${_recheck_victim}" >"${PIDFILE}"
_run_quiet cmd_recheck
if [[ -e "${RECHECK_TRIGGER}" ]]; then
_t_pass "recheck touches the trigger the daemon polls for"
else
_t_fail "recheck must create the trigger file"
fi
kill "${_recheck_victim}" 2>/dev/null || true
wait "${_recheck_victim}" 2>/dev/null || true
rm -f "${PIDFILE}"
printf 'line one\nline two\n' >"${LOG_FILE}"
case "$(cmd_log 2>&1 || true)" in
*"line two"*) _t_pass "log shows the daemon log" ;;
*) _t_fail "log must show the daemon log" ;;
esac
printf '{"mode":"focus"}\n' >"${STATUS_FILE}"
case "$(cmd_notif_status 2>&1 || true)" in
*focus*) _t_pass "notif-status shows the companion snapshot" ;;
*) _t_fail "notif-status must show the status snapshot" ;;
esac
# --- iter_whitelist_packages -------------------------------------------------
# The list is a multi-line quoted string with comments and section headings;
# only real package names may come out, or the sweep would try to disable a
# heading like "---".
_saved_whitelist="${WHITELIST}"
# The undotted token and the comment must each be caught by their OWN filter,
# so the fixture separates them: "# com.commented.out" is a comment whose
# first token IS dotted and IS charset-clean, so only the comment check can
# reject it. "not-a-package" is undotted, so only the dot check rejects it.
# A fixture without both leaves each filter deletable with no test failing.
WHITELIST="
# a comment
# --- a section heading ---
# com.commented.out
com.real.one
com.real.two
notapackage
com.bad!name
com.real.three trailing prose
"
_t_eq "com.real.one com.real.two com.real.three" \
"$(iter_whitelist_packages | tr '\n' ' ' | sed 's/ $//')" \
"iter_whitelist_packages emits only dotted package names"
# The two rejections are asserted separately, because the combined list above
# passes even if only one filter is doing any work. Captured ONCE here, before
# the fixture is replaced below: re-running the function after that would
# assert against a list holding neither an undotted token nor a comment, and
# would pass with both filters deleted.
whitelist_out="$(iter_whitelist_packages)"
case "${whitelist_out}" in
*"notapackage"*) _t_fail "an undotted token was emitted as a package" ;;
*) _t_pass "an undotted token is rejected by the dot check" ;;
esac
case "${whitelist_out}" in
*"com.bad!name"*) _t_fail "a package with illegal characters was emitted" ;;
*) _t_pass "an illegal character is rejected by the charset check" ;;
esac
case "${whitelist_out}" in
*"com.commented.out"*) _t_fail "a commented-out package name was emitted" ;;
*) _t_pass "a comment line is rejected even when its token looks valid" ;;
esac
# A comment whose first token IS dotted would slip past the dot check alone,
# so the comment filter has to be doing the work on its own.
WHITELIST="
# com.commented.out is deliberately disabled
com.real.only
"
_t_eq "com.real.only" "$(iter_whitelist_packages | tr '\n' ' ' | sed 's/ $//')" \
"a commented-out package name is not emitted"
WHITELIST="${_saved_whitelist}"

View File

@ -1,165 +0,0 @@
#!/usr/bin/env bash
# lib/tests/ctl_libs_harness.sh — the fake device behind the tests for the
# libraries split out of focus_ctl.sh.
#
# Sourced, not executed. The subcommands drive the phone through real binaries
# (pm, iptables, settings, setsid, ...), so the stubs go on PATH. The real
# config.sh supplies every default so this fixture cannot drift from the
# values the subcommands actually read; only the paths are redirected.
set -euo pipefail
# TESTS_DIR is declared readonly by the entry point that sources this file.
readonly PHONE_DIR="${TESTS_DIR}/../.."
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
_t_eq() {
local want="$1" got="$2" what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
RUN="$(mktemp -d)"
trap 'rm -rf "${RUN}"' EXIT
mkdir -p "${RUN}/bin" "${RUN}/state"
readonly DEV="${RUN}/state"
# `date` answers from a pinned clock so the window cases are deterministic
# rather than dependent on when the suite happens to run.
cat >"${RUN}/bin/date" <<'STUB'
#!/usr/bin/env bash
if [[ -f "${DEV}/now" && "$1" == "+%H%M" ]]; then
cat "${DEV}/now"
exit 0
fi
exec /usr/bin/date "$@"
STUB
chmod +x "${RUN}/bin/date"
# --- device stubs -----------------------------------------------------------
#
# Every cmd_* subcommand drives the phone through one of these. They log each
# call so a command's effect is assertable, and each honours a fail_<name>
# flag so the "not running" / "chain missing" branches are reachable.
#
# setsid is the important one: the start commands launch a real daemon with
# it. Stubbing it keeps the suite from spawning enforcers on this machine,
# and lets a test decide whether the launch "worked" by seeding the pidfile.
_mk_stub() {
cat >"${RUN}/bin/$1" <<STUB
#!/usr/bin/env bash
printf '%s %s\n' "$1" "\$*" >>"\${DEV}/calls.log"
[[ -f "\${DEV}/fail_$1" ]] && exit 1
[[ -f "\${DEV}/out_$1" ]] && cat "\${DEV}/out_$1"
exit 0
STUB
chmod +x "${RUN}/bin/$1"
}
for _s in pm am settings dumpsys svc sqlite3 getprop content kill sleep setsid; do
_mk_stub "$_s"
done
# iptables/ip6tables also model chain existence, so the MISSING branches and
# the rule counts in the status commands are both reachable.
cat >"${RUN}/bin/iptables" <<'STUB'
#!/usr/bin/env bash
bin="$(basename "$0")"
printf '%s %s\n' "$bin" "$*" >>"${DEV}/calls.log"
case "$1" in
-L) [[ -f "${DEV}/chain_${bin}" ]] && exit 0 || exit 1 ;;
-S) [[ -f "${DEV}/chain_${bin}" ]] && cat "${DEV}/chain_${bin}" || exit 1 ;;
esac
exit 0
STUB
chmod +x "${RUN}/bin/iptables"
cp "${RUN}/bin/iptables" "${RUN}/bin/ip6tables"
export DEV
export PATH="${RUN}/bin:${PATH}"
_calls() { cat "${DEV}/calls.log" 2>/dev/null || printf ''; }
_reset_calls() { : >"${DEV}/calls.log"; }
_fail_op() { touch "${DEV}/fail_$1"; }
_clear_fail() { rm -f "${DEV}/fail_$1"; }
_seed_out() { printf '%s\n' "$2" >"${DEV}/out_$1"; }
_seed_chain() { printf '%s\n' "${2:--A rule}" >"${DEV}/chain_$1"; }
_clear_chain() { rm -f "${DEV}/chain_$1"; }
_set_now() { printf '%s\n' "$1" >"${DEV}/now"; }
# --- ambient definitions the libraries expect from focus_ctl.sh ------------
log() { :; }
# The real config.sh supplies every default, so this fixture cannot drift
# from the values the subcommands actually read. FOCUS_MODE_SCRIPT_DIR points
# its SCRIPT_DIR at the checkout; the paths are then redirected at a temp dir
# so nothing here touches /data/local/tmp.
FOCUS_MODE_SCRIPT_DIR="${PHONE_DIR}"
export FOCUS_MODE_SCRIPT_DIR
# shellcheck source=../../config.sh
. "${PHONE_DIR}/config.sh"
STATE_DIR="${RUN}/state"
LOG_FILE="${STATE_DIR}/focus.log"
MODE_FILE="${STATE_DIR}/mode"
STATUS_FILE="${STATE_DIR}/status.json"
DISABLED_APPS_FILE="${STATE_DIR}/disabled.txt"
DISABLED_COMPETITORS_FILE="${STATE_DIR}/competitors.txt"
RECHECK_TRIGGER="${STATE_DIR}/trigger_recheck"
CURFEW_OVERRIDE_FILE="${STATE_DIR}/curfew_override"
CURFEW_FORCE_FILE="${STATE_DIR}/curfew_force"
CURFEW_ENFORCER_STATE="${STATE_DIR}/curfew_applied"
CURFEW_ENFORCER_LOG="${STATE_DIR}/curfew.log"
TETHER_FORCE_FILE="${STATE_DIR}/tether_force"
TETHER_OVERRIDE_FILE="${STATE_DIR}/tether_override"
TETHER_ENFORCER_STATE="${STATE_DIR}/tether_applied"
TETHER_LOG="${STATE_DIR}/tether.log"
HOSTS_LOG="${STATE_DIR}/hosts.log"
HOSTS_CANONICAL="${STATE_DIR}/hosts.canonical"
HOSTS_SHA_FILE="${STATE_DIR}/hosts.sha256"
HOSTS_TARGET="${STATE_DIR}/hosts_target"
DNS_LOG="${STATE_DIR}/dns.log"
LAUNCHER_LOG="${STATE_DIR}/launcher.log"
LAUNCHER_ACTIVITY_FILE="${STATE_DIR}/launcher.activity"
LAUNCHER_APK="${STATE_DIR}/launcher.apk"
LAUNCHER_SHA_FILE="${STATE_DIR}/launcher.sha256"
WORKOUT_DETECTOR_LOG="${STATE_DIR}/workout.log"
WORKOUT_ACTIVE_FILE="${STATE_DIR}/workout_active"
WORKOUT_DB_PATH="${STATE_DIR}/stronglifts.db"
WORKOUT_SQLITE3_BIN="${RUN}/bin/sqlite3"
NIGHT_CURFEW_START="2300"
NIGHT_CURFEW_END="0500"
# shellcheck source=../../ctl_curfew.sh
. "${PHONE_DIR}/ctl_curfew.sh"
# shellcheck source=../../ctl_hosts.sh
. "${PHONE_DIR}/ctl_hosts.sh"
# shellcheck source=../../ctl_dns.sh
. "${PHONE_DIR}/ctl_dns.sh"
# shellcheck source=../../ctl_launcher.sh
. "${PHONE_DIR}/ctl_launcher.sh"
# shellcheck source=../../ctl_workout.sh
. "${PHONE_DIR}/ctl_workout.sh"
# shellcheck source=../../ctl_tether.sh
. "${PHONE_DIR}/ctl_tether.sh"
# shellcheck source=../../ctl_daemon.sh
. "${PHONE_DIR}/ctl_daemon.sh"
# shellcheck source=../../ctl_usage.sh
. "${PHONE_DIR}/ctl_usage.sh"

View File

@ -1,173 +0,0 @@
#!/usr/bin/env bash
# lib/tests/curfew_net_cases_chain.sh — assertions for the chain-construction
# half of curfew_net.sh: night_uids, ensure_net_chain and fill_net_chain.
#
# Sourced by test_curfew_net.sh after curfew_net_harness.sh, which owns the
# stubs, the subject and the PASS/FAIL counters this file adds to. Split out
# to keep every test file under the repo's 250-line cap.
set -euo pipefail
# --- night_uids -------------------------------------------------------------
_t_eq "" "$(night_uids)" "night_uids is empty when the whitelist file is absent"
printf 'com.foo\ncom.bar\n\ncom.absent\n' >"${STATE_DIR}/night_whitelist.txt"
printf 'com.foo uid:10123\ncom.bar uid:10456\ncom.other uid:10999\n' >"${PM_FIXTURE}"
_t_eq "10123 10456" "$(night_uids | tr '\n' ' ' | sed 's/ $//')" \
"night_uids resolves only the whitelisted packages"
if [[ ! -f "${STATE_DIR}/uid_map.txt" ]]; then
_t_pass "night_uids cleans up its temporary uid map"
else
_t_fail "night_uids left uid_map.txt behind"
fi
# --- ensure_net_chain -------------------------------------------------------
_reset_ipt
if ensure_net_chain iptables; then
_t_pass "ensure_net_chain creates a missing chain"
else
_t_fail "ensure_net_chain should succeed when it can create the chain"
fi
case "$(_calls)" in
*"-N FOCUS_CURFEW_NET"*) _t_pass "ensure_net_chain creates the named chain" ;;
*) _t_fail "ensure_net_chain did not create the chain" ;;
esac
case "$(_calls)" in
*"-I OUTPUT 1 -j FOCUS_CURFEW_NET"*) _t_pass "ensure_net_chain pins the OUTPUT jump at position 1" ;;
*) _t_fail "ensure_net_chain did not pin the jump at position 1" ;;
esac
# Chain creation failing must be reported, not silently continued past.
_reset_ipt
_fail_op N
if ! ensure_net_chain iptables; then
_t_pass "ensure_net_chain fails when the chain cannot be created"
else
_t_fail "ensure_net_chain should fail when -N fails"
fi
_clear_fail N
# The jump insert failing likewise.
_reset_ipt
_fail_op I
if ! ensure_net_chain iptables; then
_t_pass "ensure_net_chain fails when the OUTPUT jump cannot be inserted"
else
_t_fail "ensure_net_chain should fail when -I fails"
fi
_clear_fail I
# Stale duplicate jumps left by a lock race are removed before the new one.
_reset_ipt
_seed_jumps iptables 3
ensure_net_chain iptables
# Four calls for three jumps: the loop is `while -D succeeds`, so the fourth
# is the one that fails and ends it. Asserting on the count rather than on
# the final state is what would catch the loop terminating early.
_t_eq "4" "$(_calls | grep -c -- "-D OUTPUT -j FOCUS_CURFEW_NET")" \
"ensure_net_chain deletes every stale OUTPUT jump"
_t_eq "1" "$(cat "${RUN}/ipt/iptables/jumps")" \
"ensure_net_chain leaves exactly one OUTPUT jump"
# An existing chain is reused rather than recreated.
_reset_ipt
_seed_jumps iptables 0
ensure_net_chain iptables
if _calls | grep -q -- "-N FOCUS_CURFEW_NET"; then
_t_fail "ensure_net_chain recreated a chain that already existed"
else
_t_pass "ensure_net_chain reuses an existing chain"
fi
# --- fill_net_chain ---------------------------------------------------------
_reset_ipt
printf '10123\n10456\n' >"${CURFEW_NET_UID_CACHE}"
fill_net_chain iptables icmp-port-unreachable
rules="$(_rules_of iptables)"
case "${rules}" in
*"-o lo -j ACCEPT"*) _t_pass "fill_net_chain always allows loopback" ;;
*) _t_fail "fill_net_chain must allow loopback" ;;
esac
case "${rules}" in
*"ESTABLISHED,RELATED -j ACCEPT"*) _t_pass "fill_net_chain allows established flows" ;;
*) _t_fail "fill_net_chain must allow established flows" ;;
esac
for uid in 0 1000 2000; do
case "${rules}" in
*"--uid-owner ${uid} -j ACCEPT"*) _t_pass "fill_net_chain allows uid ${uid}" ;;
*) _t_fail "fill_net_chain must allow uid ${uid}" ;;
esac
done
# DNS on both protocols: apps resolve via netd under a different uid, so
# without these every lookup fails under the cut-off.
_t_eq "2" "$(printf '%s\n' "${rules}" | grep -c -- "--dport 53 -j ACCEPT")" \
"fill_net_chain allows DNS over both udp and tcp"
for uid in 10123 10456; do
case "${rules}" in
*"--uid-owner ${uid} -j ACCEPT"*) _t_pass "fill_net_chain allows whitelisted uid ${uid}" ;;
*) _t_fail "fill_net_chain must allow whitelisted uid ${uid}" ;;
esac
done
case "${rules}" in
*"--uid-owner 10000-19999 -j REJECT"*) _t_pass "fill_net_chain rejects the remaining app uid range" ;;
*) _t_fail "fill_net_chain must reject the remaining app range" ;;
esac
# Ordering is the whole point: a REJECT before the ACCEPTs blocks everything.
reject_line="$(printf '%s\n' "${rules}" | grep -n -- "10000-19999" | cut -d: -f1)"
last_accept="$(printf '%s\n' "${rules}" | grep -n -- "-j ACCEPT" | tail -1 | cut -d: -f1)"
if [[ "${reject_line}" -gt "${last_accept}" ]]; then
_t_pass "fill_net_chain puts the REJECT after every ACCEPT"
else
_t_fail "fill_net_chain REJECT at ${reject_line} precedes an ACCEPT at ${last_accept}"
fi
# The reject-with argument differs per protocol and must be passed through.
_reset_ipt
fill_net_chain ip6tables icmp6-port-unreachable
case "$(_rules_of ip6tables)" in
*"--reject-with icmp6-port-unreachable"*) _t_pass "fill_net_chain passes the v6 reject type through" ;;
*) _t_fail "fill_net_chain must pass the caller's reject type" ;;
esac
# A non-numeric cache line must be skipped, not passed to iptables.
_reset_ipt
printf '10123\nnot-a-uid\n\n10456\n' >"${CURFEW_NET_UID_CACHE}"
fill_net_chain iptables icmp-port-unreachable
if _rules_of iptables | grep -q "not-a-uid"; then
_t_fail "fill_net_chain passed a non-numeric uid to iptables"
else
_t_pass "fill_net_chain skips non-numeric cache entries"
fi
# A missing cache is not fatal: the plumbing rules must still be laid down.
_reset_ipt
rm -f "${CURFEW_NET_UID_CACHE}"
fill_net_chain iptables icmp-port-unreachable
case "$(_rules_of iptables)" in
*"10000-19999 -j REJECT"*) _t_pass "fill_net_chain still cuts off apps with no uid cache" ;;
*) _t_fail "fill_net_chain must lay down the REJECT even with no cache" ;;
esac
# A failing flush aborts the fill rather than layering onto stale rules.
_reset_ipt
_fail_op F
if ! fill_net_chain iptables icmp-port-unreachable; then
_t_pass "fill_net_chain fails when the chain cannot be flushed"
else
_t_fail "fill_net_chain should fail when -F fails"
fi
_clear_fail F

View File

@ -1,121 +0,0 @@
#!/usr/bin/env bash
# lib/tests/curfew_net_cases_lifecycle.sh — assertions for the lifecycle half
# of curfew_net.sh: iptw, refresh_uid_cache, rebuild_net_from_cache, net_hold,
# apply_net and teardown_net.
#
# Sourced by test_curfew_net.sh after curfew_net_harness.sh, which owns the
# stubs, the subject and the PASS/FAIL counters this file adds to.
set -euo pipefail
# --- iptw -------------------------------------------------------------------
_reset_ipt
iptw iptables -L "${CURFEW_NET_IPT_CHAIN}" >/dev/null 2>&1 || true
if _calls | grep -q "^iptables -L"; then
_t_pass "iptw dispatches to the named binary"
else
_t_fail "iptw did not call the named binary"
fi
# --- refresh_uid_cache ------------------------------------------------------
printf 'com.foo\ncom.bar\n' >"${STATE_DIR}/night_whitelist.txt"
printf 'com.foo uid:10123\ncom.bar uid:10456\n' >"${PM_FIXTURE}"
rm -f "${CURFEW_NET_UID_CACHE}"
refresh_uid_cache
_t_eq "10123 10456" "$(tr '\n' ' ' <"${CURFEW_NET_UID_CACHE}" | sed 's/ $//')" \
"refresh_uid_cache writes the resolved uids"
if [[ ! -f "${CURFEW_NET_UID_CACHE}.tmp" ]]; then
_t_pass "refresh_uid_cache moves its temp file into place"
else
_t_fail "refresh_uid_cache left a .tmp file behind"
fi
# --- rebuild_net_from_cache -------------------------------------------------
_reset_ipt
rebuild_net_from_cache
if [[ -n "$(_rules_of iptables)" && -n "$(_rules_of ip6tables)" ]]; then
_t_pass "rebuild_net_from_cache builds both v4 and v6 chains"
else
_t_fail "rebuild_net_from_cache should build both address families"
fi
# --- net_hold ---------------------------------------------------------------
# Chain intact for the whole window: no rebuild.
_reset_ipt
_seed_jumps iptables 1
# Read back after assigning: the subject is what consumes these, but each
# file is linted standalone, so an assignment with no reader here is SC2034.
CURFEW_NET_REASSERT_INTERVAL=1
_t_eq "1" "${CURFEW_NET_REASSERT_INTERVAL}" "the reassert interval is set for net_hold"
_t_eq "0" "$(net_hold 2)" "net_hold does not rebuild while the chain survives"
# Chain missing: the first check rebuilds it, and because the rebuild
# actually creates the chain the second check finds it present. One rebuild
# for a single flush is the self-healing behaviour, not a missed one.
_reset_ipt
_t_eq "1" "$(net_hold 2)" "net_hold rebuilds a vanished chain and then settles"
# --- apply_net --------------------------------------------------------------
_reset_ipt
CURFEW_NET_ENABLED=0
apply_net
_t_eq "" "$(_calls)" "apply_net does nothing while the net layer is disabled"
CURFEW_NET_ENABLED=1
_t_eq "1" "${CURFEW_NET_ENABLED}" "the net layer is re-enabled for the remaining cases"
_reset_ipt
NET_BUILT=""
apply_net
_t_eq "1" "${NET_BUILT}" "apply_net marks the chain as built"
if [[ -n "$(_rules_of iptables)" && -n "$(_rules_of ip6tables)" ]]; then
_t_pass "apply_net builds both address families"
else
_t_fail "apply_net should build both address families"
fi
# A chain that vanished between ticks is logged, so a live test can tell
# "external flush + self-heal" from "the daemon died".
_reset_ipt
: >"${STATE_DIR}/log"
NET_BUILT=1
apply_net
if grep -q "vanished since last tick" "${STATE_DIR}/log"; then
_t_pass "apply_net logs an externally flushed chain"
else
_t_fail "apply_net should log when the chain vanished between ticks"
fi
# On a first build there is nothing to have vanished, so nothing is logged.
_reset_ipt
: >"${STATE_DIR}/log"
NET_BUILT=""
apply_net
if grep -q "vanished since last tick" "${STATE_DIR}/log"; then
_t_fail "apply_net logged a vanished chain on its first build"
else
_t_pass "apply_net stays quiet on the first build"
fi
# --- teardown_net -----------------------------------------------------------
_reset_ipt
_seed_jumps iptables 2
_seed_jumps ip6tables 1
teardown_net
# 2 jumps + 1 terminating failure on v4, 1 + 1 on v6 = 5.
_t_eq "5" "$(_calls | grep -c -- "-D OUTPUT -j FOCUS_CURFEW_NET")" \
"teardown_net removes every OUTPUT jump on both families"
_t_eq "2" "$(_calls | grep -c -- "-X FOCUS_CURFEW_NET")" \
"teardown_net deletes the chain on both families"
if [[ -f "${RUN}/ipt/iptables/chain" ]]; then
_t_fail "teardown_net left the v4 chain in place"
else
_t_pass "teardown_net leaves no chain behind"
fi

View File

@ -1,170 +0,0 @@
#!/usr/bin/env bash
# lib/tests/curfew_net_harness.sh — staging and stubs for the curfew_net.sh
# tests.
#
# Sourced, not executed. Unlike the monitor tests, the stubs here go on PATH:
# curfew_net.sh calls iptables, ip6tables and pm as real binaries, so the
# process boundary is where the fake has to sit. Modelled on
# dns_iptables_harness.sh, which stubs the same two firewall binaries.
#
# The stub records every call in order, which is what makes the rule ordering
# assertable — the ACCEPT rules must precede the 10000-19999 REJECT or the
# whitelist is meaningless.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly PHONE_DIR="${SCRIPT_DIR}/../.."
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
_t_eq() {
local want="$1" got="$2" what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
RUN="$(mktemp -d)"
trap 'rm -rf "${RUN}"' EXIT
mkdir -p "${RUN}/app" "${RUN}/bin" "${RUN}/state" "${RUN}/ipt"
cp "${PHONE_DIR}/curfew_net.sh" "${RUN}/app/"
# --- iptables/ip6tables stub ------------------------------------------------
#
# Models a per-binary chain flag, a rule list and the OUTPUT jump count. Every
# call is logged so ordering can be asserted. Failure injection mirrors
# dns_iptables_harness.sh: a test touches fail_<op> to make that operation
# report failure, which is how the error returns become reachable.
cat >"${RUN}/bin/iptables" <<'STUB'
#!/usr/bin/env bash
bin="$(basename "$0")"
store="${IPT_STATE}/${bin}"
mkdir -p "$store"
rules="${store}/rules"
touch "$rules"
# curfew_net always calls through iptw, which prepends `-w 2`. Drop it so the
# case below matches on the real operation.
[[ "$1" == "-w" ]] && shift 2
printf '%s %s\n' "$bin" "$*" >>"${IPT_STATE}/calls.log"
_fails() { [[ -f "${IPT_STATE}/fail_$1" ]]; }
_jumps_file="${store}/jumps"
[[ -f "$_jumps_file" ]] || echo 0 >"$_jumps_file"
case "$1" in
-L) [[ -f "${store}/chain" ]] && exit 0 || exit 1 ;;
-N)
_fails N && exit 1
touch "${store}/chain"
exit 0
;;
-F)
_fails F && exit 1
: >"$rules"
exit 0
;;
-X)
rm -f "${store}/chain"
exit 0
;;
-A)
shift
printf -- '%s\n' "$*" >>"$rules"
exit 0
;;
-I)
_fails I && exit 1
read -r n <"$_jumps_file"
echo $((n + 1)) >"$_jumps_file"
exit 0
;;
-D)
# Succeeds once per existing jump so the de-dupe loop terminates.
read -r n <"$_jumps_file"
if ((n > 0)); then
echo $((n - 1)) >"$_jumps_file"
exit 0
fi
exit 1
;;
esac
exit 0
STUB
chmod +x "${RUN}/bin/iptables"
cp "${RUN}/bin/iptables" "${RUN}/bin/ip6tables"
# `pm list packages -U` output, seeded per test via PM_FIXTURE.
cat >"${RUN}/bin/pm" <<'STUB'
#!/usr/bin/env bash
[[ -f "${PM_FIXTURE}" ]] && cat "${PM_FIXTURE}"
exit 0
STUB
chmod +x "${RUN}/bin/pm"
export IPT_STATE="${RUN}/ipt"
export PM_FIXTURE="${RUN}/state/pm.txt"
export PATH="${RUN}/bin:${PATH}"
# --- ambient globals the library reads from config.sh / the enforcer --------
STATE_DIR="${RUN}/state"
CURFEW_NET_IPT_CHAIN="FOCUS_CURFEW_NET"
CURFEW_NET_UID_CACHE="${RUN}/state/curfew_uids.cache"
CURFEW_NET_REASSERT_INTERVAL=1
CURFEW_NET_ENABLED=1
NET_BUILT=""
log() { printf '%s\n' "$*" >>"${RUN}/state/log"; }
# shellcheck source=../../curfew_net.sh
. "${RUN}/app/curfew_net.sh"
_reset_ipt() {
rm -rf "${RUN}/ipt"
mkdir -p "${RUN}/ipt"
}
# Make the next run report the given iptables operation as failing.
_fail_op() {
touch "${RUN}/ipt/fail_$1"
}
_clear_fail() {
rm -f "${RUN}/ipt/fail_$1"
}
# Pre-seed the chain as existing with N stale OUTPUT jumps, the lock-race
# state ensure_net_chain's de-dupe loop exists to clean up.
_seed_jumps() {
local bin="$1" count="$2"
mkdir -p "${RUN}/ipt/${bin}"
touch "${RUN}/ipt/${bin}/chain"
echo "$count" >"${RUN}/ipt/${bin}/jumps"
}
# Rules recorded for a binary, one per line.
_rules_of() {
cat "${RUN}/ipt/$1/rules" 2>/dev/null || printf ''
}
_calls() {
cat "${RUN}/ipt/calls.log" 2>/dev/null || printf ''
}

View File

@ -1,143 +0,0 @@
#!/usr/bin/env bash
# lib/tests/daemon_cases_apps.sh — assertions for daemon_apps.sh: which
# packages survive the focus-mode sweep, and what happens on the way in and
# out of focus mode.
#
# Sourced by test_daemon_libs.sh after daemon_libs_harness.sh, which owns the
# stubs, the subjects and the PASS/FAIL counters this file adds to.
#
# is_allowed is the single decision that determines whether an app keeps
# working. Everything below is a way of getting it wrong: the wrong list at
# night, a prefix that matches too much, a guard that resurrects a browser
# during curfew, or a default handler that gets disabled and leaves the phone
# without a keyboard after a reboot.
set -euo pipefail
_seed_lists() {
printf 'com.allowed.day\ncom.both\n' >"${STATE_DIR}/whitelist.txt"
printf 'com.both\n' >"${STATE_DIR}/night_whitelist.txt"
printf 'com.android.\n' >"${STATE_DIR}/sysprotect.txt"
: >"${STATE_DIR}/default_handlers.txt"
: >"${STATE_DIR}/default_browser.txt"
}
# --- is_allowed, day --------------------------------------------------------
_reset_dev
_seed_lists
NIGHT_CURFEW_ENABLED=0
if is_allowed "com.allowed.day"; then
_t_pass "a day-whitelisted package is allowed outside curfew"
else
_t_fail "the day whitelist must apply outside curfew"
fi
if ! is_allowed "com.random.app"; then
_t_pass "an unlisted package is not allowed"
else
_t_fail "an unlisted package must not be allowed"
fi
# Exact match on the whitelist. The query has to be a SUBSTRING of a listed
# entry, not a superstring: grep -qF "com.allowed" would match the line
# "com.allowed.day" and let an unlisted package through, while
# "com.allowed.dayplanner" fails under both -qF and -qxF and so proves
# nothing about which is in use.
if ! is_allowed "com.allowed"; then
_t_pass "whitelist matching is exact, not by substring"
else
_t_fail "a substring of a whitelist entry must not be allowed"
fi
if ! is_allowed "com.allowed.dayplanner"; then
_t_pass "a superstring of a whitelist entry is not allowed"
else
_t_fail "a superstring of a whitelist entry must not be allowed"
fi
# The sysprotect list IS a prefix list, deliberately, so a whole namespace
# stays reachable.
if is_allowed "com.android.settings"; then
_t_pass "the sysprotect list matches by prefix"
else
_t_fail "sysprotect must match by prefix"
fi
# Anchored at the START, not matched anywhere: "org.evil.com.android.thing"
# contains the protected prefix but is not in that namespace, and an
# unanchored match would silently protect any package that mentions it.
if ! is_allowed "org.evil.com.android.thing"; then
_t_pass "a sysprotect prefix must match at the start, not anywhere"
else
_t_fail "a sysprotect prefix must be anchored at the start"
fi
if ! is_allowed "org.android.fake"; then
_t_pass "a package outside the protected namespace is not protected"
else
_t_fail "only the protected namespace may match sysprotect"
fi
# The default-handler guard is independent of the whitelist, so a config edit
# can never disable the dialer, SMS app, launcher or keyboard.
printf 'com.dialer.app\n' >"${STATE_DIR}/default_handlers.txt"
if is_allowed "com.dialer.app"; then
_t_pass "a default handler is allowed even when not whitelisted"
else
_t_fail "the default-handler guard must override the whitelist"
fi
# The browser guard applies only outside curfew.
printf 'com.browser.app\n' >"${STATE_DIR}/default_browser.txt"
if is_allowed "com.browser.app"; then
_t_pass "the default browser is allowed outside curfew"
else
_t_fail "the default browser must be allowed outside curfew"
fi
# --- is_allowed, night curfew ----------------------------------------------
_reset_dev
_seed_lists
printf 'com.browser.app\n' >"${STATE_DIR}/default_browser.txt"
NIGHT_CURFEW_ENABLED=1
_set_now "0000"
# The strict list replaces the permissive one — this is the whole point of the
# curfew, and getting it backwards would leave the night unrestricted.
if is_allowed "com.both"; then
_t_pass "a night-whitelisted package is allowed during curfew"
else
_t_fail "the night whitelist must apply during curfew"
fi
if ! is_allowed "com.allowed.day"; then
_t_pass "a day-only package is blocked during curfew"
else
_t_fail "the day list must NOT apply during curfew"
fi
# The browser guard must not resurrect a browser at night.
if ! is_allowed "com.browser.app"; then
_t_pass "the default browser is blocked during curfew"
else
_t_fail "the browser guard must not re-allow a browser during curfew"
fi
# The sysprotect and default-handler guards still apply on top of the strict
# list, or the curfew could disable the keyboard.
if is_allowed "com.android.settings"; then
_t_pass "sysprotect still applies during curfew"
else
_t_fail "sysprotect must apply during curfew too"
fi
printf 'com.ime.app\n' >"${STATE_DIR}/default_handlers.txt"
if is_allowed "com.ime.app"; then
_t_pass "the default-handler guard still applies during curfew"
else
_t_fail "the keyboard must stay reachable during curfew"
fi
NIGHT_CURFEW_ENABLED=0
_t_eq "0" "${NIGHT_CURFEW_ENABLED}" \
"the curfew is switched back off for the sweep cases"

View File

@ -1,228 +0,0 @@
#!/usr/bin/env bash
# lib/tests/daemon_cases_location.sh — assertions for daemon_location.sh: the
# GPS fix, the Haversine distance, and the night-curfew window.
#
# Sourced by test_daemon_libs.sh after daemon_libs_harness.sh, which owns the
# stubs, the subjects and the PASS/FAIL counters this file adds to.
#
# The curfew window wraps past midnight (2300 -> 0500), which is the case a
# naive start<=now<end comparison gets exactly backwards: it would report
# curfew during the whole day and never at night.
set -euo pipefail
# --- get_location -----------------------------------------------------------
_reset_dev
_t_eq "" "$(get_location)" "location is empty when dumpsys reports no fix"
printf 'last location=Location[fused 52.2297,21.0122 hAcc=12]\n' >"${DEV}/dumpsys_out"
_t_eq "52.2297,21.0122" "$(get_location)" "location extracts a fused coordinate pair"
# Several fixes in one dump: the first is the freshest and the only one used.
printf 'Location[fused 52.2297,21.0122]\nLocation[gps 50.0000,19.0000]\n' >"${DEV}/dumpsys_out"
_t_eq "52.2297,21.0122" "$(get_location)" "location takes only the first fix"
# Too few decimal places is not a real fix and must not be matched, or the
# daemon would compute a distance from a truncated coordinate.
printf 'Location[fused 52.22,21.01]\n' >"${DEV}/dumpsys_out"
_t_eq "" "$(get_location)" "location ignores coordinates with too little precision"
printf 'Location[fused -33.8688,151.2093]\n' >"${DEV}/dumpsys_out"
_t_eq "-33.8688,151.2093" "$(get_location)" "location handles southern/negative coordinates"
# --- calc_distance ----------------------------------------------------------
_t_eq "0" "$(calc_distance 52.2297 21.0122 52.2297 21.0122)" \
"distance from a point to itself is zero"
# Warsaw to Krakow is ~252 km. A wrong radius or a degrees/radians slip shows
# up here as an answer off by orders of magnitude, not a rounding difference.
km="$(( $(calc_distance 52.2297 21.0122 50.0647 19.9450) / 1000 ))"
if [[ "${km}" -ge 245 && "${km}" -le 260 ]]; then
_t_pass "distance Warsaw-Krakow is ~252 km (got ${km} km)"
else
_t_fail "distance Warsaw-Krakow should be ~252 km, got ${km} km"
fi
# One degree of latitude is ~111 km anywhere on the globe.
deg="$(calc_distance 0 0 1 0)"
if [[ "${deg}" -ge 110000 && "${deg}" -le 112000 ]]; then
_t_pass "one degree of latitude is ~111 km (got ${deg} m)"
else
_t_fail "one degree of latitude should be ~111 km, got ${deg} m"
fi
# Symmetry: the distance cannot depend on which point is called home.
_t_eq "$(calc_distance 52.2297 21.0122 50.0647 19.9450)" \
"$(calc_distance 50.0647 19.9450 52.2297 21.0122)" \
"distance is symmetric"
# A short hop must still register, since the geofence threshold is ~100 m.
near="$(calc_distance 52.2297 21.0122 52.2306 21.0122)"
if [[ "${near}" -ge 80 && "${near}" -le 120 ]]; then
_t_pass "a 0.0009 degree step is ~100 m (got ${near} m)"
else
_t_fail "a 0.0009 degree step should be ~100 m, got ${near} m"
fi
# --- _dec -------------------------------------------------------------------
# Zero-padded times must not be read as octal: "0830" as octal is invalid and
# would abort the comparison, stranding the daemon on the wrong app list.
_t_eq "830" "$(_dec 0830)" "_dec strips the leading zero from 0830"
_t_eq "500" "$(_dec 0500)" "_dec strips the leading zero from 0500"
_t_eq "0" "$(_dec 0000)" "_dec keeps a single digit for midnight"
_t_eq "2300" "$(_dec 2300)" "_dec leaves an unpadded time alone"
_t_eq "9" "$(_dec 0009)" "_dec strips several leading zeros"
# --- is_curfew_now, wrapping window (2300 -> 0500) --------------------------
_reset_dev
NIGHT_CURFEW_START="2300"
NIGHT_CURFEW_END="0500"
for t in 2300 2359 0000 0430; do
_set_now "${t}"
if is_curfew_now; then
_t_pass "curfew is open at ${t} inside the wrapping window"
else
_t_fail "curfew should be open at ${t}"
fi
done
for t in 0500 1200 2259; do
_set_now "${t}"
if ! is_curfew_now; then
_t_pass "curfew is closed at ${t} outside the wrapping window"
else
_t_fail "curfew should be closed at ${t}"
fi
done
# --- is_curfew_now, non-wrapping window -------------------------------------
NIGHT_CURFEW_START="0900"
NIGHT_CURFEW_END="1700"
_set_now "1200"
if is_curfew_now; then
_t_pass "curfew is open midway through a same-day window"
else
_t_fail "curfew should be open at 1200 for a 0900-1700 window"
fi
_set_now "0900"
if is_curfew_now; then
_t_pass "a same-day window includes its start minute"
else
_t_fail "0900 should be inside a 0900-1700 window"
fi
# The end is exclusive, or two adjacent windows would both claim the boundary.
_set_now "1700"
if ! is_curfew_now; then
_t_pass "a same-day window excludes its end minute"
else
_t_fail "1700 should be outside a 0900-1700 window"
fi
_set_now "0800"
if ! is_curfew_now; then
_t_pass "curfew is closed before a same-day window opens"
else
_t_fail "0800 should be outside a 0900-1700 window"
fi
NIGHT_CURFEW_START="2300"
NIGHT_CURFEW_END="0500"
# Read back so the reset has a reader in this file (SC2034); the subject is
# what actually consumes it, but each file is linted standalone.
_t_eq "2300-0500" "${NIGHT_CURFEW_START}-${NIGHT_CURFEW_END}" \
"the wrapping window is restored for the remaining cases"
# A broken clock must fail OPEN — to the day list, not the strict one.
_set_now "not-a-time"
if ! is_curfew_now; then
_t_pass "a malformed clock reads as no curfew rather than permanent curfew"
else
_t_fail "a malformed clock must not enable the curfew"
fi
_set_now ""
if ! is_curfew_now; then
_t_pass "an empty clock reading reads as no curfew"
else
_t_fail "an empty clock must not enable the curfew"
fi
# --- curfew_active ----------------------------------------------------------
_reset_dev
_set_now "0000"
if curfew_active; then
_t_pass "curfew is active inside the window when enabled"
else
_t_fail "curfew should be active at 0000 when enabled"
fi
_reset_dev
_set_now "1200"
if ! curfew_active; then
_t_pass "curfew is inactive outside the window"
else
_t_fail "curfew should be inactive at 1200"
fi
# The force file is the test hook: it opens curfew regardless of the clock.
_reset_dev
_set_now "1200"
touch "${CURFEW_FORCE_FILE}"
if curfew_active; then
_t_pass "the force file opens curfew outside the window"
else
_t_fail "the force file should open curfew regardless of the clock"
fi
# The override must beat the force file, or there is no way out.
_reset_dev
touch "${CURFEW_OVERRIDE_FILE}"
_set_now "0000"
if ! curfew_active; then
_t_pass "the override file closes curfew even when forced"
else
_t_fail "the override must take precedence over the force file"
fi
rm -f "${CURFEW_OVERRIDE_FILE}" "${CURFEW_FORCE_FILE}"
_reset_dev
NIGHT_CURFEW_ENABLED=0
_set_now "0000"
if ! curfew_active; then
_t_pass "curfew is inactive while the feature is disabled"
else
_t_fail "curfew must be inactive when NIGHT_CURFEW_ENABLED is 0"
fi
NIGHT_CURFEW_ENABLED=1
_t_eq "1" "${NIGHT_CURFEW_ENABLED}" \
"the curfew is re-enabled for the memo cases"
# The memo exists to stop is_curfew_now forking `date` once per package in
# the sweep. Proven by moving the clock and requiring the answer not to move.
_reset_dev
_set_now "0000"
curfew_active
_set_now "1200"
if curfew_active; then
_t_pass "curfew_active answers from the per-tick memo, not the clock"
else
_t_fail "the memo should hold within a tick even as the clock moves"
fi
# Clearing the memo is what main does each tick; the answer must then follow
# the clock again, or a curfew could never end.
_CURFEW_TICK_CACHED=""
if ! curfew_active; then
_t_pass "clearing the memo lets the answer follow the clock again"
else
_t_fail "a cleared memo must be recomputed"
fi

View File

@ -1,184 +0,0 @@
#!/usr/bin/env bash
# lib/tests/daemon_cases_state.sh — assertions for daemon_state.sh: the state
# files derived from config, the cached default intent handlers, and the JSON
# status snapshot the companion app reads.
#
# Sourced by test_daemon_libs.sh after daemon_libs_harness.sh, which owns the
# stubs, the subjects and the PASS/FAIL counters this file adds to.
set -euo pipefail
# `settings` is only reached from refresh_default_handlers; stubbed here
# rather than in the harness because these are its only callers.
cat >"${RUN}/bin/settings" <<'STUB'
#!/usr/bin/env bash
printf 'settings %s\n' "$*" >>"${DEV}/calls.log"
key="${*: -1}"
[[ -f "${DEV}/setting_${key}" ]] && cat "${DEV}/setting_${key}"
exit 0
STUB
chmod +x "${RUN}/bin/settings"
# --- build_whitelist_file ---------------------------------------------------
_reset_dev
WHITELIST="com.one
com.two
# a comment
com.three "
build_whitelist_file
parsed="$(cat "${STATE_DIR}/whitelist.txt")"
_t_eq "com.one com.two com.three" "$(printf '%s' "${parsed}" | tr '\n' ' ' | sed 's/ $//')" \
"whitelist drops comments and blanks and trims whitespace"
# The floor warning exists because a stray quote in config.sh silently
# truncates the WHITELIST string; a short list must be loud, not silent.
case "$(_log)" in
*"WARN: whitelist suspiciously small"*) _t_pass "a short whitelist warns loudly" ;;
*) _t_fail "a whitelist under the floor must warn" ;;
esac
case "$(_log)" in
*"Whitelist parsed: 3 entries"*) _t_pass "whitelist logs the parsed entry count" ;;
*) _t_fail "whitelist should log its entry count" ;;
esac
# A healthy list must not cry wolf, or the warning stops meaning anything.
_reset_dev
WHITELIST="$(for i in $(seq 1 40); do printf 'com.app%02d\n' "$i"; done)"
build_whitelist_file
if _log | grep -q "WARN: whitelist suspiciously small"; then
_t_fail "a 40-entry whitelist must not warn"
else
_t_pass "a healthy whitelist does not warn"
fi
# Both sides checked at once: the parsed file must hold every seeded entry,
# and reading WHITELIST back gives it a reader in this file (SC2034).
_t_eq "40-40" "$(wc -l <"${STATE_DIR}/whitelist.txt")-$(printf '%s\n' "${WHITELIST}" | wc -l)" \
"the whitelist keeps every one of the 40 seeded entries"
# --- build_night_whitelist_file --------------------------------------------
_reset_dev
NIGHT_WHITELIST="com.night.one
# comment
com.night.two"
build_night_whitelist_file
_t_eq "com.night.one com.night.two" \
"$(tr '\n' ' ' <"${STATE_DIR}/night_whitelist.txt" | sed 's/ $//')" \
"night whitelist parses like the day list"
case "$(_log)" in
*"WARN: night whitelist suspiciously small"*) _t_pass "a short night whitelist warns" ;;
*) _t_fail "a night whitelist under its floor must warn" ;;
esac
# The two lists have different floors (30 and 10), so a list that is fine for
# one must not be judged by the other's threshold.
_reset_dev
NIGHT_WHITELIST="$(for i in $(seq 1 12); do printf 'com.night%02d\n' "$i"; done)"
build_night_whitelist_file
_t_eq "12" "$(printf '%s\n' "${NIGHT_WHITELIST}" | wc -l)" "12 night entries were seeded"
if _log | grep -q "WARN: night whitelist"; then
_t_fail "12 night entries is above the night floor and must not warn"
else
_t_pass "the night whitelist uses its own lower floor"
fi
# --- build_sysprotect_file / build_blocked_sys_file ------------------------
_reset_dev
SYSTEM_NEVER_DISABLE="com.android.
com.google.android.gms"
build_sysprotect_file
_t_eq "2" "$(wc -l <"${STATE_DIR}/sysprotect.txt")" "sysprotect writes every prefix"
BLOCKED_SYSTEM_APPS="com.android.browser
# not this one
com.android.chrome"
build_blocked_sys_file
_t_eq "com.android.browser com.android.chrome" \
"$(tr '\n' ' ' <"${STATE_DIR}/blocked_sys.txt" | sed 's/ $//')" \
"blocked-system list drops comments"
# Read the two seeds back so each has a reader in this file (SC2034); the
# subject is what actually consumes them.
_t_eq "2-3" "$(printf '%s\n' "${SYSTEM_NEVER_DISABLE}" | wc -l)-$(printf '%s\n' "${BLOCKED_SYSTEM_APPS}" | wc -l)" \
"the seeded sysprotect and blocked-system lists held 2 and 3 lines"
# --- refresh_default_handlers ----------------------------------------------
_reset_dev
printf 'Activity Resolver Table:\ncom.launcher.home/.MainActivity\n' >"${DEV}/cmd_out"
printf 'com.dialer.app\n' >"${DEV}/setting_get"
printf 'com.sms.app\n' >"${DEV}/setting_sms_default_application"
printf 'com.ime.app/.Service\n' >"${DEV}/setting_default_input_method"
refresh_default_handlers
handlers="$(cat "${STATE_DIR}/default_handlers.txt")"
case "${handlers}" in
*com.launcher.home*) _t_pass "default handlers include the launcher" ;;
*) _t_fail "default handlers must include the launcher" ;;
esac
# The IME is the load-bearing one: pm disable-user on the active keyboard
# survives a reboot, so a 1am reboot would leave no way to type a recovery
# command. It has to be protected day and night.
case "${handlers}" in
*com.ime.app*) _t_pass "default handlers include the active keyboard" ;;
*) _t_fail "the active IME must be protected or a reboot locks you out" ;;
esac
# Only the package part of the IME component is usable as a package name.
if grep -q "com.ime.app/" "${STATE_DIR}/default_handlers.txt"; then
_t_fail "the IME component suffix leaked into the package list"
else
_t_pass "the IME is recorded as a package, not a component"
fi
# A literal "null" from the settings provider is an absent value, not a
# package, and must never be written as one.
_reset_dev
printf 'Activity Resolver Table:\ncom.launcher.home/.MainActivity\n' >"${DEV}/cmd_out"
printf 'null\n' >"${DEV}/setting_sms_default_application"
printf 'null\n' >"${DEV}/setting_default_input_method"
refresh_default_handlers
if grep -qx "null" "${STATE_DIR}/default_handlers.txt"; then
_t_fail "a literal null was recorded as a default handler"
else
_t_pass "a null settings value is not recorded as a handler"
fi
if [[ -f "${STATE_DIR}/default_handlers.txt.tmp" ]]; then
_t_fail "refresh left its temp file behind"
else
_t_pass "refresh cleans up its temp file"
fi
# The browser is tracked separately, because during curfew the whole point is
# to disable browsers — the default-handler guard must not resurrect them.
if [[ -f "${STATE_DIR}/default_browser.txt" ]]; then
_t_pass "the default browser is tracked in its own file"
else
_t_fail "the default browser must be tracked separately"
fi
# --- is_default_handler -----------------------------------------------------
_reset_dev
printf 'com.launcher.home\ncom.ime.app\n' >"${STATE_DIR}/default_handlers.txt"
if is_default_handler "com.launcher.home"; then
_t_pass "a listed package is recognised as a default handler"
else
_t_fail "a listed package must be recognised"
fi
if ! is_default_handler "com.other.app"; then
_t_pass "an unlisted package is not a default handler"
else
_t_fail "an unlisted package must not be recognised"
fi
# Exact match only: a prefix would protect far more than intended.
if ! is_default_handler "com.launcher"; then
_t_pass "default-handler matching is exact, not by prefix"
else
_t_fail "a prefix must not match a default handler"
fi

View File

@ -1,80 +0,0 @@
#!/usr/bin/env bash
# lib/tests/daemon_cases_status.sh — assertions for write_status_snapshot, the
# JSON file the companion notification app polls.
#
# Sourced by test_daemon_libs.sh after daemon_cases_state.sh, which owns the
# stubs and counters. Split out to stay under the 250-line cap.
set -euo pipefail
# --- write_status_snapshot --------------------------------------------------
_reset_dev
printf 'com.a\ncom.b\ncom.c\n' >"${DISABLED_APPS_FILE}"
_set_now "1200"
write_status_snapshot "focus" "52.2297" "21.0122" "42" "100"
if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${STATUS_FILE}"; then
_t_pass "the status snapshot is parseable JSON"
else
_t_fail "the status snapshot must be valid JSON"
fi
_field() {
python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' \
"${STATUS_FILE}" "$1"
}
_t_eq "focus" "$(_field mode)" "the snapshot records the mode"
_t_eq "52.2297" "$(_field lat)" "the snapshot records the latitude"
_t_eq "42" "$(_field distance_m)" "the snapshot records the distance"
_t_eq "100" "$(_field threshold_m)" "the snapshot records the threshold"
_t_eq "3" "$(_field disabled_count)" "the snapshot counts the disabled apps"
# Absent coordinates must still produce valid JSON: distance and threshold
# become null, not an empty token that would break the companion app's parse.
_reset_dev
: >"${DISABLED_APPS_FILE}"
write_status_snapshot "normal" "" "" "" ""
if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${STATUS_FILE}"; then
_t_pass "a snapshot with no fix is still valid JSON"
else
_t_fail "a snapshot with no fix must remain valid JSON"
fi
_t_eq "None" "$(_field distance_m)" "a missing distance is written as JSON null"
_t_eq "0" "$(_field disabled_count)" "an empty disabled list counts as zero"
# The curfew flags are what the companion app renders, so they must track the
# files rather than being hardcoded.
_reset_dev
touch "${CURFEW_OVERRIDE_FILE}"
write_status_snapshot "normal" "" "" "" ""
_t_eq "1" "$(_field curfew_override)" "the snapshot reports an active override"
_t_eq "0" "$(_field curfew)" "curfew reads inactive while overridden"
rm -f "${CURFEW_OVERRIDE_FILE}"
_reset_dev
touch "${CURFEW_FORCE_FILE}"
write_status_snapshot "normal" "" "" "" ""
_t_eq "1" "$(_field curfew_force)" "the snapshot reports the force flag"
_t_eq "1" "$(_field curfew)" "curfew reads active while forced"
rm -f "${CURFEW_FORCE_FILE}"
if [[ -f "${STATUS_FILE}.tmp" ]]; then
_t_fail "the snapshot left its temp file behind"
else
_t_pass "the snapshot moves its temp file into place"
fi
# An unwritable status file must not take the daemon down with it: the
# snapshot is for the companion app, and losing it is not worth losing
# enforcement. Proven by pointing STATUS_FILE into a directory that does not
# exist, so the redirect fails.
_reset_dev
_saved_status="${STATUS_FILE}"
STATUS_FILE="${RUN}/state/no-such-dir/status.json"
if write_status_snapshot "focus" "" "" "" ""; then
_t_pass "an unwritable snapshot returns success rather than aborting"
else
_t_fail "a failed snapshot write must not propagate a failure"
fi
STATUS_FILE="${_saved_status}"

View File

@ -1,173 +0,0 @@
#!/usr/bin/env bash
# lib/tests/daemon_cases_sweep.sh — assertions for the focus-mode transitions
# in daemon_apps.sh: the sweep that disables non-whitelisted packages, the
# restore pass, and the reconciliation that brings back anything that has
# since become allowed.
#
# Sourced by test_daemon_libs.sh after daemon_cases_apps.sh, which owns
# _seed_lists and the counters. Split out to stay under the 250-line cap.
set -euo pipefail
# --- enable_focus_mode ------------------------------------------------------
_reset_dev
_seed_lists
CURRENT_MODE="normal"
: >"${DISABLED_APPS_FILE}"
_seed_packages com.allowed.day com.random.one com.random.two
: >"${STATE_DIR}/blocked_sys.txt"
enable_focus_mode
_t_eq "focus" "${CURRENT_MODE}" "entering focus mode records the mode"
disabled="$(_disabled | sort | tr '\n' ' ' | sed 's/ $//')"
_t_eq "com.random.one com.random.two" "${disabled}" \
"focus mode disables exactly the non-whitelisted packages"
# Both packages, not just the first. The loop feeding pm reads its list by
# redirect precisely so pm cannot drain its stdin — this assertion is what
# would catch that regression.
_t_eq "2" "$(_disabled | wc -l)" "focus mode processes every package, not just the first"
recorded="$(sort "${DISABLED_APPS_FILE}" | tr '\n' ' ' | sed 's/ $//')"
_t_eq "com.random.one com.random.two" "${recorded}" \
"focus mode records what it disabled so it can be undone"
# Blocked system apps are disabled too, from their own list.
_reset_dev
_seed_lists
CURRENT_MODE="normal"
: >"${DISABLED_APPS_FILE}"
_seed_packages com.allowed.day
printf 'com.android.browser\ncom.android.other\n' >"${STATE_DIR}/blocked_sys.txt"
enable_focus_mode
case "$(_disabled)" in
*com.android.browser*) _t_pass "focus mode disables blocked system apps" ;;
*) _t_fail "blocked system apps must be disabled" ;;
esac
_t_eq "2" "$(_disabled | wc -l)" "every blocked system app is processed"
# A package pm refuses to disable must not be recorded as disabled, or the
# restore pass would claim to re-enable something it never touched.
_reset_dev
_seed_lists
CURRENT_MODE="normal"
: >"${DISABLED_APPS_FILE}"
_seed_packages com.random.one com.stubborn
printf 'com.stubborn\n' >"${DEV}/pm_undisableable"
: >"${STATE_DIR}/blocked_sys.txt"
enable_focus_mode
if grep -qx "com.stubborn" "${DISABLED_APPS_FILE}"; then
_t_fail "a package pm refused to disable was recorded as disabled"
else
_t_pass "only packages pm actually disabled are recorded"
fi
# Re-entering focus mode must not wipe the record of what is already disabled.
_reset_dev
_seed_lists
CURRENT_MODE="focus"
printf 'com.already.off\n' >"${DISABLED_APPS_FILE}"
_seed_packages com.allowed.day
: >"${STATE_DIR}/blocked_sys.txt"
enable_focus_mode
if grep -qx "com.already.off" "${DISABLED_APPS_FILE}"; then
_t_pass "a repeat sweep keeps the existing disabled record"
else
_t_fail "a repeat sweep must not wipe the disabled record"
fi
# --- disable_focus_mode -----------------------------------------------------
_reset_dev
CURRENT_MODE="focus"
printf 'com.random.one\ncom.random.two\n' >"${DISABLED_APPS_FILE}"
disable_focus_mode
_t_eq "normal" "${CURRENT_MODE}" "leaving focus mode records the mode"
_t_eq "com.random.one com.random.two" \
"$(_enabled | sort | tr '\n' ' ' | sed 's/ $//')" \
"leaving focus mode re-enables everything it disabled"
_t_eq "2" "$(_enabled | wc -l)" "every disabled package is re-enabled, not just the first"
# Already normal: nothing to do, and nothing should be touched.
_reset_dev
CURRENT_MODE="normal"
printf 'com.random.one\n' >"${DISABLED_APPS_FILE}"
disable_focus_mode
_t_eq "" "$(_enabled)" "leaving focus mode is a no-op when already normal"
# --- reconcile_disabled_apps ------------------------------------------------
# A package that has since become allowed must be brought back, and dropped
# from the record — otherwise it stays disabled forever.
_reset_dev
_seed_lists
printf 'com.allowed.day\ncom.random.one\n' >"${DISABLED_APPS_FILE}"
reconcile_disabled_apps
case "$(_enabled)" in
*com.allowed.day*) _t_pass "reconciliation re-enables a now-allowed package" ;;
*) _t_fail "a package that became allowed must be re-enabled" ;;
esac
_t_eq "com.random.one" "$(cat "${DISABLED_APPS_FILE}")" \
"reconciliation keeps only the packages that are still blocked"
case "$(_log)" in
*"Re-enabled allowed app during state reconciliation"*)
_t_pass "reconciliation logs what it restored" ;;
*) _t_fail "reconciliation should log the packages it restored" ;;
esac
# install-existing runs before enable: a package removed for user 0 has to be
# reinstalled for that user before it can be enabled at all.
calls="$(_calls)"
inst="$(printf '%s\n' "${calls}" | grep -n "install-existing" | head -1 | cut -d: -f1)"
en="$(printf '%s\n' "${calls}" | grep -n "^pm enable com.allowed.day" | head -1 | cut -d: -f1)"
if [[ -n "${inst}" && -n "${en}" && "${en}" -gt "${inst}" ]]; then
_t_pass "reconciliation reinstalls for user 0 before enabling"
else
_t_fail "install-existing must precede enable"
fi
# No record file at all is a fresh install, not an error. The call is guarded
# because the bare `return` propagates the failed [ -f ] status; the daemon
# runs without set -e so that is harmless there, but it would abort this file.
_reset_dev
_seed_lists
rm -f "${DISABLED_APPS_FILE}"
reconcile_disabled_apps || true
_t_eq "" "$(_enabled)" "reconciliation is a no-op with no disabled record"
if [[ -f "${STATE_DIR}/disabled_by_focus.tmp" ]]; then
_t_fail "reconciliation left its temp file behind"
else
_t_pass "reconciliation moves its temp file into place"
fi
# A re-sweep that finds apps the user re-enabled must say so: silence here
# would hide the single most useful signal that enforcement is being fought.
_reset_dev
_seed_lists
CURRENT_MODE="focus"
: >"${DISABLED_APPS_FILE}"
_seed_packages com.random.one
: >"${STATE_DIR}/blocked_sys.txt"
enable_focus_mode
case "$(_log)" in
*"re-disabled 1 apps"*) _t_pass "a re-sweep reports what the user re-enabled" ;;
*) _t_fail "a re-sweep must report re-disabled apps" ;;
esac
# A re-sweep with nothing to do stays quiet, or the log fills with noise every
# tick and the line above stops carrying information.
_reset_dev
_seed_lists
CURRENT_MODE="focus"
printf 'com.random.one\n' >"${DISABLED_APPS_FILE}"
_seed_packages com.allowed.day
: >"${STATE_DIR}/blocked_sys.txt"
enable_focus_mode
if _log | grep -q "re-sweep"; then
_t_fail "a re-sweep with nothing to do must not log"
else
_t_pass "a quiet re-sweep stays quiet"
fi

View File

@ -1,160 +0,0 @@
#!/usr/bin/env bash
# lib/tests/daemon_libs_harness.sh — the fake device behind the three
# libraries split out of focus_daemon.sh.
#
# Sourced, not executed. All three drive the phone through real binaries — pm,
# dumpsys, cmd, date — so the stubs go on PATH.
#
# `date` is stubbed rather than mocked as a function because is_curfew_now
# calls it in a command substitution; a shell function would work here but
# would not survive the subject being run any other way, and the point of the
# midnight-wrap cases is to pin the clock precisely.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly PHONE_DIR="${SCRIPT_DIR}/../.."
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
_t_eq() {
local want="$1" got="$2" what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
RUN="$(mktemp -d)"
trap 'rm -rf "${RUN}"' EXIT
mkdir -p "${RUN}/bin" "${RUN}/state" "${RUN}/dev"
readonly DEV="${RUN}/dev"
# --- stubs ------------------------------------------------------------------
# pm records every call and honours a per-package failure flag, so the
# "disable refused" branches are reachable.
cat >"${RUN}/bin/pm" <<'STUB'
#!/usr/bin/env bash
printf 'pm %s\n' "$*" >>"${DEV}/calls.log"
case "$1" in
list)
[[ -f "${DEV}/pm_list" ]] && cat "${DEV}/pm_list"
exit 0
;;
disable-user)
pkg="${*: -1}"
grep -qxF "${pkg}" "${DEV}/pm_undisableable" 2>/dev/null && exit 1
printf '%s\n' "${pkg}" >>"${DEV}/disabled"
exit 0
;;
enable)
pkg="${*: -1}"
grep -qxF "${pkg}" "${DEV}/pm_unenableable" 2>/dev/null && exit 1
printf '%s\n' "${pkg}" >>"${DEV}/enabled"
exit 0
;;
esac
exit 0
STUB
chmod +x "${RUN}/bin/pm"
cat >"${RUN}/bin/dumpsys" <<'STUB'
#!/usr/bin/env bash
printf 'dumpsys %s\n' "$*" >>"${DEV}/calls.log"
[[ -f "${DEV}/dumpsys_out" ]] && cat "${DEV}/dumpsys_out"
exit 0
STUB
chmod +x "${RUN}/bin/dumpsys"
cat >"${RUN}/bin/cmd" <<'STUB'
#!/usr/bin/env bash
printf 'cmd %s\n' "$*" >>"${DEV}/calls.log"
[[ -f "${DEV}/cmd_out" ]] && cat "${DEV}/cmd_out"
exit 0
STUB
chmod +x "${RUN}/bin/cmd"
# `date` answers from a pinned clock when one is set, so the curfew-window
# cases are deterministic rather than dependent on when the suite runs.
cat >"${RUN}/bin/date" <<'STUB'
#!/usr/bin/env bash
if [[ -f "${DEV}/now" && "$1" == "+%H%M" ]]; then
cat "${DEV}/now"
exit 0
fi
exec /usr/bin/date "$@"
STUB
chmod +x "${RUN}/bin/date"
export DEV
export PATH="${RUN}/bin:${PATH}"
# --- ambient definitions the libraries expect from the daemon --------------
log() { printf '%s\n' "$*" >>"${DEV}/log"; }
STATE_DIR="${RUN}/state"
STATUS_FILE="${RUN}/state/status.json"
MODE_FILE="${RUN}/state/mode"
DISABLED_APPS_FILE="${RUN}/state/disabled_apps.txt"
CURFEW_FORCE_FILE="${RUN}/state/curfew_force"
CURFEW_OVERRIDE_FILE="${RUN}/state/curfew_override"
NIGHT_CURFEW_ENABLED=1
NIGHT_CURFEW_START="2300"
NIGHT_CURFEW_END="0500"
WHITELIST="com.allowed.one com.allowed.two"
NIGHT_WHITELIST="com.night.only"
SYSTEM_NEVER_DISABLE="com.android."
BLOCKED_SYSTEM_APPS="com.android.browser"
RADIUS=100
# Written by init/enable/disable in the entry script and read by main, so it
# lives there; the harness stands in for that ownership.
CURRENT_MODE="normal"
_CURFEW_TICK_CACHED=""
_CURFEW_TICK_RESULT=0
# shellcheck source=../../daemon_location.sh
. "${PHONE_DIR}/daemon_location.sh"
# shellcheck source=../../daemon_state.sh
. "${PHONE_DIR}/daemon_state.sh"
# shellcheck source=../../daemon_apps.sh
. "${PHONE_DIR}/daemon_apps.sh"
# --- helpers ----------------------------------------------------------------
_reset_dev() {
rm -rf "${DEV}"
mkdir -p "${DEV}"
_CURFEW_TICK_CACHED=""
_CURFEW_TICK_RESULT=0
}
_set_now() { printf '%s\n' "$1" >"${DEV}/now"; }
_calls() { cat "${DEV}/calls.log" 2>/dev/null || printf ''; }
_log() { cat "${DEV}/log" 2>/dev/null || printf ''; }
_disabled() { cat "${DEV}/disabled" 2>/dev/null || printf ''; }
_enabled() { cat "${DEV}/enabled" 2>/dev/null || printf ''; }
# Seed the installed third-party package list `pm list packages -3 -e` returns.
_seed_packages() {
local pkg=""
: >"${DEV}/pm_list"
for pkg in "$@"; do
printf 'package:%s\n' "${pkg}" >>"${DEV}/pm_list"
done
}

View File

@ -1,174 +0,0 @@
#!/usr/bin/env bash
# dns_iptables_harness.sh — staging and assertions shared by the
# dns_iptables.sh test runners.
#
# Sourced, not executed. Stages dns_enforcer.sh + dns_iptables.sh into a temp
# dir beside a fake config.sh, stubs iptables/ip6tables/settings, and exposes
# _with_subject to run a snippet against the staged copy. The caller owns PASS
# and FAIL and prints the tally.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly PHONE_DIR="${SCRIPT_DIR}/../.."
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
# Assert two strings match, naming the expectation either way.
_t_eq() {
local want="$1" got="$2" what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
RUN="$(mktemp -d)"
trap 'rm -rf "${RUN}"' EXIT
mkdir -p "${RUN}/app" "${RUN}/bin" "${RUN}/state" "${RUN}/ipt"
cp "${PHONE_DIR}/dns_enforcer.sh" "${PHONE_DIR}/dns_iptables.sh" "${RUN}/app/"
# --- fake config.sh in the script's own dir (dns_enforcer sources a sibling) ---
cat >"${RUN}/app/config.sh" <<EOF
export STATE_DIR="${RUN}/state"
export DNS_LOG="${RUN}/state/dns.log"
export DNS_IPT_CHAIN="FOCUS_DNS_BLOCK"
export DNS_CHECK_INTERVAL=1
export DNS_DOH_IPV4="1.1.1.1 8.8.8.8"
export DNS_DOH_IPV6="2606:4700:4700::1111"
export DNS_TRUSTED_DOT_HOST="\${TEST_DOT_HOST:-}"
export DNS_TRUSTED_DOT_IPS="\${TEST_DOT_IPS:-}"
EOF
# --- iptables/ip6tables stub: a rule list per binary, plus the OUTPUT jump ---
cat >"${RUN}/bin/iptables" <<'STUB'
#!/usr/bin/env bash
# Models only what the functions under test inspect: a per-binary rule file
# and a flag for the OUTPUT -> chain jump. Every call is logged so tests can
# assert on ordering, which is what makes the ACCEPT-before-REJECT rule
# checkable without a real firewall.
bin="$(basename "$0")"
store="${IPT_STATE}/${bin}"
mkdir -p "$store"
rules="${store}/rules"
jump="${store}/jump"
touch "$rules"
printf '%s %s\n' "$bin" "$*" >>"${IPT_STATE}/calls.log"
# Failure injection. A test writes the operation it wants to fail into
# ${IPT_STATE}/fail_<op>; the stub then reports that operation as failing,
# which is how the error branches inside ensure_chain become reachable.
_fails() { [[ -f "${IPT_STATE}/fail_$1" ]]; }
# Pre-seeded jump count, so the de-dupe path (more than one existing jump)
# can be exercised. Defaults to the single flag file.
_jumps_file="${store}/jumps"
[[ -f "$_jumps_file" ]] || { [[ -f "$jump" ]] && echo 1 >"$_jumps_file"; }
case "$1" in
-L) [[ -f "${store}/chain" ]] && exit 0 || exit 1 ;;
-N)
_fails N && exit 1
touch "${store}/chain"
exit 0
;;
-F) : >"$rules"; exit 0 ;;
-A)
shift
printf -- '-A %s\n' "$*" >>"$rules"
exit 0
;;
-I)
_fails I && exit 1
touch "$jump"
echo 1 >"$_jumps_file"
exit 0
;;
-D)
# Succeeds once per existing jump, so the de-dupe loop terminates.
n=0
[[ -f "$_jumps_file" ]] && read -r n <"$_jumps_file"
if ((n > 0)); then
echo $((n - 1)) >"$_jumps_file"
((n - 1 == 0)) && rm -f "$jump"
exit 0
fi
rm -f "$jump"
exit 1
;;
-C) [[ -f "$jump" ]] && exit 0 || exit 1 ;;
-S) cat "$rules"; exit 0 ;;
esac
exit 0
STUB
chmod +x "${RUN}/bin/iptables"
cp "${RUN}/bin/iptables" "${RUN}/bin/ip6tables"
# `settings` is only reached via ensure_private_dns_*, which these tests do
# not exercise; stubbed so an accidental call cannot hit a real binary.
cat >"${RUN}/bin/settings" <<'STUB'
#!/usr/bin/env bash
echo "null"
STUB
chmod +x "${RUN}/bin/settings"
# Build a sourceable copy: main() would otherwise loop forever. The pattern
# uses a character class for the dollar so that no literal expansion appears
# in this file, keeping the intent readable without a lint suppression.
sed 's/^main "[$]@"$/: # main disabled under test/' \
"${RUN}/app/dns_enforcer.sh" >"${RUN}/app/under_test.sh"
# Run a snippet with the subject sourced, under the stub PATH. Executed as a
# subprocess rather than sourced here so that $0 — and therefore SCRIPT_DIR —
# resolves to the app dir, which is how the script finds its siblings.
#
# The snippet is read from stdin rather than taken as an argument: it is code
# for the *subject* to expand, so any $VAR in it must survive this file
# untouched. A heredoc says that plainly, where a quoted argument would look
# like an expansion bug and draw SC2016.
_with_subject() {
{
cat <<'PRELUDE'
#!/usr/bin/env bash
. "$(cd "$(dirname "$0")" && pwd)/under_test.sh"
PRELUDE
cat
} >"${RUN}/app/case.sh"
PATH="${RUN}/bin:${PATH}" IPT_STATE="${RUN}/ipt" \
TEST_DOT_HOST="${TEST_DOT_HOST:-}" TEST_DOT_IPS="${TEST_DOT_IPS:-}" \
bash "${RUN}/app/case.sh"
}
_reset_ipt() {
rm -rf "${RUN}/ipt"
mkdir -p "${RUN}/ipt"
}
# Make the next run report the given iptables operation as failing, so the
# error branches in ensure_chain are reachable. "N" is chain creation,
# "I" is inserting the OUTPUT jump.
_fail_op() {
touch "${RUN}/ipt/fail_$1"
}
# Pre-seed the chain as existing with N stale OUTPUT jumps, which is the
# lock-race state ensure_chain's de-dupe loop exists to clean up.
_seed_jumps() {
local bin="$1" count="$2"
mkdir -p "${RUN}/ipt/${bin}"
touch "${RUN}/ipt/${bin}/chain" "${RUN}/ipt/${bin}/jump"
echo "$count" >"${RUN}/ipt/${bin}/jumps"
}

View File

@ -1,114 +0,0 @@
#!/usr/bin/env bash
# lib/tests/hosts_cases_assert.sh — assertions for assert_bind_mount, the
# recovery path of hosts_mount.sh: bind the canonical over a wrong target,
# and fall back to a direct overwrite when the bind will not take.
#
# Sourced by test_hosts_libs.sh after hosts_cases_mount.sh, which owns the
# stubs and counters. Split out to stay under the 250-line cap.
set -euo pipefail
# --- assert_bind_mount ------------------------------------------------------
# Already correct: nothing should be mounted or unmounted at all.
_reset_dev
printf '127.0.0.1 blocked.example\n' >"${HOSTS_CANONICAL}"
_copy "${HOSTS_CANONICAL}" "${HOSTS_TARGET}"
if assert_bind_mount; then
_t_pass "assert succeeds without work when already mounted"
else
_t_fail "assert should succeed when the target already matches"
fi
if _calls | grep -q "^mount --bind"; then
_t_fail "assert re-mounted a target that was already correct"
else
_t_pass "assert does not re-mount an already-correct target"
fi
# Wrong content: the bind mount is what fixes it.
_reset_dev
printf '127.0.0.1 tampered\n' >"${HOSTS_TARGET}"
if assert_bind_mount; then
_t_pass "assert bind-mounts the canonical over a tampered target"
else
_t_fail "assert should recover a tampered target via bind mount"
fi
case "$(_log)" in
*"Bind-mounted"*) _t_pass "assert logs the successful bind mount" ;;
*) _t_fail "assert should log a successful bind mount" ;;
esac
# Bind reports success but changes nothing — the case the code explicitly
# guards, since a silent no-op mount would otherwise read as enforced.
_reset_dev
printf '127.0.0.1 tampered\n' >"${HOSTS_TARGET}"
touch "${DEV}/bind_is_noop"
assert_bind_mount || true
case "$(_log)" in
*"still mismatches"*) _t_pass "assert detects a bind mount that silently did nothing" ;;
*) _t_fail "assert must notice a no-op bind mount" ;;
esac
case "$(_log)" in
*"falling back to direct overwrite"*) _t_pass "assert falls back to direct overwrite" ;;
*) _t_fail "assert should fall back when the bind does not take" ;;
esac
rm -f "${DEV}/bind_is_noop"
# Bind fails outright: the fallback path has to run and succeed.
_reset_dev
printf '127.0.0.1 tampered\n' >"${HOSTS_TARGET}"
_fail_op mount
assert_bind_mount || true
case "$(_log)" in
*"Bind mount failed"*) _t_pass "assert logs an outright bind-mount failure" ;;
*) _t_fail "assert should log when the bind mount fails" ;;
esac
_clear_fail mount
# Nothing works: assert must report failure rather than claim enforcement.
_reset_dev
printf '127.0.0.1 tampered\n' >"${HOSTS_TARGET}"
touch "${DEV}/bind_is_noop"
_fail_op cp
if ! assert_bind_mount; then
_t_pass "assert fails when neither bind nor overwrite can fix the target"
else
_t_fail "assert must not report success when the target is still wrong"
fi
_clear_fail cp
rm -f "${DEV}/bind_is_noop"
# A mount that survives five umount attempts must be reported as unclearable,
# not retried forever. The stub is made to "succeed" while leaving the entry
# in place, which is exactly a mount that will not go away.
_reset_dev
_seed_mount
touch "${DEV}/umount_is_noop"
if ! unmount_existing_hosts_mount; then
_t_pass "unmount reports failure after five attempts"
else
_t_fail "unmount must fail once it has given up"
fi
case "$(_log)" in
*"after 5 attempts"*) _t_pass "unmount logs that it gave up" ;;
*) _t_fail "unmount should log giving up" ;;
esac
# The count is asserted, not just the give-up: a larger threshold would spin
# that many times against a mount that is never going to clear, inside the
# enforcer's poll loop.
_t_eq "5" "$(_calls | grep -c "^umount ")" "unmount tries exactly five times before giving up"
rm -f "${DEV}/umount_is_noop"
# The direct-overwrite fallback succeeding is the last line of defence: the
# bind failed, but the target ends up correct anyway.
_reset_dev
printf '127.0.0.1 blocked.example\n' >"${HOSTS_CANONICAL}"
printf '127.0.0.1 tampered\n' >"${HOSTS_TARGET}"
_fail_op mount
touch "${DEV}/overwrite_works"
if assert_bind_mount; then
_t_pass "assert succeeds via the direct-overwrite fallback"
else
_t_fail "assert should succeed when the overwrite fixes the target"
fi
rm -f "${DEV}/overwrite_works"
_clear_fail mount

View File

@ -1,149 +0,0 @@
#!/usr/bin/env bash
# lib/tests/hosts_cases_cache.sh — assertions for the cache-invalidation half
# of hosts_magisk.sh: force-stopping browsers so their in-process DNS caches
# clear, holding the canonical files immutable, and restarting netd so its
# in-memory copy of the hosts file is reloaded.
#
# Sourced by test_hosts_libs.sh after hosts_cases_magisk.sh, which owns the
# stubs and counters. Split out to stay under the 250-line cap.
set -euo pipefail
# --- flush_browser_dns_caches ----------------------------------------------
_reset_dev
flush_browser_dns_caches
calls="$(_calls)"
for pkg in org.mozilla.fenix com.android.chrome; do
case "${calls}" in
*"am force-stop ${pkg}"*) _t_pass "flush force-stops ${pkg}" ;;
*) _t_fail "flush must force-stop ${pkg}" ;;
esac
done
case "$(_log)" in
*"Flushed DNS cache"*) _t_pass "flush logs each browser it stopped" ;;
*) _t_fail "flush should log the browsers it stopped" ;;
esac
# A browser that is not running must not be reported as flushed.
_reset_dev
_fail_op am
flush_browser_dns_caches
if _log | grep -q "Flushed DNS cache"; then
_t_fail "flush claimed to stop a browser that was not running"
else
_t_pass "flush stays quiet when no browser was running"
fi
_clear_fail am
# --- ensure_canonical_immutable --------------------------------------------
_reset_dev
printf '127.0.0.1 blocked.example\n' >"${HOSTS_CANONICAL}"
rm -f "${HOSTS_CANONICAL_WORKOUT}"
ensure_canonical_immutable
case "$(_calls)" in
*"chattr +i ${HOSTS_CANONICAL}"*) _t_pass "immutable locks the active canonical" ;;
*) _t_fail "immutable must lock the canonical" ;;
esac
if _calls | grep -qF "chattr +i ${HOSTS_CANONICAL_WORKOUT}"; then
_t_fail "immutable locked a workout canonical that does not exist"
else
_t_pass "immutable skips a workout canonical that is absent"
fi
# Both variants are locked when both exist, so a later workout transition is
# just as tamper-resistant as the current state.
_reset_dev
printf '127.0.0.1 workout\n' >"${HOSTS_CANONICAL_WORKOUT}"
ensure_canonical_immutable
case "$(_calls)" in
*"chattr +i ${HOSTS_CANONICAL_WORKOUT}"*) _t_pass "immutable locks the workout canonical too" ;;
*) _t_fail "immutable must lock both canonical variants" ;;
esac
# --- restart_netd_for_hosts_cache ------------------------------------------
# netd not running: nothing to restart, and no stamp written.
_reset_dev
rm -f "${STATE_DIR}/netd_restart.pid"
restart_netd_for_hosts_cache
if [[ -f "${STATE_DIR}/netd_restart.pid" ]]; then
_t_fail "netd restart stamped a pid when netd was not running"
else
_t_pass "netd restart does nothing when netd is not running"
fi
# Same pid as last time: already restarted this boot, so skip the network
# blip. This is the check that keeps an enforcer restart from cycling netd.
_reset_dev
printf '4242\n' >"${DEV}/netd_pid"
printf '4242\n' >"${STATE_DIR}/netd_restart.pid"
restart_netd_for_hosts_cache
if _calls | grep -q "^stop netd"; then
_t_fail "netd was restarted again for a pid already stamped"
else
_t_pass "netd restart is skipped when the pid is unchanged"
fi
# A real Firefox profile (one containing prefs.js) gets the DoH-disable pref.
# Firefox reaches its DoH resolver over hardcoded bootstrap IPs, so the hosts
# file alone does not stop it — this pref is what does.
_reset_dev
real_profile="${FENIX_DIR}/abc123.default/"
mkdir -p "${real_profile}"
printf 'user_pref("x", 1);\n' >"${real_profile}prefs.js"
disable_firefox_doh
if grep -qF 'user_pref("network.trr.mode", 5);' "${real_profile}user.js" 2>/dev/null; then
_t_pass "firefox DoH writes the trr.mode pref to a real profile"
else
_t_fail "firefox DoH must write the trr.mode pref"
fi
case "$(_log)" in
*"Wrote DoH-disable pref"*) _t_pass "firefox DoH logs the profile it wrote" ;;
*) _t_fail "firefox DoH should log the write" ;;
esac
# Already present: the pref must not be appended twice on every flush.
_reset_dev
disable_firefox_doh
_t_eq "1" "$(grep -cF 'network.trr.mode' "${real_profile}user.js")" \
"firefox DoH does not duplicate an existing pref"
if _log | grep -q "Wrote DoH-disable pref"; then
_t_fail "firefox DoH logged a write it did not perform"
else
_t_pass "firefox DoH stays quiet when the pref is already set"
fi
# --- restart_netd_for_hosts_cache, the restart path -------------------------
# A different pid than last stamped means netd has been cycled since our last
# restart, so the hosts cache is stale again and must be reloaded.
_reset_dev
printf '5555\n' >"${DEV}/netd_pid"
printf '1111\n' >"${STATE_DIR}/netd_restart.pid"
restart_netd_for_hosts_cache
calls="$(_calls)"
case "${calls}" in
*"stop netd"*) _t_pass "netd restart stops netd when the pid changed" ;;
*) _t_fail "netd restart must stop netd on a changed pid" ;;
esac
case "${calls}" in
*"start netd"*) _t_pass "netd restart starts netd again" ;;
*) _t_fail "netd restart must start netd again" ;;
esac
stop_line="$(printf '%s\n' "${calls}" | grep -n "^stop netd" | head -1 | cut -d: -f1)"
start_line="$(printf '%s\n' "${calls}" | grep -n "^start netd" | head -1 | cut -d: -f1)"
if [[ "${start_line}" -gt "${stop_line}" ]]; then
_t_pass "netd restart starts only after it has stopped"
else
_t_fail "netd restart must stop before it starts"
fi
_t_eq "5555" "$(cat "${STATE_DIR}/netd_restart.pid")" \
"netd restart stamps the new pid so the next tick skips the work"
case "$(_log)" in
*"hosts cache is now live"*) _t_pass "netd restart logs completion" ;;
*) _t_fail "netd restart should log completion" ;;
esac

View File

@ -1,129 +0,0 @@
#!/usr/bin/env bash
# lib/tests/hosts_cases_magisk.sh — assertions for hosts_magisk.sh: keeping
# the Magisk module in step, defeating the module's disable/remove markers,
# and making already-running resolvers notice a changed hosts file.
#
# Sourced by test_hosts_libs.sh after hosts_libs_harness.sh, which owns the
# stubs, the subjects and the PASS/FAIL counters this file adds to.
set -euo pipefail
readonly MODULE_DIR="${RUN}/state/modules/hosts"
_seed_module() {
rm -rf "${MODULE_DIR}"
mkdir -p "${MODULE_DIR}/system/etc"
printf '%s' "${1:-}" >"${HOSTS_MAGISK_MODULE_FILE}"
}
# --- sync_magisk_module -----------------------------------------------------
_reset_dev
printf '127.0.0.1 blocked.example\n' >"${HOSTS_CANONICAL}"
_seed_module "old content"
sync_magisk_module ""
_t_eq "" "$(_calls)" "sync does nothing when handed an empty canonical"
sync_magisk_module "${RUN}/state/no-such-file"
_t_eq "" "$(_calls)" "sync does nothing when the canonical does not exist"
# No module installed: sync must be a no-op, not an error.
_reset_dev
rm -rf "${MODULE_DIR}"
sync_magisk_module "${HOSTS_CANONICAL}"
if _calls | grep -q "^cp "; then
_t_fail "sync wrote to a module directory that does not exist"
else
_t_pass "sync is a no-op when the Magisk module is not installed"
fi
# Contents differ: the module gets rewritten, and unlocked to allow it.
_reset_dev
_seed_module "stale"
sync_magisk_module "${HOSTS_CANONICAL}"
calls="$(_calls)"
case "${calls}" in
*"cp ${HOSTS_CANONICAL} ${HOSTS_MAGISK_MODULE_FILE}"*)
_t_pass "sync copies the canonical into the module" ;;
*) _t_fail "sync must copy the canonical when contents differ" ;;
esac
case "${calls}" in
*"chattr -i ${HOSTS_MAGISK_MODULE_FILE}"*)
_t_pass "sync drops the file's immutable flag before writing" ;;
*) _t_fail "sync must unlock the module file before writing" ;;
esac
case "$(_log)" in
*"Synced Magisk module hosts"*) _t_pass "sync logs the rewrite" ;;
*) _t_fail "sync should log when it rewrites the module" ;;
esac
# Already in step: the cheap hash compare must skip the write entirely, which
# is what keeps this off the hot path of every poll iteration.
_reset_dev
_seed_module ""
_copy "${HOSTS_CANONICAL}" "${HOSTS_MAGISK_MODULE_FILE}"
sync_magisk_module "${HOSTS_CANONICAL}"
if _calls | grep -q "^cp "; then
_t_fail "sync rewrote a module that was already in step"
else
_t_pass "sync skips the write when the hashes already match"
fi
# Even when it skips the write, the directory lock is re-asserted.
case "$(_calls)" in
*"chattr +i ${MODULE_DIR}"*) _t_pass "sync re-locks the module directory regardless" ;;
*) _t_fail "sync must re-assert the directory lock every time" ;;
esac
# --- protect_magisk_module --------------------------------------------------
_reset_dev
rm -rf "${MODULE_DIR}"
protect_magisk_module || true
_t_eq "" "$(_calls)" "protect does nothing when the module is not installed"
# No markers present: the dir is still locked, nothing is reported removed.
_reset_dev
_seed_module ""
protect_magisk_module
_t_eq "0" "$?" "protect reports no markers removed on a clean module"
case "$(_calls)" in
*"chattr +i ${MODULE_DIR}"*) _t_pass "protect locks the module directory" ;;
*) _t_fail "protect must lock the module directory" ;;
esac
# Each of the three markers is a separate way to disable the module through
# the Magisk UI, so each must be removed.
for marker in disable remove update; do
_reset_dev
_seed_module ""
touch "${MODULE_DIR}/${marker}"
protect_magisk_module && rc=0 || rc=$?
_t_eq "1" "${rc}" "protect reports removing the '${marker}' marker"
if [[ -e "${MODULE_DIR}/${marker}" ]]; then
_t_fail "protect left the '${marker}' marker in place"
else
_t_pass "protect deletes the '${marker}' marker"
fi
case "$(_log)" in
*"TAMPER: removed Magisk module marker"*) _t_pass "protect logs the '${marker}' tamper" ;;
*) _t_fail "protect must log the '${marker}' tamper" ;;
esac
done
# All three at once, so the count is a count and not a boolean.
_reset_dev
_seed_module ""
touch "${MODULE_DIR}/disable" "${MODULE_DIR}/remove" "${MODULE_DIR}/update"
protect_magisk_module && rc=0 || rc=$?
_t_eq "3" "${rc}" "protect counts every marker it removed"
# --- disable_firefox_doh ----------------------------------------------------
_reset_dev
rm -rf "${FENIX_DIR:?}"/*
mkdir -p "${FENIX_DIR}/not-a-profile"
# A directory with no prefs.js is not a real profile and must be skipped.
disable_firefox_doh
_t_eq "" "$(_log)" "firefox DoH skips directories that are not profiles"

Some files were not shown because too many files have changed in this diff Show More