mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:07:06 +01:00
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:
parent
cbdf3a3d85
commit
a5209b6878
8
.gitignore
vendored
8
.gitignore
vendored
@ -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/
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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."
|
||||||
|
}
|
||||||
@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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") == ""
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
@ -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 "============================================================"
|
|
||||||
@ -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
|
|
||||||
"
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
"
|
|
||||||
@ -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"
|
|
||||||
@ -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.
|
|
||||||
"
|
|
||||||
@ -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"
|
|
||||||
@ -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)."
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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."
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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 "$@"
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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" ]
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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)"
|
|
||||||
}
|
|
||||||
@ -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."
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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 "$@"
|
|
||||||
@ -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 "$@"
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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`.
|
|
||||||
@ -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.
|
|
||||||
@ -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
|
|
||||||
@ -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.
|
|
||||||
@ -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 "$@"
|
|
||||||
@ -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
|
|
||||||
@ -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 "$@"
|
|
||||||
@ -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>
|
|
||||||
@ -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"
|
|
||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 "$@"
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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 "$@"
|
|
||||||
@ -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"
|
|
||||||
@ -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}"
|
|
||||||
}
|
|
||||||
@ -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."
|
|
||||||
}
|
|
||||||
@ -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}"
|
|
||||||
}
|
|
||||||
@ -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}"
|
|
||||||
}
|
|
||||||
@ -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}"
|
|
||||||
}
|
|
||||||
@ -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}"
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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())
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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}"
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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}"
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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 ''
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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}"
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user