testsAndMisc/phone_focus_mode
Krzysztof kuhy Rudnicki 82e206116a
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Make the geofence tell the truth, and the phone explain itself
The enforcer failed in both directions: nothing blocked at home, and
everything blocked at the office. The comparison was never inverted --
`metres <= threshold` was always right. Three separate causes:

Only ACCESS_COARSE_LOCATION was declared. Android 12+ fuzzes coarse to a
~1-2 km grid against a 150 m fence, so a fix taken at home routinely landed
outside it, read as AWAY, and restored every app the geofence exists to hide.
FINE is now declared and self-granted by the device owner, along with coarse
and background, re-asserted every pass so a reinstall cannot silently drop it.

There were three states but two behaviours. LOCATION_UNKNOWN applies the
identical sweep to AT_HOME -- correct fail-closed design, indistinguishable
from outside. And nothing ever requested a fix: the pass read whatever another
app happened to cache, so at the office with no navigation running the cache
was empty. Passes now acquire a fix inside a location-typed foreground
service, bounded at 20 s, and treat anything older than 30 min as no fix. That
window is deliberately longer than the 15-minute cadence; a shorter one marks
the newest cache stale on every pass of an idle phone and blocks everything.

None of it was diagnosable. `adb logcat -d -s FocusOwner:V` came back empty
while dumpsys showed 82 alarms had fired, and `run-as` is refused on the
release build device owner requires. So the pass now writes a ring-buffered
JSONL record read back in-app: reason, distance vs the fence, fix
age/provider/accuracy, curfew state, and every hidden app with the rule that
hid it. Coordinates are never recorded -- a distance is enough to debug a
geofence, and this is a home address.

Also fixed along the way:

- Hysteresis keyed off `isEnforcing`, which is true under AWAY too because
  YouTube stays hidden, so the threshold was permanently radius+hysteresis.
  It now keys off whether the previous pass was inside the fence.
- The scheduler's curfew-boundary alarms were documented but never scheduled;
  both callers invoked `scheduleNext()` bare, so 05:00 could land 15 min late.
- Installing over the app cancelled its pending alarms, so `adb install -r`
  silently stopped enforcement until someone opened the app. MY_PACKAGE_REPLACED
  now re-arms it, verified on device without launching the app.
- A wrong-typed log record threw instead of being skipped, and the exception
  escaped far enough to leave the page stuck busy -- which disables the
  release-device-owner button, the one control that must work when everything
  else has gone wrong.
- `lib/enforcement.dart` was a second decision layer with no always-blocked
  concept, cited by KDoc as pinned by a `PolicyParityTest` that never existed.
  It would have made the new screen report YouTube visible while it is hidden.
  Deleted; the UI renders the Kotlin record rather than re-deciding.
- Anchoring home accepted a stale or unknown-precision fix. A wrong home
  offsets the fence everywhere and forever, which is what produced a 766 m
  reading while standing at home.

Policy: Play Store is allowed by day (not during curfew) so infakt can be
installed and updated; the sweep is default-deny for third-party packages, so
anything installed from Play is hidden on the next at-home pass. Tachiyomi's
per-source extension apks are covered by a new prefix allowlist rather than an
exact list that goes stale whenever an extension is installed.

`export.py` stopped subtracting the allowlist from `blockable_system_packages`.
Sweepable is not hideable: that list decides which system packages are
eligible for a decision, and `isAllowed` is what protects them. Removing an
allowed system package from the sweep freezes it in its last state -- it would
have stranded Play hidden permanently the moment it was allowlisted.

Verified on the Pixel 6a: FINE granted POLICY_FIXED, `applied AT_HOME` with a
0 m distance from a +/-11 m GPS fix, YouTube/Chrome/Firefox absent from
`pm list packages -e`, Play Store, infakt, RunnerUp and all five Tachiyomi
packages present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjxL9kdAzZmGqQA5tMNrZ8
2026-08-14 13:43:05 +02:00
..
docs inFakt is SMS-only 2FA, so the wipe cannot strand the account 2026-08-10 20:04:06 +02:00
focus_status_app Fail the build on any shellcheck finding; fix the 222 the old gate hid 2026-08-03 19:49:11 +02:00
lib Block YouTube on the unrooted Pixel, and trust more than one phone 2026-08-10 19:16:01 +02:00
systemd feat(phone-focus): add recovery workflow, automation scripts, and docs 2026-05-01 19:07:27 +02:00
backup_manifest.sh feat(phone-focus): add recovery workflow, automation scripts, and docs 2026-05-01 19:07:27 +02:00
batch3_bloatware_uninstall.sh fix(phone): restore Blackview launcher for recents gesture 2026-05-31 16:27:08 +02:00
config.sh Make the geofence tell the truth, and the phone explain itself 2026-08-14 13:43:05 +02:00
curfew_enforcer.sh Fix the 11 shellcheck findings that kept CI red since b83ce5d 2026-08-04 20:55:02 +02:00
deploy.sh Fail the build on any shellcheck finding; fix the 222 the old gate hid 2026-08-03 19:49:11 +02:00
distraction_purge.sh Block YouTube on the unrooted Pixel, and trust more than one phone 2026-08-10 19:16:01 +02:00
dns_enforcer.sh Serve the blocklist over DoT and hand the policy to the app as an asset 2026-08-05 09:36:11 +02:00
enable_persistent_adb.sh phone_focus_mode: reach the phone over adb without touching it 2026-08-03 13:33:45 +02:00
focus_ctl.sh Fail the build on any shellcheck finding; fix the 222 the old gate hid 2026-08-03 19:49:11 +02:00
focus_daemon.sh Fail the build on any shellcheck finding; fix the 222 the old gate hid 2026-08-03 19:49:11 +02:00
hosts_enforcer.sh Fix the 11 shellcheck findings that kept CI red since b83ce5d 2026-08-04 20:55:02 +02:00
launcher_enforcer.sh phone_focus_mode: add persistent home-mode status notification 2026-04-20 15:33:46 +02:00
magisk_service.sh phone_focus_mode: block hotspot/tethering to close the second-phone bypass 2026-07-13 10:48:59 +02:00
phone_backup.sh Back up what adb can actually reach, and block YouTube in Firefox too 2026-08-10 19:43:28 +02:00
README.md phone_focus_mode: prettier-format the README tethering section 2026-07-13 10:50:48 +02:00
restore_apps.sh Make the enforcer real: block YouTube, and refuse to be uninstalled 2026-08-10 21:12:53 +02:00
run_phone.sh Fix the 11 shellcheck findings that kept CI red since b83ce5d 2026-08-04 20:55:02 +02:00
strip_workout_hosts.py Make focus policy and blocklist usable without root; fix workout CDN unblock 2026-08-05 09:15:02 +02:00
tether_enforcer.sh phone_focus_mode: block hotspot/tethering to close the second-phone bypass 2026-07-13 10:48:59 +02:00
workout_detector.sh Apply focus-mode, screen-locker, and steam backlog updates 2026-05-03 22:41:53 +02:00

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

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:

./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

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

./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):

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:

./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.

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)

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

# 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:

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)

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

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:

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.

Updating

After editing config.sh (e.g. changing whitelist):

./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