diff --git a/.github/workflows/reverse-survivors-ci.yml b/.github/workflows/reverse-survivors-ci.yml
deleted file mode 100644
index 72bafef5..00000000
--- a/.github/workflows/reverse-survivors-ci.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-name: reverse-survivors CI
-
-# Relocated here from reverse_survivors/.github/workflows/ci.yml when the game
-# moved into this monorepo — GitHub only reads workflows from the repository
-# root, so the nested copy would have been inert.
-
-on:
- push:
- branches: [main]
- paths:
- - "reverse_survivors/**"
- - ".github/workflows/reverse-survivors-ci.yml"
- pull_request:
- branches: [main]
- paths:
- - "reverse_survivors/**"
- - ".github/workflows/reverse-survivors-ci.yml"
-
-defaults:
- run:
- working-directory: reverse_survivors
-
-jobs:
- check:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
- cache-dependency-path: reverse_survivors/package-lock.json
- - run: npm ci
- - run: npm run check
- - run: npm run build
diff --git a/.prettierignore b/.prettierignore
index a2bec95e..248a56d7 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -19,4 +19,3 @@ 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.
-focus_owner/assets/policy.json
diff --git a/AGENTS.md b/AGENTS.md
index 576f15e6..a27699bb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,9 +11,6 @@ Mixed-language monorepo: Python packages, Bash automation, and several apps.
| `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) |
-| `reverse_survivors/` | React/Vite game — you play the horde (TypeScript) |
-| `kcd2_dice_solver/` | KCD2 dice solver |
-| `focus_owner/` | Android Device-Owner focus enforcer (Kotlin) |
| `bottles/` | Small standalone project |
| `docs/` | Reference docs; `docs/superpowers/` holds AI workflow artifacts |
| `third_party/` | Vendored upstream skills/agents |
@@ -27,7 +24,10 @@ Extracted to their own repos: [`steam-backlog-enforcer`](https://github.com/kuhy
[`diet-guard`](https://github.com/kuhyx/diet-guard),
[`wake-alarm`](https://github.com/kuhyx/wake-alarm),
[`dufs-cloud`](https://github.com/kuhyx/dufs-cloud),
-[`build-your-x`](https://github.com/kuhyx/build-your-x) (lives at `~/build_your_x`).
+[`build-your-x`](https://github.com/kuhyx/build-your-x) (lives at `~/build_your_x`),
+[`reverse-survivors`](https://github.com/kuhyx/reverse-survivors),
+[`kcd2-dice-solver`](https://github.com/kuhyx/kcd2-dice-solver) (lives at `~/kcd2-dice-solver`),
+[`focus-owner`](https://github.com/kuhyx/focus-owner).
Archived work: [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
## Adding a Flutter app
diff --git a/docs/superpowers/contracts/extract-three-apps-2026-08-23.json b/docs/superpowers/contracts/extract-three-apps-2026-08-23.json
new file mode 100644
index 00000000..50d7027c
--- /dev/null
+++ b/docs/superpowers/contracts/extract-three-apps-2026-08-23.json
@@ -0,0 +1,20 @@
+{
+ "title": "Extract reverse_survivors, kcd2_dice_solver and focus_owner to their own public repos",
+ "objective": "Move three self-contained applications out of testsAndMisc into their own repositories with full history, give each real CI it never had in the monorepo, and repoint every consumer the monorepo keeps - including the live systemd unit and Docker bind mount that deploy the dice solver.",
+ "acceptance_criteria": [
+ "Each unit is a public GitHub repo on branch main whose commit count equals the monorepo's count for that path (8, 14, 30).",
+ "Each extracted repo passes its own toolchain from a clean clone: npm check+build; pnpm lint+test+build; flutter analyze --fatal-infos + test.",
+ "Each extracted repo carries a CI workflow that runs those same commands with no paths filter and no working-directory.",
+ "kcd2-dice-build.service starts successfully and exits 0 after being repointed - proven by running it, not by reading it.",
+ "https://dice.kuhy.duckdns.org/ returns HTTP 200 and serves the bundle hash produced by that run.",
+ "setup_kcd2_dice_solver.sh builds from the extracted repo, and its generated post-commit hook no longer filters on a kcd2_dice_solver/ path that can never match again.",
+ "No file outside docs/ or a historical note still points at a removed directory.",
+ "The repo-wide pytest gate and pre-commit both still pass, without --no-verify."
+ ],
+ "out_of_scope": [
+ "phone_focus_mode and python_pkg/focus_policy, which move together in a later step.",
+ "The four system subsystems under periodic_background.",
+ "Reformatting focus_owner's Dart sources: the committed tree predates the current formatter and 15 files would change, which belongs in its own commit in the new repo."
+ ],
+ "verifier": "gh api confirms three public repos; standalone clones pass npm/pnpm/flutter checks; systemctl --user start kcd2-dice-build.service exits 0; curl https://dice.kuhy.duckdns.org/ returns 200 with a bundle hash matching dist/; ./meta/scripts/report_live_state.sh shows no unit pointing at a removed path; pre-commit passes."
+}
diff --git a/docs/superpowers/evidence/extract-three-apps-2026-08-23.json b/docs/superpowers/evidence/extract-three-apps-2026-08-23.json
new file mode 100644
index 00000000..9e76a314
--- /dev/null
+++ b/docs/superpowers/evidence/extract-three-apps-2026-08-23.json
@@ -0,0 +1,56 @@
+{
+ "intent": "Extract reverse_survivors, kcd2_dice_solver and focus_owner into their own public GitHub repositories with full history, and repoint every reference the monorepo kept - including the live kcd2 systemd unit.",
+ "scope": [
+ "reverse_survivors/ (44 files, 8 commits) -> github.com/kuhyx/reverse-survivors",
+ "kcd2_dice_solver/ (73 files, 14 commits) -> github.com/kuhyx/kcd2-dice-solver, cloned to ~/kcd2-dice-solver",
+ "focus_owner/ (72 files, 30 commits) -> github.com/kuhyx/focus-owner",
+ ".github/workflows/reverse-survivors-ci.yml removed; each new repo carries its own CI",
+ "setup_kcd2_dice_solver.sh + lib/kcd2_autorebuild.sh repointed; the deploy script stays here",
+ "Non-goal: phone_focus_mode and the four system subsystems, which are separate steps"
+ ],
+ "changes": [
+ "Each unit extracted with git filter-repo --path-rename
/: so it owns its repository root, preserving every commit that touched it.",
+ "Added a CI workflow to each: npm for reverse-survivors, pnpm for kcd2-dice-solver, flutter for focus-owner. The latter two had no CI in the monorepo at all.",
+ "focus_owner's two references to python_pkg.focus_policy now name the phone-focus-mode repo, where the generator is going. Regeneration of assets/policy.json stays manual and says why.",
+ "setup_kcd2_dice_solver.sh now builds from ${HOME}/kcd2-dice-solver instead of ${HOME}/testsAndMisc/kcd2_dice_solver, and its generated post-commit hook drops the `git diff -- kcd2_dice_solver/` path filter, which could never match again once every commit in that repo is solver code.",
+ "Repointed the live kcd2-dice-build.service user unit and reloaded systemd.",
+ "Added pnpm-workspace.yaml to the solver repo approving esbuild's build script - found by running the unit, not by reading it."
+ ],
+ "verification": [
+ {
+ "command": "npm ci && npm run check && npm run build (reverse-survivors, standalone clone)",
+ "result": "pass",
+ "evidence": "100% statements/branches/functions/lines; vite build produced dist/ in 74ms."
+ },
+ {
+ "command": "pnpm install && pnpm run lint && pnpm run test && pnpm run build (kcd2, standalone clone)",
+ "result": "pass",
+ "evidence": "tsc --noEmit and eslint clean; 288 tests in 24 files passed; vite build succeeded."
+ },
+ {
+ "command": "flutter pub get && flutter analyze --fatal-infos && flutter test (focus-owner, standalone clone)",
+ "result": "pass",
+ "evidence": "No issues found; 94 tests passed. Re-run after the two comment edits, same result."
+ },
+ {
+ "command": "systemctl --user start kcd2-dice-build.service",
+ "result": "fail then pass",
+ "evidence": "First run exited 1: ERR_PNPM_IGNORED_BUILDS for esbuild@0.25.12. package.json carried pnpm.onlyBuiltDependencies but pnpm 10 reads the approval from pnpm-workspace.yaml, which was never tracked. Adding it fixed the install. This is exactly the failure that reading the script could not have found."
+ },
+ {
+ "command": "gh api repos/kuhyx/",
+ "result": "pass",
+ "evidence": "All three repos public on default branch main; reverse-survivors CI queued on first push."
+ }
+ ],
+ "risks": [
+ "The kcd2 auto-rebuild git hook is installed into the solver repo's .git/hooks by setup_kcd2_dice_solver.sh; a fresh clone of that repo will not have it until the setup script is re-run.",
+ "focus_owner's policy.json is now a committed artifact whose generator lives in a different repo. Drift is possible if config.sh changes and nobody regenerates; the pubspec comment records the exact command.",
+ "The extracted repos were verified in a scratch clone, not in the working copies at ~/; only kcd2-dice-solver was cloned locally because a systemd unit needs it."
+ ],
+ "rollback": [
+ "git revert the removal commit to restore all three trees and the reverse-survivors workflow.",
+ "Repoint kcd2-dice-build.service back to ${HOME}/testsAndMisc/kcd2_dice_solver and systemctl --user daemon-reload.",
+ "The new GitHub repos can be left in place; they do not affect the monorepo once the trees are restored."
+ ]
+}
diff --git a/focus_owner/.gitignore b/focus_owner/.gitignore
deleted file mode 100644
index 3820a95c..00000000
--- a/focus_owner/.gitignore
+++ /dev/null
@@ -1,45 +0,0 @@
-# Miscellaneous
-*.class
-*.log
-*.pyc
-*.swp
-.DS_Store
-.atom/
-.build/
-.buildlog/
-.history
-.svn/
-.swiftpm/
-migrate_working_dir/
-
-# IntelliJ related
-*.iml
-*.ipr
-*.iws
-.idea/
-
-# The .vscode folder contains launch configuration and tasks you configure in
-# VS Code which you may wish to be included in version control, so this line
-# is commented out by default.
-#.vscode/
-
-# Flutter/Dart/Pub related
-**/doc/api/
-**/ios/Flutter/.last_build_id
-.dart_tool/
-.flutter-plugins-dependencies
-.pub-cache/
-.pub/
-/build/
-/coverage/
-
-# Symbolication related
-app.*.symbols
-
-# Obfuscation related
-app.*.map.json
-
-# Android Studio will place build artifacts here
-/android/app/debug
-/android/app/profile
-/android/app/release
diff --git a/focus_owner/.metadata b/focus_owner/.metadata
deleted file mode 100644
index d312f635..00000000
--- a/focus_owner/.metadata
+++ /dev/null
@@ -1,30 +0,0 @@
-# This file tracks properties of this Flutter project.
-# Used by Flutter tool to assess capabilities and perform upgrades etc.
-#
-# This file should be version controlled and should not be manually edited.
-
-version:
- revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6"
- channel: "stable"
-
-project_type: app
-
-# Tracks metadata for the flutter migrate command
-migration:
- platforms:
- - platform: root
- create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- - platform: android
- create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
-
- # User provided section
-
- # List of Local paths (relative to this file) that should be
- # ignored by the migrate tool.
- #
- # Files that are not part of the templates will be ignored by default.
- unmanaged_files:
- - 'lib/main.dart'
- - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/focus_owner/README.md b/focus_owner/README.md
deleted file mode 100644
index d20ae622..00000000
--- a/focus_owner/README.md
+++ /dev/null
@@ -1,142 +0,0 @@
-# focus_owner
-
-Device Owner app that enforces focus mode on an unrooted Pixel 6a
-(`23181JEGR08034`). It hides apps by GPS distance from home, plus a 23:00–05:00
-night curfew, and keeps YouTube hidden everywhere.
-
-**Live and enforcing.** It is provisioned as device owner, pins RethinkDNS as an
-always-on VPN with lockdown, and blocks its own uninstall while enforcing.
-
-## How a decision is made
-
-One pass runs every 15 minutes (and at each curfew boundary), from
-`EnforcementService` → `EnforcementRunner.decide()` → `EnforcementDecision.evaluate()`.
-
-There are **five** outcomes, and only the first two look different from the
-outside — which is exactly what made this hard to debug before the status
-screen existed:
-
-| Reason | What it does |
-| ------------------ | ----------------------------------------------------------------------------------- |
-| `AWAY` | Hides only `always_blocked_packages`; everything else is available. |
-| `AT_HOME` | Full day allowlist sweep. |
-| `CURFEW` | Full sweep against the shorter night allowlist. |
-| `LOCATION_UNKNOWN` | **Identical sweep to `AT_HOME`** — fail-closed, so losing GPS is not an off switch. |
-| `WORKOUT` | As at home, plus the workout exemptions. |
-
-Plus a sixth state that is not a reason: **no home configured**, which makes the
-fence answer "inside" everywhere and so enforces everywhere. The status screen
-renders that case explicitly rather than letting it read as `AT_HOME`.
-
-`always_blocked_packages` (YouTube, YouTube Music, Chrome) are exempt from every
-branch including `AWAY` and `WORKOUT`, so leaving the house cannot restore them.
-
-## Diagnosing it
-
-**Use the in-app screen, not adb.** Neither adb route works here:
-
-- `run-as` returns `package not debuggable` — device owner requires a release
- build, and making it debuggable would let anyone with adb edit this app's
- state, which is the bypass the whole design exists to close.
-- logcat rotates. Measured 2026-08-14: `adb logcat -d -s FocusOwner:V` came back
- **empty** while `dumpsys alarm` showed 82 alarms had fired.
-
-So the durable record is `filesDir/enforcement_log.jsonl`, written by
-`EnforcementLog` and read back over the platform channel. The main screen shows
-the latest pass — reason, distance vs the fence, fix age/provider/accuracy,
-curfew state, and every hidden app with the rule that hid it — and **Debug log**
-shows the history. It never records coordinates: distance in metres is enough to
-debug a geofence, and this is a home address.
-
-Useful fields when something looks wrong:
-
-- `fix.outcome` — `CACHED_FRESH` / `ACTIVE_OK` / `TIMEOUT` / `NO_PERMISSION` /
- `NO_PROVIDER`. Distinguishes "no fix at the office" from "fuzzed fix at home".
-- `fix.accuracy_m` — tens of metres means precise location is live. Hundreds or
- thousands means the app is being fed fuzzed coarse location and the 150 m
- fence cannot be trusted.
-- `failure` — set on passes that decided nothing (not provisioned, unreadable
- policy), which otherwise leave no trace at all.
-
-## Location
-
-The geofence needs `ACCESS_FINE_LOCATION`; coarse is fuzzed to a ~1–2 km grid on
-Android 12+, against a 150 m radius. The app **self-grants** fine, coarse and
-background location every pass via `setPermissionGrantState` (device owner needs
-no prompt), and calls `setLocationEnabled(true)`.
-
-Each pass actively requests a fix rather than reading whatever another app
-cached, bounded by `ACQUIRE_TIMEOUT_MS` (20 s) and run inside a
-`location`-typed foreground service — background location delivery is throttled
-too hard otherwise. A cached fix younger than `FRESH_WINDOW_MS` (30 min) is used
-as-is; anything older is treated as no fix and fails closed. That window is
-deliberately **longer than the 15-minute cadence**: a shorter one would mark the
-newest available cache stale on every pass of an idle phone and block everything.
-
-## Policy
-
-Generated from `phone_focus_mode/config.sh`, never hand-edited:
-
-```bash
-cd ~/testsAndMisc
-python3 -m python_pkg.focus_policy \
- --config phone_focus_mode/config.sh \
- --output focus_owner/assets/policy.json --redact-home
-```
-
-`--redact-home` is required — the committed asset must keep `latitude`/
-`longitude` null. Real coordinates live only in app-private storage, written by
-"Set home to current location" in the app.
-
-Allowlists are exact-match, plus `allowed_prefixes` / `night_allowed_prefixes`
-for apps that ship as a package family (Tachiyomi installs every source as its
-own apk). Prefixes match whole labels, so `eu.kanade.tachiyomi` covers
-`eu.kanade.tachiyomi.extension.all.mangadex` but not `eu.kanade.tachiyomisomething`.
-
-**Sweepable is not the same as hideable.** `blockable_system_packages` decides
-which `FLAG_SYSTEM` packages are _eligible for a decision_; `isAllowed` is what
-then protects them. Removing an allowed system package from that list does not
-protect it, it **freezes** it in whatever state it was last left in — it lands
-in neither `packagesToHide` nor `packagesToShow`. Play Store is the live example.
-
-## Build and verify
-
-```bash
-cd ~/testsAndMisc/focus_owner
-flutter analyze && flutter test
-JAVA_HOME=/usr/lib/jvm/java-21-openjdk (cd android && ./gradlew :app:testDebugUnitTest)
-JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --release
-adb -s 23181JEGR08034 install -r build/app/outputs/flutter-apk/app-release.apk
-adb -s 23181JEGR08034 shell am start -n com.kuhy.focus_owner/.MainActivity
-```
-
-`JAVA_HOME` is required: the system default JDK 26 breaks the Android Gradle
-plugin's `jlink` transform.
-
-Release build only — device owner refuses a debuggable one. Then tap **Run
-enforcement now** and read the status card; `EnforcementService` is not
-exported, so it cannot be started from adb.
-
-> **Never** `adb uninstall`, `pm clear`, or wipe this app's data. Uninstalling a
-> device owner strands ownership with no holder and a factory reset becomes the
-> only exit. `install -r` is safe and preserves the home location.
-
-## Why the escape hatch ships first
-
-`dpm remove-active-admin` does not work on a device owner. The only exit that
-avoids a factory reset is `DevicePolicyManager.clearDeviceOwnerApp()`, callable
-solely by this package on itself. So `releaseDeviceOwner()` is implemented,
-unit-tested, and reachable from the main screen with no PC attached.
-
-Accounts now exist on the device, so `dpm set-device-owner` would refuse to run
-again: releasing is a one-way door until a wipe. The confirmation dialog says so
-in those terms. See
-`~/.claude/projects/-home-kuhy-testsAndMisc/memory/device-owner-not-root-banking-works.md`.
-
-## Known gap
-
-The network layer does not filter `youtube.com`. `private_dns_host` is empty
-(the router does not forward 853, and under VPN lockdown app DNS goes to the
-VPN's own resolver anyway), and RethinkDNS's blocklists can be switched off from
-inside that app. So a browser reaches YouTube on the web **when away from home**.
-At home and during curfew every browser is hidden, which is what closes it there.
diff --git a/focus_owner/analysis_options.yaml b/focus_owner/analysis_options.yaml
deleted file mode 100644
index bf8d4218..00000000
--- a/focus_owner/analysis_options.yaml
+++ /dev/null
@@ -1,37 +0,0 @@
-# This file configures the analyzer, which statically analyzes Dart code to
-# check for errors, warnings, and lints.
-#
-# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
-# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
-# invoked from the command line by running `flutter analyze`.
-
-# The following line activates a set of recommended lints for Flutter apps,
-# packages, and plugins designed to encourage good coding practices.
-analyzer:
- exclude:
- - build/**
- - android/**
- - ios/**
- - web/**
- - windows/**
- - macos/**
- - linux/**
-include: package:flutter_lints/flutter.yaml
-
-linter:
- # The lint rules applied to this project can be customized in the
- # section below to disable rules from the `package:flutter_lints/flutter.yaml`
- # included above or to enable additional rules. A list of all available lints
- # and their documentation is published at https://dart.dev/lints.
- #
- # Instead of disabling a lint rule for the entire project in the
- # section below, it can also be suppressed for a single line of code
- # or a specific dart file by using the `// ignore: name_of_lint` and
- # `// ignore_for_file: name_of_lint` syntax on the line or in the file
- # producing the lint.
- rules:
- # avoid_print: false # Uncomment to disable the `avoid_print` rule
- # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
-
-# Additional information about this file can be found at
-# https://dart.dev/guides/language/analysis-options
diff --git a/focus_owner/android/.gitignore b/focus_owner/android/.gitignore
deleted file mode 100644
index be3943c9..00000000
--- a/focus_owner/android/.gitignore
+++ /dev/null
@@ -1,14 +0,0 @@
-gradle-wrapper.jar
-/.gradle
-/captures/
-/gradlew
-/gradlew.bat
-/local.properties
-GeneratedPluginRegistrant.java
-.cxx/
-
-# Remember to never publicly share your keystore.
-# See https://flutter.dev/to/reference-keystore
-key.properties
-**/*.keystore
-**/*.jks
diff --git a/focus_owner/android/app/build.gradle.kts b/focus_owner/android/app/build.gradle.kts
deleted file mode 100644
index 851e3009..00000000
--- a/focus_owner/android/app/build.gradle.kts
+++ /dev/null
@@ -1,82 +0,0 @@
-import java.util.Properties
-
-plugins {
- id("com.android.application")
- // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
- id("dev.flutter.flutter-gradle-plugin")
-}
-
-// Release signing key, kept out of the repo, shared with the other apps.
-//
-// This matters more here than elsewhere: a device owner can only be replaced
-// by an APK with the same signature, and `dpm remove-active-admin` does not
-// work on one. Provisioning a debug-signed build would mean the app could
-// never be updated, and the only exit from a stale device owner that cannot
-// release itself is another factory reset.
-val keystoreProperties = Properties().apply {
- val f = rootProject.file("key.properties")
- if (f.exists()) f.inputStream().use { load(it) }
-}
-
-android {
- namespace = "com.kuhy.focus_owner"
- compileSdk = flutter.compileSdkVersion
- ndkVersion = flutter.ndkVersion
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
- }
-
- defaultConfig {
- // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
- applicationId = "com.kuhy.focus_owner"
- // You can update the following values to match your application needs.
- // For more information, see: https://flutter.dev/to/review-gradle-config.
- minSdk = flutter.minSdkVersion
- targetSdk = flutter.targetSdkVersion
- versionCode = flutter.versionCode
- versionName = flutter.versionName
- }
-
- signingConfigs {
- if (keystoreProperties.getProperty("storeFile") != null) {
- create("release") {
- storeFile = file(keystoreProperties.getProperty("storeFile"))
- storePassword = keystoreProperties.getProperty("storePassword")
- keyAlias = keystoreProperties.getProperty("keyAlias")
- keyPassword = keystoreProperties.getProperty("keyPassword")
- }
- }
- }
-
- buildTypes {
- release {
- // Falls back to the debug key only when key.properties is absent
- // (a fresh clone), so `flutter run --release` still works.
- signingConfig = signingConfigs.findByName("release")
- ?: signingConfigs.getByName("debug")
- // Deliberately no minify/shrink. An untested ProGuard pass that
- // strips a DevicePolicyManager callback would produce a device
- // owner that cannot release itself, and that costs a factory
- // reset to undo.
- }
- }
-}
-
-kotlin {
- compilerOptions {
- jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
- }
-}
-
-flutter {
- source = "../.."
-}
-
-dependencies {
- // org.json is an Android stub on the JVM classpath, so unit tests need a
- // real implementation to parse the policy asset.
- testImplementation("org.json:json:20240303")
- testImplementation("junit:junit:4.13.2")
-}
diff --git a/focus_owner/android/app/src/debug/AndroidManifest.xml b/focus_owner/android/app/src/debug/AndroidManifest.xml
deleted file mode 100644
index 399f6981..00000000
--- a/focus_owner/android/app/src/debug/AndroidManifest.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
diff --git a/focus_owner/android/app/src/main/AndroidManifest.xml b/focus_owner/android/app/src/main/AndroidManifest.xml
deleted file mode 100644
index 5ddb6816..00000000
--- a/focus_owner/android/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,134 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/BootReceiver.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/BootReceiver.kt
deleted file mode 100644
index 26530804..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/BootReceiver.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.os.UserManager
-import android.util.Log
-
-/**
- * Re-arms enforcement after a reboot.
- *
- * Two actions, because one is not enough. `BOOT_COMPLETED` does not arrive
- * until the user first unlocks the device — measured on this phone, where the
- * user stayed `RUNNING_LOCKED` for several minutes after a restart with no
- * network and no app able to start. Relying on it alone means enforcement
- * state is frozen at whatever it was before the reboot until someone unlocks.
- *
- * That freeze is safe in one direction and not the other. Apps stay hidden, so
- * nothing leaks — but apps that *should* have been restored stay hidden too, so
- * a reboot during curfew could leave the dialer missing at breakfast.
- * `LOCKED_BOOT_COMPLETED` fires before unlock, so the schedule is re-armed as
- * early as the system allows and the first post-unlock run repairs the rest.
- */
-class BootReceiver : BroadcastReceiver() {
-
- override fun onReceive(context: Context, intent: Intent) {
- when (intent.action) {
- // MY_PACKAGE_REPLACED for the same reason as the boot actions:
- // installing over the app cancels its pending alarms, so without
- // it the chain stays dead until someone opens the app. It arrives
- // only for this package and only after the install completes.
- Intent.ACTION_LOCKED_BOOT_COMPLETED,
- Intent.ACTION_BOOT_COMPLETED,
- Intent.ACTION_MY_PACKAGE_REPLACED,
- -> {
- val unlocked = context.getSystemService(UserManager::class.java)
- ?.isUserUnlocked ?: false
- Log.i(
- FocusDeviceAdminReceiver.TAG,
- "boot: ${intent.action} unlocked=$unlocked",
- )
- // Always re-arm the alarm: it is device-encryption safe and
- // cheap. Only run enforcement once storage is readable, since
- // the policy asset and the coordinates live in credential-
- // encrypted storage and are unreadable before unlock.
- EnforcementScheduler(context).scheduleNext()
- if (unlocked) EnforcementService.start(context)
- }
- else -> Log.w(FocusDeviceAdminReceiver.TAG, "unexpected ${intent.action}")
- }
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt
deleted file mode 100644
index 1fd374b0..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt
+++ /dev/null
@@ -1,219 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.app.admin.DevicePolicyManager
-import android.content.ComponentName
-import android.content.Context
-import android.os.Build
-import android.util.Log
-
-/**
- * Thin wrapper over [DevicePolicyManager] exposing only what this build needs:
- * reporting provisioning state, and giving up device ownership.
- *
- * No restriction is applied anywhere in this class. Adding enforcement is a
- * later, separate step; see [FocusDeviceAdminReceiver] for why the release path
- * ships and is verified first.
- */
-class DevicePolicyBridge(private val context: Context) {
-
- private val dpm: DevicePolicyManager =
- context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
-
- private val admin: ComponentName =
- ComponentName(context, FocusDeviceAdminReceiver::class.java)
-
- /** VPN-pinning and Private-DNS-pinning calls; see [DevicePolicyVpnDns]. */
- private val vpnDns: DevicePolicyVpnDns =
- DevicePolicyVpnDns(dpm, admin, isDeviceOwner = ::isDeviceOwner)
-
- /** Location-permission and location-services calls; see [DevicePolicyLocation]. */
- private val location: DevicePolicyLocation = DevicePolicyLocation(
- context,
- dpm,
- admin,
- isDeviceOwnerOrProfileOwner = { isDeviceOwner() || isProfileOwner() },
- isDeviceOwner = ::isDeviceOwner,
- )
-
- /** Uninstall-blocking calls; see [DevicePolicyUninstallGuard]. */
- private val uninstallGuard: DevicePolicyUninstallGuard =
- DevicePolicyUninstallGuard(context, dpm, admin, isDeviceOwner = ::isDeviceOwner)
-
- /** Whether this package currently holds device owner. */
- fun isDeviceOwner(): Boolean = dpm.isDeviceOwnerApp(context.packageName)
-
- /** Whether this package is profile owner (a work profile, not the device). */
- fun isProfileOwner(): Boolean = dpm.isProfileOwnerApp(context.packageName)
-
- /**
- * Hides or unhides a package, the DPM equivalent of `pm disable-user`.
- *
- * This is the mechanism the whole Device Owner design rests on, and unlike
- * `pm suspend` and `pm disable-user` — both measured to be cleared by a
- * reboot on this device — it is expected to persist. That expectation is
- * the thing worth testing before a factory reset is spent on it.
- *
- * Requires device owner or profile owner; returns false when held by
- * neither, rather than throwing.
- */
- fun setApplicationHidden(packageName: String, hidden: Boolean): Boolean {
- if (!isDeviceOwner() && !isProfileOwner()) {
- Log.w(FocusDeviceAdminReceiver.TAG, "not DO/PO; cannot hide $packageName")
- return false
- }
- return runCatching {
- val applied = dpm.setApplicationHidden(admin, packageName, hidden)
- Log.i(FocusDeviceAdminReceiver.TAG, "setApplicationHidden($packageName,$hidden)=$applied")
- applied
- }.getOrElse { error ->
- Log.e(FocusDeviceAdminReceiver.TAG, "setApplicationHidden failed", error)
- false
- }
- }
-
- /** Self-grants the location permissions the geofence needs. See [DevicePolicyLocation.grantLocationPermissions]. */
- fun grantLocationPermissions(): Set = location.grantLocationPermissions()
-
- /** Turns location services on, so a fix is obtainable at all. */
- fun enableLocation(): Boolean = location.enableLocation()
-
- /** Whether a package is currently hidden by this admin. */
- fun isApplicationHidden(packageName: String): Boolean {
- if (!isDeviceOwner() && !isProfileOwner()) return false
- return runCatching { dpm.isApplicationHidden(admin, packageName) }
- .getOrDefault(false)
- }
-
- /** Whether the admin component is active (a weaker state than ownership). */
- fun isAdminActive(): Boolean = dpm.isAdminActive(admin)
-
- /**
- * Whether any account exists, which decides if releasing is reversible.
- *
- * `dpm set-device-owner` refuses to run while the device has accounts, so
- * once one is added, releasing ownership can only be undone by a factory
- * reset. The release dialog says so, and it must say so truthfully rather
- * than assume: during provisioning the count is zero and releasing is
- * cheap, which is exactly when the escape hatch should be exercised.
- */
- fun hasAccounts(): Boolean = runCatching {
- // Measured on device rather than assumed: with GET_ACCOUNTS undeclared
- // this returned 5 accounts while dumpsys reported 6, so the call works
- // without the runtime permission but does not necessarily see every
- // account. Only "any account at all" is needed here, so the visibility
- // gap does not matter -- and a permission prompt would be a heavy
- // price for one warning string.
- android.accounts.AccountManager.get(context)
- .getAccountsByType(null)
- .isNotEmpty()
- }.getOrElse { error ->
- // Fail toward the scarier message: claiming a release is cheap when it
- // is not is the more damaging of the two possible wrong answers.
- Log.w(FocusDeviceAdminReceiver.TAG, "could not read accounts", error)
- true
- }
-
- /** Blocks or unblocks uninstalling this app. See [DevicePolicyUninstallGuard.setSelfUninstallBlocked]. */
- fun setSelfUninstallBlocked(blocked: Boolean): Boolean =
- uninstallGuard.setSelfUninstallBlocked(blocked)
-
- /** Whether uninstalling this app is currently blocked. */
- fun isSelfUninstallBlocked(): Boolean = uninstallGuard.isSelfUninstallBlocked()
-
- /** Blocks uninstalling an arbitrary package. See [DevicePolicyUninstallGuard.setPackageUninstallBlocked]. */
- fun setPackageUninstallBlocked(packageName: String, blocked: Boolean): Boolean =
- uninstallGuard.setPackageUninstallBlocked(packageName, blocked)
-
- /** Pins [vpnPackage] as the always-on VPN. See [DevicePolicyVpnDns.setAlwaysOnVpn]. */
- fun setAlwaysOnVpn(vpnPackage: String, lockdown: Boolean = false): String? =
- vpnDns.setAlwaysOnVpn(vpnPackage, lockdown)
-
- /** Pins Private DNS to [host]. See [DevicePolicyVpnDns.setPrivateDns]. */
- fun setPrivateDns(host: String): String? = vpnDns.setPrivateDns(host)
-
- /** The Private DNS host currently pinned, or null. */
- fun privateDnsHost(): String? = vpnDns.privateDnsHost()
-
- /** Blocks or unblocks user changes to Private DNS. */
- fun setPrivateDnsConfigBlocked(blocked: Boolean): Boolean =
- vpnDns.setPrivateDnsConfigBlocked(blocked)
-
- /** The package currently pinned as always-on VPN, or null. */
- fun alwaysOnVpnPackage(): String? = vpnDns.alwaysOnVpnPackage()
-
- /** Prevents the user from changing VPN configuration. See [DevicePolicyVpnDns.setVpnConfigBlocked]. */
- fun setVpnConfigBlocked(blocked: Boolean): Boolean = vpnDns.setVpnConfigBlocked(blocked)
-
- /**
- * Describes provisioning state for the UI, so the escape hatch can be found
- * without a PC attached.
- */
- fun status(): Map = mapOf(
- "packageName" to context.packageName,
- "isDeviceOwner" to isDeviceOwner(),
- "isAdminActive" to isAdminActive(),
- "sdkInt" to Build.VERSION.SDK_INT,
- "restrictionsApplied" to false,
- "uninstallBlocked" to isSelfUninstallBlocked(),
- "alwaysOnVpn" to (alwaysOnVpnPackage() ?: ""),
- "hasAccounts" to hasAccounts(),
- )
-
- /**
- * Relinquishes device ownership without wiping the device.
- *
- * This is the only exit that preserves user data: `dpm remove-active-admin`
- * refuses to act on a device owner, and a factory reset is the sole
- * alternative — one that `DISALLOW_FACTORY_RESET` could itself remove.
- *
- * [DevicePolicyManager.clearDeviceOwnerApp] is deprecated (API 26) but
- * still functional, and it is callable only by the owning package on
- * itself, which is exactly why this method has to live inside the app.
- *
- * @return true when ownership was released or was never held.
- */
- @Suppress("DEPRECATION")
- fun releaseDeviceOwner(): Boolean {
- if (!isDeviceOwner()) {
- Log.i(FocusDeviceAdminReceiver.TAG, "Not device owner; nothing to release")
- return true
- }
- return runCatching {
- // Order matters and is not interchangeable: every one of these is a
- // device-owner power, so clearing ownership first would leave them
- // in place with nothing privileged left to lift them — an app that
- // owns nothing and cannot be removed, and a pinned VPN nobody can
- // reconfigure. Undo the restrictions, then release.
- setSelfUninstallBlocked(false)
- setVpnConfigBlocked(false)
- // Leaving Private DNS pinned to a resolver the user cannot change
- // would outlive the ownership required to unpin it, and if that
- // host ever stops answering the device has no DNS at all.
- setPrivateDnsConfigBlocked(false)
- runCatching { dpm.setGlobalPrivateDnsModeOpportunistic(admin) }
- .onFailure {
- Log.w(FocusDeviceAdminReceiver.TAG, "could not reset Private DNS", it)
- }
- // Read the pinned package BEFORE unpinning: afterwards the getter
- // returns null and the provider would stay permanently
- // unremovable, which is the exact trap this block exists to avoid.
- val vpn = vpnDns.alwaysOnVpnBlockedPackage
- // Unpinning clears lockdown with it, which is what restores
- // connectivity if the VPN app is the thing that broke.
- runCatching { dpm.setAlwaysOnVpnPackage(admin, null, false) }
- .onFailure {
- Log.w(FocusDeviceAdminReceiver.TAG, "could not unpin VPN", it)
- }
- vpn?.let { setPackageUninstallBlocked(it, false) }
- dpm.clearDeviceOwnerApp(context.packageName)
- val released = !isDeviceOwner()
- Log.i(FocusDeviceAdminReceiver.TAG, "clearDeviceOwnerApp -> released=$released")
- released
- }.getOrElse { error ->
- // Never rethrow: the UI button that calls this is the last resort
- // before a factory reset, so it must always render an outcome.
- Log.e(FocusDeviceAdminReceiver.TAG, "clearDeviceOwnerApp failed", error)
- false
- }
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyLocation.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyLocation.kt
deleted file mode 100644
index 407d513d..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyLocation.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.app.admin.DevicePolicyManager
-import android.content.ComponentName
-import android.content.Context
-import android.util.Log
-
-/**
- * The location-permission and location-services half of [DevicePolicyBridge].
- *
- * Split out to keep [DevicePolicyBridge] under the 250-line cap. Owns the
- * self-grant-permissions and enable-location DPM calls the geofence pass
- * depends on, while [DevicePolicyBridge] keeps ownership/uninstall/hide/VPN
- * concerns and delegates these calls to an instance of this class.
- */
-internal class DevicePolicyLocation(
- private val context: Context,
- private val dpm: DevicePolicyManager,
- private val admin: ComponentName,
- private val isDeviceOwnerOrProfileOwner: () -> Boolean,
- private val isDeviceOwner: () -> Boolean,
-) {
-
- /**
- * Self-grants the location permissions the geofence needs.
- *
- * A device owner can grant its own runtime permissions with no user
- * prompt, which matters here for two reasons. First, FINE is required:
- * COARSE is fuzzed to a ~1-2 km grid on Android 12+, against a 150 m
- * fence, so a coarse-only build misclassifies home as AWAY and restores
- * everything the geofence exists to hide. Second, background location was
- * previously granted by hand over adb, which is invisible state that a
- * reinstall silently loses -- and losing it fails closed, blocking
- * everything with no on-screen explanation.
- *
- * Re-asserted on every pass rather than once at provisioning, matching the
- * VPN and private-DNS pins: the protection then self-heals instead of
- * depending on anyone remembering.
- *
- * @return the permissions that are granted after this call.
- */
- fun grantLocationPermissions(): Set {
- if (!isDeviceOwnerOrProfileOwner()) return emptySet()
- return LOCATION_PERMISSIONS.filterTo(mutableSetOf()) { permission ->
- runCatching {
- dpm.setPermissionGrantState(
- admin,
- context.packageName,
- permission,
- DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED,
- )
- // Read back rather than trusting the setter: setPermissionGrantState
- // returns false for a permission the platform refuses to
- // auto-grant, and ACCESS_BACKGROUND_LOCATION is exactly the
- // kind of permission that can be refused.
- dpm.getPermissionGrantState(admin, context.packageName, permission) ==
- DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED
- }.getOrElse { error ->
- Log.w(FocusDeviceAdminReceiver.TAG, "could not grant $permission", error)
- false
- }
- }
- }
-
- /** Turns location services on, so a fix is obtainable at all. */
- fun enableLocation(): Boolean {
- if (!isDeviceOwner()) return false
- return runCatching {
- dpm.setLocationEnabled(admin, true)
- true
- }.getOrElse { error ->
- // Not fatal: the pass still runs and fails closed without a fix.
- Log.w(FocusDeviceAdminReceiver.TAG, "could not enable location", error)
- false
- }
- }
-
- private companion object {
- /**
- * Location permissions self-granted every pass.
- *
- * Ordered coarse-then-fine-then-background because that is the order
- * the platform expects them to be escalated in; granting background
- * without a foreground grant in place is refused.
- */
- val LOCATION_PERMISSIONS = listOf(
- android.Manifest.permission.ACCESS_COARSE_LOCATION,
- android.Manifest.permission.ACCESS_FINE_LOCATION,
- android.Manifest.permission.ACCESS_BACKGROUND_LOCATION,
- )
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyUninstallGuard.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyUninstallGuard.kt
deleted file mode 100644
index 171ddf87..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyUninstallGuard.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.app.admin.DevicePolicyManager
-import android.content.ComponentName
-import android.content.Context
-import android.util.Log
-
-/**
- * The uninstall-blocking half of [DevicePolicyBridge].
- *
- * Split out to keep [DevicePolicyBridge] under the 250-line cap. Owns the
- * uninstall-block DPM calls for both this app (the soft-brick guard) and an
- * arbitrary package (used for the VPN provider), while [DevicePolicyBridge]
- * keeps ownership/hide/VPN/location concerns and delegates these calls to an
- * instance of this class.
- */
-internal class DevicePolicyUninstallGuard(
- private val context: Context,
- private val dpm: DevicePolicyManager,
- private val admin: ComponentName,
- private val isDeviceOwner: () -> Boolean,
-) {
-
- /**
- * Blocks or unblocks uninstalling this app.
- *
- * Device owner already hides Settings' uninstall button, but `adb uninstall`
- * still works without this, and that path is the soft-brick: removing the
- * app without releasing first strands ownership with no holder, and a
- * factory reset becomes the only exit.
- *
- * Self-targeted deliberately. Blocking uninstall of *other* packages is a
- * separate concern and is not what this protects.
- */
- fun setSelfUninstallBlocked(blocked: Boolean): Boolean {
- if (!isDeviceOwner()) {
- Log.w(FocusDeviceAdminReceiver.TAG, "not DO; cannot change uninstall block")
- return false
- }
- return runCatching {
- dpm.setUninstallBlocked(admin, context.packageName, blocked)
- val applied = dpm.isUninstallBlocked(admin, context.packageName)
- Log.i(FocusDeviceAdminReceiver.TAG, "setUninstallBlocked($blocked) -> $applied")
- applied == blocked
- }.getOrElse { error ->
- Log.e(FocusDeviceAdminReceiver.TAG, "setUninstallBlocked failed", error)
- false
- }
- }
-
- /** Whether uninstalling this app is currently blocked. */
- fun isSelfUninstallBlocked(): Boolean {
- if (!isDeviceOwner()) return false
- return runCatching { dpm.isUninstallBlocked(admin, context.packageName) }
- .getOrDefault(false)
- }
-
- /**
- * Blocks uninstalling an arbitrary package, used for the VPN provider.
- *
- * Measured hole, not a hypothetical: `pm uninstall --user 0
- * com.celzero.bravedns` succeeded while the VPN was pinned and
- * DISALLOW_CONFIG_VPN was set, and the network filter went down with it.
- * The always-on pin survives in device policy, but it points at a package
- * that is no longer installed.
- */
- fun setPackageUninstallBlocked(packageName: String, blocked: Boolean): Boolean {
- if (!isDeviceOwner()) return false
- return runCatching {
- dpm.setUninstallBlocked(admin, packageName, blocked)
- val applied = dpm.isUninstallBlocked(admin, packageName)
- Log.i(FocusDeviceAdminReceiver.TAG, "uninstallBlocked($packageName)=$applied")
- applied == blocked
- }.getOrElse { error ->
- Log.e(FocusDeviceAdminReceiver.TAG, "setUninstallBlocked($packageName) failed", error)
- false
- }
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyVpnDns.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyVpnDns.kt
deleted file mode 100644
index a4363073..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyVpnDns.kt
+++ /dev/null
@@ -1,146 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.app.admin.DevicePolicyManager
-import android.content.ComponentName
-import android.util.Log
-
-/**
- * The always-on-VPN and Private DNS pinning half of [DevicePolicyBridge].
- *
- * Split out to keep [DevicePolicyBridge] under the 250-line cap. Owns the
- * network-lockdown DPM calls specifically -- pinning the VPN provider,
- * pinning Private DNS, and blocking user changes to either -- while
- * [DevicePolicyBridge] keeps ownership/uninstall/hide concerns and delegates
- * these calls to an instance of this class.
- */
-internal class DevicePolicyVpnDns(
- private val dpm: DevicePolicyManager,
- private val admin: ComponentName,
- private val isDeviceOwner: () -> Boolean,
-) {
-
- /**
- * The VPN provider whose uninstall this admin has blocked, if any.
- *
- * Read back from the system rather than remembered in the app, so the
- * release path still finds it after a process restart -- an unremovable
- * app left behind by a half-completed release is the failure worth
- * avoiding here.
- */
- val alwaysOnVpnBlockedPackage: String?
- get() = runCatching { dpm.getAlwaysOnVpnPackage(admin) }.getOrNull()
-
- /**
- * Pins [vpnPackage] as the always-on VPN, the network-level block.
- *
- * This is the DPM call, not `settings put secure always_on_vpn_app` --
- * that one writes successfully, survives a reboot, and does nothing,
- * because VpnManager ignores adb-written values. Measured on this device.
- *
- * [lockdown] drops every packet that is not going through the tunnel,
- * which is what closes "stop the VPN and browse freely". It is a real
- * hazard and not the default: if the VPN app breaks, the device has no
- * connectivity until device ownership is released. That is survivable
- * only because the release path is in the app itself and does not need
- * the network -- verified on this device.
- *
- * @return null on success, or a message describing why it failed.
- */
- fun setAlwaysOnVpn(vpnPackage: String, lockdown: Boolean = false): String? {
- if (!isDeviceOwner()) return "not device owner"
- return runCatching {
- dpm.setAlwaysOnVpnPackage(admin, vpnPackage, lockdown)
- val active = dpm.getAlwaysOnVpnPackage(admin)
- Log.i(FocusDeviceAdminReceiver.TAG, "alwaysOnVpn -> $active")
- if (active == vpnPackage) null else "pinned $active, expected $vpnPackage"
- }.getOrElse { error ->
- // UnsupportedOperationException is the documented signal that the
- // package does not declare a VPN service, or does not opt in to
- // always-on. Report it rather than leaving a silent no-op.
- Log.e(FocusDeviceAdminReceiver.TAG, "setAlwaysOnVpnPackage failed", error)
- error.message ?: error::class.java.simpleName
- }
- }
-
- /**
- * Pins Private DNS to [host] and forbids the user changing it.
- *
- * This is the layer that survives the bypass measured on 2026-08-11:
- * RethinkDNS's blocklists can be switched off from inside that app in a
- * few taps, and no device owner API can stop it. Private DNS is a system
- * setting, so the rules move to a resolver the phone cannot edit at all.
- *
- * Fails loudly rather than silently: an unresolvable or unreachable host
- * here means no DNS at all, so the caller must verify before locking.
- *
- * @return null on success, or a message describing why it failed.
- */
- fun setPrivateDns(host: String): String? {
- if (!isDeviceOwner()) return "not device owner"
- return runCatching {
- val result = dpm.setGlobalPrivateDnsModeSpecifiedHost(admin, host)
- val active = dpm.getGlobalPrivateDnsHost(admin)
- Log.i(FocusDeviceAdminReceiver.TAG, "privateDns($host) -> code=$result now=$active")
- if (result == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR && active == host) {
- null
- } else {
- "setGlobalPrivateDnsModeSpecifiedHost returned $result (host=$active)"
- }
- }.getOrElse { error ->
- Log.e(FocusDeviceAdminReceiver.TAG, "setPrivateDns failed", error)
- error.message ?: error::class.java.simpleName
- }
- }
-
- /** The Private DNS host currently pinned, or null. */
- fun privateDnsHost(): String? {
- if (!isDeviceOwner()) return null
- return runCatching { dpm.getGlobalPrivateDnsHost(admin) }.getOrNull()
- }
-
- /** Blocks or unblocks user changes to Private DNS. */
- fun setPrivateDnsConfigBlocked(blocked: Boolean): Boolean {
- if (!isDeviceOwner()) return false
- return runCatching {
- if (blocked) {
- dpm.addUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_PRIVATE_DNS)
- } else {
- dpm.clearUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_PRIVATE_DNS)
- }
- Log.i(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_PRIVATE_DNS=$blocked")
- true
- }.getOrElse { error ->
- Log.e(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_PRIVATE_DNS failed", error)
- false
- }
- }
-
- /** The package currently pinned as always-on VPN, or null. */
- fun alwaysOnVpnPackage(): String? {
- if (!isDeviceOwner()) return null
- return runCatching { dpm.getAlwaysOnVpnPackage(admin) }.getOrNull()
- }
-
- /**
- * Prevents the user from changing VPN configuration.
- *
- * Applied separately from [setAlwaysOnVpn] and only after it is confirmed
- * working: pinning the restriction first would make a failed VPN setup
- * harder to repair from the device.
- */
- fun setVpnConfigBlocked(blocked: Boolean): Boolean {
- if (!isDeviceOwner()) return false
- return runCatching {
- if (blocked) {
- dpm.addUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_VPN)
- } else {
- dpm.clearUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_VPN)
- }
- Log.i(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_VPN=$blocked")
- true
- }.getOrElse { error ->
- Log.e(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_VPN failed", error)
- false
- }
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementAlarmReceiver.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementAlarmReceiver.kt
deleted file mode 100644
index ea6f04cf..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementAlarmReceiver.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-
-/**
- * Fires when a scheduled evaluation is due and hands off to the service.
- *
- * A receiver cannot do the work itself: broadcast receivers get roughly ten
- * seconds and no foreground guarantee, while an enforcement pass queries
- * location and iterates every installed package. It exists only to start the
- * service, which then schedules the following run.
- */
-class EnforcementAlarmReceiver : BroadcastReceiver() {
-
- override fun onReceive(context: Context, intent: Intent) {
- EnforcementService.start(context)
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt
deleted file mode 100644
index 0dcb3f78..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt
+++ /dev/null
@@ -1,221 +0,0 @@
-package com.kuhy.focus_owner
-
-import kotlin.math.asin
-import kotlin.math.cos
-import kotlin.math.sin
-import kotlin.math.sqrt
-
-/**
- * The outcome of evaluating the policy against current conditions.
- *
- * A pure function of its inputs, so it can be tested without a device, a clock
- * or a location provider. The inputs and the two result enums live in
- * `EnforcementTypes.kt`.
- *
- * This is the ONLY implementation that enforces anything. `lib/enforcement.dart`
- * used to be described here as a mirror pinned by a `PolicyParityTest`; no such
- * test ever existed, and the two had silently drifted -- the Dart copy has no
- * always-blocked concept at all, so it would report YouTube as visible while it
- * is in fact hidden. The UI therefore renders the record this class produces
- * rather than re-deciding anything, which removes the drift instead of policing
- * it.
- */
-data class EnforcementDecision(
- val reason: EnforcementReason,
- val packagesToHide: Set,
- val packagesToShow: Set,
- /**
- * Metres from home, or null when there was no fix or no coordinates.
- *
- * Reported so the phone can explain itself. Coordinates are deliberately
- * NOT carried: a distance is enough to debug the geofence, and logcat is
- * readable by anyone with adb.
- */
- val distanceM: Double? = null,
- /** The value [distanceM] was compared against, including hysteresis. */
- val thresholdM: Double? = null,
- /**
- * Whether the fence returned a verdict, or null when it could not.
- *
- * Null is not the same as false: it means the question was unanswerable,
- * so the caller leaves the previous verdict standing instead of recording
- * "outside" and losing hysteresis at the worst moment.
- */
- val insideFence: Boolean? = null,
- /** Why each hidden package is hidden, for the UI and the log. */
- val hideReasons: Map = emptyMap(),
- /**
- * Whether the clock is inside the curfew window.
- *
- * Carried separately from [reason] because the two disagree: at 23:30 away
- * from home the reason is AWAY, and inferring curfew from the enum would
- * report "not in curfew" in exactly the branches where it is least obvious.
- */
- val curfewActive: Boolean = false,
-) {
- /**
- * Whether this pass leaves anything hidden.
- *
- * Derived rather than stored, which matters for the always-blocked set:
- * AWAY still hides those, so it still counts as enforcing, and the
- * uninstall block keyed off this stays on away from home. Were this tied
- * to the reason instead, leaving the house would lift the block while
- * YouTube stayed hidden -- making `adb uninstall` the off switch for the
- * one thing that is meant not to have one.
- */
- val isEnforcing: Boolean get() = packagesToHide.isNotEmpty()
-
- companion object {
- private const val EARTH_RADIUS_M = 6_371_000.0
-
- /**
- * Decides which packages should be hidden right now.
- *
- * Fail-closed on unknown location, matching `focus_daemon.sh`, which
- * logs "Location unavailable - defaulting to focus mode". Losing GPS
- * must not be a way to switch enforcement off.
- *
- * [packagesToShow] is always the complete allowed set, never a delta,
- * so a missed run repairs itself on the next one. Failing to hide is a
- * mild disappointment; failing to *unhide* strands the user without a
- * dialer or a banking app after a reboot during curfew.
- */
- fun evaluate(policy: FocusPolicy, inputs: EnforcementInputs): EnforcementDecision {
- val lat = inputs.latitude
- val lon = inputs.longitude
- val hasFix = lat != null && lon != null
- val fence = if (hasFix) {
- geofence(policy, lat, lon, inputs.currentlyEnforcing)
- } else {
- null
- }
- val atHome = fence?.inside ?: false
- // Computed before the AWAY return so every branch can report it.
- // The window is a property of the clock, not of where the phone is.
- val duringCurfew = policy.isCurfewActive(inputs.minutesSinceMidnight)
-
- // Exempt from every branch below, including AWAY and WORKOUT.
- // Restricted to packages actually present, so the sets stay a
- // description of this device rather than of the policy.
- val alwaysBlocked = inputs.installedPackages
- .intersect(policy.alwaysBlockedPackages)
- .filterNot { policy.isProtected(it) }
- .toSet()
-
- if (hasFix && !atHome) {
- return EnforcementDecision(
- reason = EnforcementReason.AWAY,
- packagesToHide = alwaysBlocked,
- packagesToShow = inputs.installedPackages - alwaysBlocked,
- distanceM = fence?.metres,
- thresholdM = fence?.threshold,
- insideFence = fence?.inside,
- hideReasons = alwaysBlocked.associateWith { HideReason.ALWAYS_BLOCKED },
- curfewActive = duringCurfew,
- )
- }
-
- val reason = when {
- !hasFix -> EnforcementReason.LOCATION_UNKNOWN
- duringCurfew -> EnforcementReason.CURFEW
- else -> EnforcementReason.AT_HOME
- }
-
- val hide = mutableSetOf()
- val show = mutableSetOf()
- val why = mutableMapOf()
- for (pkg in inputs.installedPackages) {
- if (pkg in alwaysBlocked) {
- hide.add(pkg)
- why[pkg] = HideReason.ALWAYS_BLOCKED
- } else if (policy.isAllowed(pkg, duringCurfew)) {
- show.add(pkg)
- } else {
- hide.add(pkg)
- why[pkg] = if (duringCurfew) {
- HideReason.NOT_IN_NIGHT_ALLOWLIST
- } else {
- HideReason.NOT_IN_ALLOWLIST
- }
- }
- }
-
- if (inputs.workoutActive) {
- for (pkg in policy.workoutExemptPackages) {
- // A workout exemption must not become a way to reach the
- // always-blocked set, or "go for a run" is the off switch.
- if (pkg in alwaysBlocked) continue
- if (hide.remove(pkg)) {
- show.add(pkg)
- why.remove(pkg)
- }
- }
- return EnforcementDecision(
- reason = EnforcementReason.WORKOUT,
- packagesToHide = hide,
- packagesToShow = show,
- distanceM = fence?.metres,
- thresholdM = fence?.threshold,
- insideFence = fence?.inside,
- hideReasons = why,
- curfewActive = duringCurfew,
- )
- }
- return EnforcementDecision(
- reason = reason,
- packagesToHide = hide,
- packagesToShow = show,
- distanceM = fence?.metres,
- thresholdM = fence?.threshold,
- insideFence = fence?.inside,
- hideReasons = why,
- curfewActive = duringCurfew,
- )
- }
-
- /**
- * Evaluates the fence, returning the geometry as well as the verdict.
- *
- * The distance and the threshold are returned rather than discarded so
- * the app can say *why* it decided what it decided. Without them the
- * three states are indistinguishable from outside, which is what made
- * the original misbehaviour impossible to diagnose from the phone.
- */
- private fun geofence(
- policy: FocusPolicy,
- latitude: Double,
- longitude: Double,
- currentlyEnforcing: Boolean,
- ): Geofence {
- val home = policy.home
- val threshold = home.radiusM + if (currentlyEnforcing) home.hysteresisM else 0.0
- // No coordinates configured: refuse to claim the user is away.
- // Reported as inside with an unknown distance, so the UI can say
- // "no home set" rather than presenting a fabricated 0 m.
- if (!home.hasCoordinates) {
- return Geofence(metres = null, threshold = threshold, inside = true)
- }
- val metres = haversineMetres(
- home.latitude!!,
- home.longitude!!,
- latitude,
- longitude,
- )
- return Geofence(metres, threshold, inside = metres <= threshold)
- }
-
- private fun haversineMetres(
- lat1: Double,
- lon1: Double,
- lat2: Double,
- lon2: Double,
- ): Double {
- val dLat = Math.toRadians(lat2 - lat1)
- val dLon = Math.toRadians(lon2 - lon1)
- val a = sin(dLat / 2) * sin(dLat / 2) +
- cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
- sin(dLon / 2) * sin(dLon / 2)
- return 2 * EARTH_RADIUS_M * asin(sqrt(a))
- }
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementLog.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementLog.kt
deleted file mode 100644
index 33d53607..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementLog.kt
+++ /dev/null
@@ -1,223 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.content.Context
-import android.util.Log
-import org.json.JSONArray
-import org.json.JSONObject
-import java.io.File
-
-/**
- * Append-only, size-capped record of enforcement passes.
- *
- * Read back through the platform channel rather than adb, because neither adb
- * route works on this build: `run-as` returns "package not debuggable" on the
- * release build device owner requires (see [EnforcementRunner.setHomeToCurrentLocation]),
- * and logcat rotates -- measured 2026-08-14, `adb logcat -d -s FocusOwner:V`
- * came back empty while `dumpsys alarm` showed 82 alarms had fired. An in-app
- * reader is the only durable option, so this file plus a UI is the deliverable.
- *
- * Never records coordinates. Distance in metres, fix age, accuracy and
- * provider carry everything needed to debug the geofence without writing down
- * where the user lives.
- */
-class EnforcementLog(private val directory: File) {
-
- constructor(context: Context) : this(context.filesDir)
-
- private val file: File get() = File(directory, FILE_NAME)
-
- /**
- * Appends one record.
- *
- * Every failure is swallowed. This is instrumentation: a logging problem
- * must never be able to fail an enforcement pass, because the pass running
- * is the app's actual promise.
- */
- fun append(record: JSONObject) {
- runCatching {
- if (!directory.exists()) directory.mkdirs()
- file.appendText(record.toString() + "\n")
- rotateIfNeeded()
- }.onFailure {
- Log.w(FocusDeviceAdminReceiver.TAG, "could not append enforcement record", it)
- }
- }
-
- /**
- * The most recent records, newest first.
- *
- * A corrupt line is skipped rather than failing the read: a half-written
- * record from a process killed mid-append must not cost the whole history,
- * which is exactly when the history is most wanted.
- */
- fun readRecent(limit: Int = DEFAULT_LIMIT): List = runCatching {
- if (!file.exists()) return emptyList()
- file.readLines()
- .asReversed()
- .asSequence()
- .filter { it.isNotBlank() }
- .filter { line -> runCatching { JSONObject(line) }.isSuccess }
- .take(limit)
- .toList()
- }.getOrElse {
- Log.w(FocusDeviceAdminReceiver.TAG, "could not read enforcement log", it)
- emptyList()
- }
-
- /**
- * Trims the file once it grows past [MAX_BYTES].
- *
- * Rotates by whole lines rather than truncating bytes, so the file can
- * never be left starting mid-record, which would make the oldest surviving
- * entry unparsable.
- */
- private fun rotateIfNeeded() {
- if (file.length() <= MAX_BYTES) return
- val kept = file.readLines().filter { it.isNotBlank() }.takeLast(KEEP_LINES)
- file.writeText(kept.joinToString("\n", postfix = "\n"))
- }
-
- companion object {
- private const val FILE_NAME = "enforcement_log.jsonl"
-
- /**
- * Byte size that triggers a trim.
- *
- * NOT the resulting file size, and not a ceiling. A trim drops the
- * file to [KEEP_LINES] whole records and appends resume until this is
- * crossed again, so the steady state is 400 records plus one trim
- * interval -- measured at 492 lines after 1200 appends. Measured
- * against this device (~32 sweepable packages): ~730 B/record at home
- * and ~1.7 KB during curfew when 17 apps are hidden, so the file sits
- * somewhere around 300-700 KB. Bounded, just not by this number.
- */
- private const val MAX_BYTES = 256L * 1024L
-
- /** The real bound: ~400 records is a little over four days at 15 min. */
- private const val KEEP_LINES = 400
- private const val DEFAULT_LIMIT = 200
-
- /** Schema version of the records written by this build. */
- const val SCHEMA_VERSION = 1
-
- /**
- * Renders one pass as a record.
- *
- * Deliberately a pure function of its arguments so it can be tested
- * without a device, and so it is obvious by inspection that no
- * coordinate reaches it: it takes a [LocationFix] but reads only the
- * age, accuracy, provider and outcome.
- */
- fun record(
- timestampMs: Long,
- decision: EnforcementDecision,
- fix: LocationFix?,
- policy: FocusPolicy?,
- homeConfigured: Boolean,
- permissions: Set,
- hidDelta: List,
- restoredDelta: List,
- failure: String? = null,
- ): JSONObject = JSONObject().apply {
- put("v", SCHEMA_VERSION)
- put("ts", timestampMs)
- put("reason", decision.reason.name)
- put("distance_m", decision.distanceM ?: JSONObject.NULL)
- put("threshold_m", decision.thresholdM ?: JSONObject.NULL)
- put("inside_fence", decision.insideFence ?: JSONObject.NULL)
- put("home_configured", homeConfigured)
- put(
- "fix",
- JSONObject().apply {
- put("age_ms", fix?.takeIf { it.hasCoordinates }?.ageMs ?: JSONObject.NULL)
- put("provider", fix?.provider ?: JSONObject.NULL)
- put("accuracy_m", fix?.accuracyM ?: JSONObject.NULL)
- put("outcome", fix?.outcome?.name ?: JSONObject.NULL)
- },
- )
- put("permissions", JSONArray(permissions.sorted()))
- // From the clock, not inferred from the reason: at 23:30 away from
- // home the reason is AWAY while the curfew is genuinely in force.
- put("curfew_active", decision.curfewActive)
- put(
- "curfew_window",
- policy?.curfew?.let { "${hhmm(it.startMinutes)}-${hhmm(it.endMinutes)}" }
- ?: JSONObject.NULL,
- )
- put(
- "counts",
- JSONObject().apply {
- put("to_hide", decision.packagesToHide.size)
- put("to_show", decision.packagesToShow.size)
- put("hid_delta", hidDelta.size)
- put("restored_delta", restoredDelta.size)
- },
- )
- put(
- "hidden",
- JSONArray().apply {
- decision.packagesToHide.sorted().forEach { pkg ->
- put(
- JSONObject()
- .put("pkg", pkg)
- .put(
- "why",
- decision.hideReasons[pkg]?.name ?: "UNKNOWN",
- ),
- )
- }
- },
- )
- put("hid", JSONArray(hidDelta.sorted()))
- put("restored", JSONArray(restoredDelta.sorted()))
- put("failure", failure ?: JSONObject.NULL)
- }
-
- /**
- * A record for a pass that produced no decision at all.
- *
- * Written from the early-return paths -- not provisioned, unreadable
- * policy -- because those are precisely the states behind "it just
- * stopped working and nothing says why", and without this they leave
- * no trace anywhere the phone can show.
- */
- fun failureRecord(timestampMs: Long, failure: String): JSONObject =
- JSONObject().apply {
- put("v", SCHEMA_VERSION)
- put("ts", timestampMs)
- put("reason", "NO_DECISION")
- put("distance_m", JSONObject.NULL)
- put("threshold_m", JSONObject.NULL)
- put("inside_fence", JSONObject.NULL)
- put("home_configured", JSONObject.NULL)
- put(
- "fix",
- JSONObject().apply {
- put("age_ms", JSONObject.NULL)
- put("provider", JSONObject.NULL)
- put("accuracy_m", JSONObject.NULL)
- put("outcome", JSONObject.NULL)
- },
- )
- put("permissions", JSONArray())
- put("curfew_active", JSONObject.NULL)
- put("curfew_window", JSONObject.NULL)
- put(
- "counts",
- JSONObject().apply {
- put("to_hide", 0)
- put("to_show", 0)
- put("hid_delta", 0)
- put("restored_delta", 0)
- },
- )
- put("hidden", JSONArray())
- put("hid", JSONArray())
- put("restored", JSONArray())
- put("failure", failure)
- }
-
- private fun hhmm(minutes: Int): String =
- "%02d:%02d".format(minutes / 60, minutes % 60)
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt
deleted file mode 100644
index 62799ee4..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt
+++ /dev/null
@@ -1,237 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.content.Context
-import android.util.Log
-import java.util.Calendar
-
-/**
- * Gathers the inputs for one enforcement pass and applies the result.
- *
- * Split out of [EnforcementService] so the service stays a thin lifecycle
- * wrapper and this part can be reasoned about — and eventually instrumented —
- * on its own. Location-fix acquisition itself lives in [LocationAcquisition];
- * this class keeps the decide/apply orchestration and delegates to it.
- */
-class EnforcementRunner(private val context: Context) {
-
- private val locationAcquisition = LocationAcquisition(context)
- private val homeLocationStore = HomeLocationStore(context) { freshWindowMs, maxAccuracyM ->
- acquireLocation(freshWindowMs = freshWindowMs, maxAccuracyM = maxAccuracyM)
- }
-
- /**
- * Inputs from the current pass, carried from [decide] to [apply].
- *
- * The record needs both halves: the fix and whether home is configured are
- * only visible while deciding, the hide/show deltas only while applying.
- * This is why [EnforcementService] uses ONE runner for both calls -- a
- * second instance would log a pass with no fix metadata at all.
- */
- private var pass: PassContext? = null
-
- private data class PassContext(
- val fix: LocationFix,
- val homeConfigured: Boolean,
- val policy: FocusPolicy?,
- )
-
- /** Builds the decision, or null when the policy cannot be read. */
- fun decide(): EnforcementDecision? {
- val policy = try {
- FocusPolicy.load(context)
- } catch (e: Exception) {
- // A policy that cannot be parsed must never be treated as an empty
- // allowlist: that would hide every app on the device.
- Log.e(FocusDeviceAdminReceiver.TAG, "policy unreadable", e)
- return null
- }
-
- val home = homeLocationStore.readHomeLocation()
- // Acquired even when no home is configured, so the log can tell
- // "never provisioned" apart from "could not get a fix" -- previously
- // both produced an identical LOCATION_UNKNOWN with nothing to
- // distinguish them.
- val fix = acquireLocation()
- val fresh = fix.isFresh(FRESH_WINDOW_MS)
- pass = PassContext(fix = fix, homeConfigured = home != null, policy = policy)
- val calendar = Calendar.getInstance()
- val minutes = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE)
-
- // Coordinates come from app-private storage, not the committed asset,
- // which ships redacted. Substituted in here so the decision layer sees
- // one coherent policy.
- val effective = if (home == null) {
- policy
- } else {
- policy.copy(
- home = policy.home.copy(latitude = home.first, longitude = home.second),
- )
- }
-
- return EnforcementDecision.evaluate(
- effective,
- EnforcementInputs(
- installedPackages = sweepablePackages(context, effective),
- minutesSinceMidnight = minutes,
- // A stale fix is withheld entirely rather than passed with its
- // age attached: it must not be able to classify the phone as
- // AWAY. Fail-closed here is only safe because acquireLocation
- // now actively requests a fix, so "stale" is the exception
- // rather than the steady state.
- latitude = if (fresh) fix.latitude else null,
- longitude = if (fresh) fix.longitude else null,
- // Keyed off whether the last pass placed the phone inside the
- // fence, NOT off whether it hid anything. AWAY still hides the
- // always-blocked set, so the old `wasEnforcing()` was true in
- // every state and the threshold was permanently radius +
- // hysteresis -- biasing every borderline call toward AT_HOME.
- currentlyEnforcing = wasInsideFence(),
- workoutActive = false,
- fix = fix,
- ),
- )
- }
-
- /**
- * Applies a decision.
- *
- * Show is applied before hide. If a pass is interrupted part-way — the
- * process is killed, the device sleeps — the half that has run is the half
- * that restores access rather than the half that removes it.
- */
- fun apply(decision: EnforcementDecision, bridge: DevicePolicyBridge) {
- // Re-read rather than threaded through from decide(): an unreadable
- // policy must not stop a pass that is otherwise ready to apply, and
- // the VPN pin is the one step that should survive a policy problem.
- val policy = runCatching { FocusPolicy.load(context) }.getOrNull()
-
- // Re-asserted every pass for the same self-healing reason as the pins
- // below. The geofence is only as good as its input, and a lost FINE
- // grant degrades it silently: coarse location is fuzzed to a ~1-2 km
- // grid, so the phone starts reading home as AWAY with nothing on
- // screen to say why. Background location was previously granted by
- // hand over adb, which a reinstall would have quietly dropped.
- val granted = bridge.grantLocationPermissions()
- bridge.enableLocation()
-
- // VPN/Private DNS pins + self-uninstall-block re-assertion; see
- // [pinPolicyToDevice].
- pinPolicyToDevice(policy, decision, bridge)
-
- // Show/hide deltas; see [applyVisibilitySweep]. Show is applied before
- // hide inside it for the same interrupted-pass reason documented there.
- val (hiddenNow, restoredNow) =
- applyVisibilitySweep(decision, bridge, selfPackage = context.packageName)
- rememberEnforcing(decision.isEnforcing)
- // Only recorded when the fence actually returned an answer. A pass
- // with no usable fix must leave the previous verdict standing rather
- // than resetting it to "outside", which would drop hysteresis for the
- // next pass at exactly the moment the input is least reliable.
- decision.insideFence?.let { rememberInsideFence(it) }
- // Durable, in-app-readable record. Logcat is kept for live tailing but
- // cannot be relied on: it rotates, and `run-as` is refused on this
- // build, so logcat alone left the phone unable to explain itself.
- EnforcementLog(context).append(
- EnforcementLog.record(
- timestampMs = System.currentTimeMillis(),
- decision = decision,
- fix = pass?.fix,
- policy = pass?.policy ?: policy,
- homeConfigured = pass?.homeConfigured ?: homeLocationStore.hasHomeLocation(),
- permissions = granted,
- hidDelta = hiddenNow,
- restoredDelta = restoredNow,
- ),
- )
- Log.i(
- FocusDeviceAdminReceiver.TAG,
- "applied ${decision.reason}: hid ${hiddenNow.size}, " +
- "restored ${restoredNow.size}",
- )
- }
-
- /** Writes the current location as home. See [HomeLocationStore.setHomeToCurrentLocation]. */
- fun setHomeToCurrentLocation(): String? = homeLocationStore.setHomeToCurrentLocation()
-
- /** Whether home coordinates are provisioned, without revealing them. */
- fun hasHomeLocation(): Boolean = homeLocationStore.hasHomeLocation()
-
- /** Acquires a fix. See [LocationAcquisition.acquireLocation]. */
- fun acquireLocation(
- timeoutMs: Long = LocationAcquisition.ACQUIRE_TIMEOUT_MS,
- freshWindowMs: Long = LocationAcquisition.FRESH_WINDOW_MS,
- maxAccuracyM: Double? = null,
- ): LocationFix = locationAcquisition.acquireLocation(timeoutMs, freshWindowMs, maxAccuracyM)
-
- private fun prefs() =
- context.getSharedPreferences("enforcement", Context.MODE_PRIVATE)
-
- /** Whether the previous pass was enforcing. */
- private fun wasEnforcing(): Boolean = prefs().getBoolean(KEY_ENFORCING, false)
-
- private fun rememberEnforcing(value: Boolean) =
- prefs().edit().putBoolean(KEY_ENFORCING, value).apply()
-
- /**
- * Whether the previous pass placed the phone inside the fence.
- *
- * This, not [wasEnforcing], is what hysteresis must key off. Hysteresis
- * exists to stop a phone sitting on the boundary from flapping, so it has
- * to widen the fence only for someone already judged to be inside it.
- */
- private fun wasInsideFence(): Boolean = prefs().getBoolean(KEY_WAS_INSIDE, false)
-
- private fun rememberInsideFence(value: Boolean) =
- prefs().edit().putBoolean(KEY_WAS_INSIDE, value).apply()
-
- companion object {
- private const val KEY_ENFORCING = "was_enforcing"
-
- /** Whether the previous pass placed the phone inside the fence. */
- private const val KEY_WAS_INSIDE = "was_inside_fence"
-
- /** Longest a pass will wait for a fresh fix before falling back. */
- const val ACQUIRE_TIMEOUT_MS = LocationAcquisition.ACQUIRE_TIMEOUT_MS
-
- /**
- * A fix at most this old is trusted as describing "now".
- *
- * Deliberately longer than the 15-minute enforcement cadence. A window
- * below the cadence would mark the newest available cache stale on
- * every pass of a phone sitting still, sending every pass to
- * LOCATION_UNKNOWN and blocking everything -- turning the fail-closed
- * default from a safety net into the normal case.
- */
- const val FRESH_WINDOW_MS = LocationAcquisition.FRESH_WINDOW_MS
-
- /**
- * Picks the fix best able to answer "is the phone inside the fence".
- *
- * Freshness is a HARD GATE and accuracy is a preference *within* it,
- * in that order. Getting this backwards is not a style choice: sorting
- * by accuracy first can surface a very precise fix from this morning,
- * which the age check then rejects, sending the pass to
- * LOCATION_UNKNOWN and blocking everything -- the exact office
- * symptom. A recent coarse fix beats an old precise one because the
- * old one is describing somewhere the phone no longer is.
- *
- * Within the window, accuracy decides: a ±2 km network fix cannot
- * answer a 150 m question, so it loses to a ±10 m GPS fix even if it
- * is a minute newer. Unknown accuracy sorts last rather than first --
- * it might be anything, and this comparison exists to avoid guessing.
- *
- * When nothing is fresh, returns the newest stale fix so the log can
- * report what was actually available; the caller classifies it
- * unusable either way.
- */
- fun best(candidates: List, freshWindowMs: Long): LocationFix? {
- if (candidates.isEmpty()) return null
- val fresh = candidates.filter { it.ageMs <= freshWindowMs }
- if (fresh.isEmpty()) return candidates.minByOrNull { it.ageMs }
- return fresh.sortedWith(
- compareBy { it.accuracyM ?: Double.MAX_VALUE }
- .thenBy { it.ageMs },
- ).first()
- }
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementScheduler.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementScheduler.kt
deleted file mode 100644
index e90f441f..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementScheduler.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.app.AlarmManager
-import android.app.PendingIntent
-import android.content.Context
-import android.content.Intent
-import android.os.Build
-import android.util.Log
-
-/**
- * Wakes the enforcement service at the moments the decision can change.
- *
- * A polling loop is the obvious design and the wrong one: Doze will skip it,
- * and skipping the 05:00 boundary means the curfew allowlist stays applied into
- * the morning — the user finds their banking app missing and no obvious cause.
- * The transitions are known ahead of time, so they get exact alarms.
- *
- * A periodic fallback runs alongside them. Alarms can be lost to a reboot, a
- * force-stop, or a system that decides otherwise; the fallback bounds how long
- * a lost alarm can leave stale policy applied. It is deliberately coarse, since
- * its job is repair rather than precision.
- */
-class EnforcementScheduler(private val context: Context) {
-
- private val alarms = context.getSystemService(AlarmManager::class.java)
-
- /**
- * Schedules the next evaluation.
- *
- * Uses `setExactAndAllowWhileIdle` where permitted so Doze cannot defer a
- * boundary. When exact alarms are not permitted the inexact variant still
- * fires, just late — degraded rather than broken, which is the right
- * failure for a wellbeing tool.
- */
- fun scheduleNext(atMillis: Long = defaultNextRun()) {
- val pending = PendingIntent.getBroadcast(
- context,
- REQUEST_CODE,
- Intent(context, EnforcementAlarmReceiver::class.java),
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
- val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
- alarms.canScheduleExactAlarms()
- if (exact) {
- alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMillis, pending)
- } else {
- // Not fatal: the alarm still arrives, only later. Logged because a
- // silently-inexact schedule would look like a bug at the boundary.
- Log.w(FocusDeviceAdminReceiver.TAG, "exact alarms not permitted; using inexact")
- alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMillis, pending)
- }
- }
-
- /** Cancels any pending evaluation. */
- fun cancel() {
- PendingIntent.getBroadcast(
- context,
- REQUEST_CODE,
- Intent(context, EnforcementAlarmReceiver::class.java),
- PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
- )?.let(alarms::cancel)
- }
-
- /**
- * The sooner of the periodic fallback and the next curfew boundary.
- *
- * The class comment has always described exact alarms at the boundaries,
- * but nothing ever computed one: both callers invoked `scheduleNext()`
- * bare, so the default was only ever `now + 15 min` and 05:00 could be
- * served up to fifteen minutes late -- exactly the "banking app still
- * missing in the morning" failure the comment warns about.
- */
- private fun defaultNextRun(): Long {
- val now = System.currentTimeMillis()
- val fallback = now + FALLBACK_INTERVAL_MS
- val boundary = nextCurfewBoundary(now) ?: return fallback
- return minOf(fallback, boundary)
- }
-
- /** When the curfew next opens or closes, or null when it is disabled. */
- private fun nextCurfewBoundary(now: Long): Long? {
- val curfew = runCatching { FocusPolicy.load(context) }.getOrNull()?.curfew
- ?: return null
- return listOf(curfew.startMinutes, curfew.endMinutes)
- .map { nextOccurrenceOf(it, now) }
- .min()
- }
-
- /** The next wall-clock instant at [minutesSinceMidnight], strictly after [now]. */
- private fun nextOccurrenceOf(minutesSinceMidnight: Int, now: Long): Long {
- val calendar = java.util.Calendar.getInstance().apply {
- timeInMillis = now
- set(java.util.Calendar.HOUR_OF_DAY, minutesSinceMidnight / 60)
- set(java.util.Calendar.MINUTE, minutesSinceMidnight % 60)
- set(java.util.Calendar.SECOND, 0)
- set(java.util.Calendar.MILLISECOND, 0)
- }
- // Rolled forward rather than clamped, so a boundary already passed
- // today schedules for tomorrow instead of firing immediately.
- if (calendar.timeInMillis <= now) {
- calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
- }
- return calendar.timeInMillis
- }
-
- companion object {
- private const val REQUEST_CODE = 100
-
- /**
- * Upper bound on how long a lost alarm can leave stale policy applied.
- *
- * Fifteen minutes trades a little battery for a much smaller window in
- * which the user is locked out of an app they should have back.
- */
- const val FALLBACK_INTERVAL_MS: Long = 15 * 60 * 1000
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementService.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementService.kt
deleted file mode 100644
index edc17387..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementService.kt
+++ /dev/null
@@ -1,196 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.Service
-import android.content.Context
-import android.content.Intent
-import android.content.pm.ServiceInfo
-import android.os.Build
-import android.os.IBinder
-import android.util.Log
-
-/**
- * Applies the current enforcement decision, then schedules the next evaluation.
- *
- * Deliberately not a polling loop. Doze can skip a poll indefinitely, and the
- * transitions that matter are known in advance — the curfew boundaries — so
- * [EnforcementScheduler] sets exact alarms for them instead. The service wakes,
- * applies, schedules, and stops; it does not sit resident burning battery to
- * re-decide something that cannot change until a known moment.
- *
- * The unhide path is the one that has to be reliable. Failing to hide an app is
- * a mild disappointment; failing to *unhide* one strands the user without a
- * dialer or a banking app because the phone happened to reboot during curfew.
- * So every run applies the complete allowed set rather than a delta, and a
- * missed run repairs itself on the next one without needing to know what the
- * previous one did.
- */
-class EnforcementService : Service() {
-
- /** Single-threaded so overlapping starts queue rather than race. */
- private val worker = java.util.concurrent.Executors.newSingleThreadExecutor()
-
- override fun onBind(intent: Intent?): IBinder? = null
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- // The LOCATION type is what lifts background location throttling for
- // the fix this pass is about to request. It is requested rather than
- // assumed: the platform refuses that type unless a location permission
- // is already held, and this app grants its own permissions *during* a
- // pass -- so the very first pass after a fresh install has none yet.
- // Measured 2026-08-14: passing it unconditionally threw
- // SecurityException and crashed the service on every alarm, which is
- // strictly worse than a throttled fix. Fall back to SPECIAL_USE alone.
- val foreground = startForegroundTyped(FGS_TYPE) ||
- startForegroundTyped(ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
- if (!foreground) {
- // Neither type was accepted. Continuing would leave a started
- // service that never called startForeground, which the platform
- // kills with ForegroundServiceDidNotStartInTimeException -- a
- // crash on every alarm, i.e. enforcement silently stops. Bailing
- // out costs one tick instead; the alarm chain is re-armed below.
- Log.e(FocusDeviceAdminReceiver.TAG, "could not enter foreground; skipping pass")
- EnforcementScheduler(applicationContext).scheduleNext()
- stopSelf(startId)
- return START_NOT_STICKY
- }
- // Run off the main thread. The pass now waits up to
- // EnforcementRunner.ACQUIRE_TIMEOUT_MS for a location fix, and
- // blocking the main thread for that long is an ANR. The foreground
- // notification is already showing, so the service is entitled to stay
- // alive for the few seconds this takes.
- worker.execute {
- runCatching { applyAndReschedule() }
- .onFailure {
- Log.e(FocusDeviceAdminReceiver.TAG, "enforcement run failed", it)
- }
- // Stopped from the completion path, not from onStartCommand: doing
- // it there would tear the service down while the work is in flight.
- stopSelf(startId)
- }
- // The alarm brings us back. START_NOT_STICKY because a restart by the
- // system would be a duplicate of the next alarm.
- return START_NOT_STICKY
- }
-
- override fun onDestroy() {
- worker.shutdown()
- super.onDestroy()
- }
-
- /** Enters the foreground with [type], or returns false if refused. */
- private fun startForegroundTyped(type: Int): Boolean = runCatching {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- startForeground(NOTIFICATION_ID, buildNotification(), type)
- } else {
- startForeground(NOTIFICATION_ID, buildNotification())
- }
- true
- }.getOrElse { error ->
- Log.w(FocusDeviceAdminReceiver.TAG, "FGS type $type refused", error)
- false
- }
-
- private fun applyAndReschedule() {
- val context = applicationContext
- // Genuinely scheduled FIRST, not merely in a finally. A finally covers
- // exceptions but not process death, and the pass now blocks up to
- // ACQUIRE_TIMEOUT_MS waiting for a fix -- a kill inside that window
- // (low memory, force-stop) would lose the alarm chain entirely, with
- // no way back until the app is opened or the phone reboots. Arming up
- // front costs nothing: the next pass re-arms again regardless.
- EnforcementScheduler(context).scheduleNext()
- try {
- val bridge = DevicePolicyBridge(context)
- if (!bridge.isDeviceOwner() && !bridge.isProfileOwner()) {
- // Not provisioned: there is nothing this build may enforce, and
- // saying so is more useful than failing silently every tick.
- Log.i(FocusDeviceAdminReceiver.TAG, "not DO/PO - no enforcement applied")
- recordFailure(context, "not device owner or profile owner")
- return
- }
- // Granted before deciding, not only in apply(). decide() is what
- // reads the location, so granting afterwards left the very first
- // pass after an install with no permission at all -- outcome
- // NO_PERMISSION, reason LOCATION_UNKNOWN, everything hidden for a
- // full cadence. Cheap and idempotent, so it also stays in apply()
- // where it self-heals every pass.
- bridge.grantLocationPermissions()
- bridge.enableLocation()
-
- // One runner for both calls: it carries the acquired fix and the
- // pass timings from decide() through to the record apply() writes.
- // Constructing a second one silently discarded all of that.
- val runner = EnforcementRunner(context)
- val decision = runner.decide()
- if (decision == null) {
- Log.w(FocusDeviceAdminReceiver.TAG, "no decision - policy unreadable")
- recordFailure(context, "policy unreadable - no decision made")
- return
- }
- runner.apply(decision, bridge)
- } finally {
- // Re-armed, not redundant with the pre-arm above: scheduleNext
- // picks the sooner of the fallback and the next curfew boundary,
- // and the pass itself can take 20 s, so re-running it here lands
- // on the correct boundary rather than one computed before the
- // pass. Re-arming an already-armed alarm just replaces it.
- EnforcementScheduler(context).scheduleNext()
- }
- }
-
- /** Leaves a trace for a pass that produced no decision. */
- private fun recordFailure(context: Context, failure: String) {
- EnforcementLog(context).append(
- EnforcementLog.failureRecord(System.currentTimeMillis(), failure),
- )
- }
-
- private fun buildNotification(): Notification {
- val manager = getSystemService(NotificationManager::class.java)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- manager.createNotificationChannel(
- NotificationChannel(
- CHANNEL_ID,
- "Focus enforcement",
- // Low: this notification exists because a foreground
- // service requires one, not because it needs attention.
- NotificationManager.IMPORTANCE_LOW,
- ),
- )
- }
- return Notification.Builder(this, CHANNEL_ID)
- .setContentTitle("Focus Owner")
- .setContentText("Applying focus policy")
- .setSmallIcon(android.R.drawable.ic_lock_idle_lock)
- .setOngoing(true)
- .build()
- }
-
- companion object {
- private const val CHANNEL_ID = "focus_enforcement"
- private const val NOTIFICATION_ID = 1
-
- /** Starts one enforcement run. */
- fun start(context: Context) {
- val intent = Intent(context, EnforcementService::class.java)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- context.startForegroundService(intent)
- } else {
- context.startService(intent)
- }
- }
-
- /**
- * Foreground service type, mirroring the manifest declaration.
- *
- * LOCATION is part of it because the pass requests a fix; without that
- * type, background location delivery is throttled hard enough that the
- * request usually times out and the pass fails closed.
- */
- const val FGS_TYPE: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or
- ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt
deleted file mode 100644
index c9172844..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.kuhy.focus_owner
-
-/**
- * The vocabulary one enforcement pass is expressed in: what goes in, and the
- * two enums that explain what came out.
- *
- * Same package as [EnforcementDecision], so nothing imports these explicitly
- * and no consumer changes when they move here.
- */
-
-/** Why a particular package is hidden. */
-enum class HideReason {
- /** Hidden in every state, exempt from the geofence. */
- ALWAYS_BLOCKED,
-
- /** Not on the day allowlist. */
- NOT_IN_ALLOWLIST,
-
- /** On the day allowlist but not the shorter curfew one. */
- NOT_IN_NIGHT_ALLOWLIST,
-}
-
-/** The fence geometry behind one decision. */
-data class Geofence(
- /** Metres from home, or null when no coordinates are configured. */
- val metres: Double?,
- val threshold: Double,
- val inside: Boolean,
-)
-
-/** Why enforcement is currently on or off. */
-enum class EnforcementReason {
- AWAY,
- AT_HOME,
- CURFEW,
- WORKOUT,
- LOCATION_UNKNOWN,
-}
-
-/** Inputs to one enforcement decision. */
-data class EnforcementInputs(
- val installedPackages: Set,
- val minutesSinceMidnight: Int,
- val latitude: Double? = null,
- val longitude: Double? = null,
- /**
- * Whether the previous pass placed the phone inside the fence.
- *
- * Widens the threshold by the hysteresis margin, so a phone parked on the
- * boundary does not flap between states.
- */
- val currentlyEnforcing: Boolean = false,
- val workoutActive: Boolean = false,
- /**
- * The fix these coordinates came from, carried for the log only.
- *
- * Never consulted by [EnforcementDecision.evaluate]: staleness is applied
- * by the caller, which withholds the coordinates outright. Keeping the
- * decision a function of latitude/longitude alone is what lets it stay
- * testable without a location provider.
- */
- val fix: LocationFix? = null,
-)
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusDeviceAdminReceiver.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusDeviceAdminReceiver.kt
deleted file mode 100644
index 28c5627a..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusDeviceAdminReceiver.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.app.admin.DeviceAdminReceiver
-import android.content.Context
-import android.content.Intent
-import android.util.Log
-
-/**
- * Device admin component for focus mode.
- *
- * Deliberately inert: it declares the component so the app *can* be provisioned
- * as device owner, but it applies no user restrictions and hides no packages.
- * Enforcement is added only after the removal path in [DevicePolicyBridge] has
- * been exercised on a real device.
- *
- * The ordering matters and is not stylistic. `dpm remove-active-admin` does not
- * work on a device owner, so the only exit that avoids a factory reset is
- * `clearDeviceOwnerApp()`, callable solely by this package on itself. An app
- * that locks the device down before proving it can let go leaves a soft-brick
- * recoverable only through fastboot — painful on a locked bootloader.
- */
-class FocusDeviceAdminReceiver : DeviceAdminReceiver() {
-
- override fun onEnabled(context: Context, intent: Intent) {
- super.onEnabled(context, intent)
- Log.i(TAG, "Device admin enabled")
- }
-
- override fun onDisabled(context: Context, intent: Intent) {
- super.onDisabled(context, intent)
- Log.i(TAG, "Device admin disabled")
- }
-
- companion object {
- const val TAG = "FocusOwner"
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt
deleted file mode 100644
index 8ff4dbc7..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt
+++ /dev/null
@@ -1,238 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.content.Context
-import org.json.JSONObject
-import java.io.File
-
-/**
- * The device-independent focus policy, generated from `config.sh`.
- *
- * Deliberately a second implementation of the Dart model in `lib/policy.dart`
- * rather than a call into it. A background [android.app.Service] has no Flutter
- * engine, and starting one per alarm to answer a pure question would be both
- * slow and a new failure mode at exactly the moment enforcement must be
- * reliable.
- *
- * This side is the one that enforces. The Dart copy is used only to display
- * the policy (allowlist sizes, the curfew window), never to decide anything --
- * a `PolicyParityTest` was cited here for months and did not exist, and the
- * two had drifted apart unnoticed. Keeping the Dart side out of the decision
- * path means a drift can no longer produce a wrong answer, only a stale label.
- */
-data class FocusPolicy(
- val home: HomeLocation,
- val allowedPackages: Set,
- val nightAllowedPackages: Set,
- val neverDisablePrefixes: Set,
- /**
- * Prefix-matched day allowlist, for apps shipping as a family of packages.
- *
- * Tachiyomi installs every source as its own apk, so an exact list goes
- * stale the moment a new extension is installed -- which from the phone is
- * indistinguishable from the enforcer being broken. Matched on whole
- * labels like [neverDisablePrefixes], not as a bare string prefix.
- *
- * Weaker than the exact list by construction: it allows packages that do
- * not exist yet, so entries must stay narrow and vendor-specific.
- */
- val allowedPrefixes: Set = emptySet(),
- /** Prefixes that survive the curfew. Subset of [allowedPrefixes]. */
- val nightAllowedPrefixes: Set = emptySet(),
- val workoutUnblockDomains: Set,
- val curfew: CurfewWindow?,
- val launcherPackage: String?,
- /**
- * System apps the sweep may hide, named individually.
- *
- * The sweep is default-deny for `FLAG_SYSTEM` packages, because most of
- * them are platform components: on this device 243 of 320 match no
- * allowlist entry and no prefix, including the emergency-alert receiver.
- * Only packages named here are eligible, and an asset that predates this
- * field parses to the empty set, preserving the old never-touch-system
- * behaviour.
- */
- val blockableSystemPackages: Set = emptySet(),
- /**
- * Packages hidden everywhere, exempt from the geofence.
- *
- * The geofence makes leaving home an off switch, which is what device
- * owner exists to remove. These stay hidden under AWAY, CURFEW, AT_HOME
- * and WORKOUT alike; [EnforcementDecision] never places them in
- * `packagesToShow`.
- */
- val alwaysBlockedPackages: Set = emptySet(),
- /**
- * Package pinned as the always-on VPN, or null when none is configured.
- *
- * The exporter emits this only when the package is also protected from
- * the sweep, so the enforcer can never hide the app it has pinned.
- */
- val alwaysOnVpnPackage: String? = null,
- /**
- * Whether the pinned VPN runs in lockdown mode.
- *
- * Lockdown drops every packet not going through the tunnel, which is what
- * stops "turn the VPN off and browse freely". Defaults false so an asset
- * predating the field cannot silently cut the device off.
- */
- val vpnLockdown: Boolean = false,
- /**
- * Private DNS host to pin, or null to leave the setting alone.
- *
- * The domain rules live on this resolver rather than in the VPN app,
- * because the VPN app's own blocklists can be switched off from inside it
- * and no device owner API can stop that.
- */
- val privateDnsHost: String? = null,
-) {
- /**
- * Whether a package must never be hidden.
- *
- * Prefix-matched on whole labels, so `com.android.providers` covers
- * `com.android.providers.telephony` but not `com.android.providersomething`.
- */
- fun isProtected(packageName: String): Boolean =
- matchesPrefix(packageName, neverDisablePrefixes)
-
- /** Whether a package may run under the given conditions. */
- fun isAllowed(packageName: String, duringCurfew: Boolean): Boolean {
- if (isProtected(packageName)) return true
- if (packageName == launcherPackage) return true
- val allowed = if (duringCurfew) nightAllowedPackages else allowedPackages
- if (packageName in allowed) return true
- val prefixes = if (duringCurfew) nightAllowedPrefixes else allowedPrefixes
- return matchesPrefix(packageName, prefixes)
- }
-
- /** Whether the curfew is in force at [minutesSinceMidnight]. */
- fun isCurfewActive(minutesSinceMidnight: Int): Boolean =
- curfew?.contains(minutesSinceMidnight) ?: false
-
- /**
- * Packages released while a workout is in progress.
- *
- * The rooted system expressed this as *domains*, because it enforced with
- * a hosts file. Hiding is coarser: there is no way to unblock youtube.com
- * without unhiding the YouTube app, so the exception becomes packages.
- */
- val workoutExemptPackages: Set
- get() {
- val mapping = mapOf(
- "youtube.com" to listOf(
- "com.google.android.youtube",
- "com.google.android.apps.youtube.music",
- ),
- )
- val result = mutableSetOf()
- for ((domain, packages) in mapping) {
- val covered = workoutUnblockDomains.any {
- it == domain || it.endsWith(".$domain")
- }
- if (covered) result.addAll(packages)
- }
- return result
- }
-
- companion object {
- /**
- * Whether [packageName] is covered by any entry of [prefixes].
- *
- * Matched on whole labels, so `com.android.providers` covers
- * `com.android.providers.telephony` but not
- * `com.android.providersomething`. Shared by every prefix list so the
- * boundary rule cannot drift between them.
- */
- private fun matchesPrefix(packageName: String, prefixes: Set): Boolean =
- prefixes.any { packageName == it || packageName.startsWith("$it.") }
-
- /** Reads and parses the policy bundled as an asset. */
- fun load(context: Context, assetName: String = "flutter_assets/assets/policy.json"): FocusPolicy =
- context.assets.open(assetName).bufferedReader().use { parse(it.readText()) }
-
- /** Reads a policy from a file, for tests and for overrides. */
- fun loadFile(file: File): FocusPolicy = parse(file.readText())
-
- /** Parses a rendered policy document. */
- fun parse(text: String): FocusPolicy {
- val json = JSONObject(text)
- val version = json.optInt("schema_version", -1)
- if (version != SUPPORTED_SCHEMA_VERSION) {
- // Accepting a newer schema would mean enforcing a policy this
- // code has misread, and a misread allowlist blocks the dialer.
- throw PolicyFormatException(
- "unsupported schema_version $version " +
- "(this build understands $SUPPORTED_SCHEMA_VERSION)",
- )
- }
- val home = json.optJSONObject("home")
- ?: throw PolicyFormatException("missing \"home\" object")
- val curfewJson = json.optJSONObject("curfew")
- return FocusPolicy(
- home = HomeLocation(
- latitude = if (home.isNull("latitude")) null else home.getDouble("latitude"),
- longitude = if (home.isNull("longitude")) null else home.getDouble("longitude"),
- radiusM = home.getDouble("radius_m"),
- hysteresisM = home.getDouble("hysteresis_m"),
- ),
- allowedPackages = json.stringSet("allowed_packages"),
- nightAllowedPackages = json.stringSet("night_allowed_packages"),
- neverDisablePrefixes = json.stringSet("never_disable_prefixes"),
- allowedPrefixes = json.optionalStringSet("allowed_prefixes"),
- nightAllowedPrefixes = json.optionalStringSet("night_allowed_prefixes"),
- workoutUnblockDomains = json.stringSet("workout_unblock_domains"),
- blockableSystemPackages = json.optionalStringSet("blockable_system_packages"),
- alwaysBlockedPackages = json.optionalStringSet("always_blocked_packages"),
- // Empty string means "not configured", which is what the
- // exporter writes when the provider is not sweep-protected.
- alwaysOnVpnPackage = json.optString("always_on_vpn_package")
- .takeIf { it.isNotEmpty() },
- vpnLockdown = json.optBoolean("vpn_lockdown", false),
- privateDnsHost = json.optString("private_dns_host")
- .takeIf { it.isNotEmpty() },
- curfew = curfewJson?.let {
- CurfewWindow(
- startMinutes = parseHhMm(it.getString("start"), "curfew.start"),
- endMinutes = parseHhMm(it.getString("end"), "curfew.end"),
- )
- },
- launcherPackage = if (json.isNull("launcher_package")) {
- null
- } else {
- json.getString("launcher_package")
- },
- )
- }
-
- private fun JSONObject.stringSet(field: String): Set {
- val array = optJSONArray(field)
- ?: throw PolicyFormatException("\"$field\" must be a list")
- return (0 until array.length()).mapTo(mutableSetOf()) { array.getString(it) }
- }
-
- /**
- * A list that may be absent, read as empty.
- *
- * Used for fields added after assets were already shipped. The strict
- * [stringSet] would throw, and an unparsable policy makes the runner
- * skip the pass entirely — safe, but it silently disables enforcement
- * rather than degrading to the previous behaviour. A present-but-wrong
- * type is still an error, since that is a real mistake.
- */
- private fun JSONObject.optionalStringSet(field: String): Set {
- if (!has(field)) return emptySet()
- return stringSet(field)
- }
-
- private fun parseHhMm(value: String, field: String): Int {
- val parts = value.split(":")
- val hour = parts.getOrNull(0)?.toIntOrNull()
- val minute = parts.getOrNull(1)?.toIntOrNull()
- if (parts.size != 2 || hour == null || minute == null ||
- hour > 23 || minute > 59 || hour < 0 || minute < 0
- ) {
- throw PolicyFormatException("\"$field\" is not a valid time: \"$value\"")
- }
- return hour * 60 + minute
- }
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt
deleted file mode 100644
index b74f0176..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.content.Context
-import android.util.Log
-import org.json.JSONObject
-import java.io.File
-
-/**
- * The home-location read/write half of [EnforcementRunner].
- *
- * Split out to keep [EnforcementRunner] under the 250-line cap. Owns writing
- * the current fix as home and reading it back, while [EnforcementRunner]
- * keeps the decide/apply orchestration and delegates to an instance of this
- * class.
- */
-internal class HomeLocationStore(
- private val context: Context,
- private val acquireLocation: (freshWindowMs: Long, maxAccuracyM: Double?) -> LocationFix,
-) {
-
- /**
- * Writes the current location as home.
- *
- * The app has to do this itself. `push_home_location.sh` stages the file
- * through `run-as`, which returns "package not debuggable" on the release
- * build device owner requires -- and making the build debuggable to fix
- * that would let anyone with adb edit this app's state, which is the
- * bypass the whole design exists to close.
- *
- * Deliberately not an exported intent or receiver: anything that can set
- * home from outside the app can set it to somewhere else, and the geofence
- * would then report AWAY forever with enforcement off.
- *
- * @return null on success, or a message describing why it failed.
- */
- fun setHomeToCurrentLocation(): String? {
- // Its own thresholds, so a retry actually requests a new fix rather
- // than being handed the same cached one it just rejected.
- val fix = acquireLocation(SET_HOME_MAX_AGE_MS, SET_HOME_MAX_ACCURACY_M)
- if (!fix.hasCoordinates) {
- return when (fix.outcome) {
- FixOutcome.NO_PERMISSION -> "location permission is not granted"
- FixOutcome.NO_PROVIDER -> "location services are off - turn them on"
- else -> "no location fix available - go outside or open a maps app, then retry"
- }
- }
- // A wrong home is worse than no home: it silently inverts the geofence
- // everywhere, forever, and nothing on screen would say so. So this
- // refuses anything but a fix taken now -- unlike an enforcement pass,
- // there is a user standing here who can simply retry.
- if (!fix.isFresh(SET_HOME_MAX_AGE_MS)) {
- return "only a stale fix (${fix.ageMs / 1000}s old) - wait a moment and retry"
- }
- // Unknown accuracy reads as unacceptable, matching `best` and the
- // cached-fix gate. The opposite reading would let a fix of unknown
- // precision anchor home, which silently inverts the geofence
- // everywhere and forever -- and unlike an enforcement pass, there is a
- // user present who can simply step outside and retry.
- val accuracy = fix.accuracyM ?: Double.MAX_VALUE
- if (accuracy > SET_HOME_MAX_ACCURACY_M) {
- val reported = fix.accuracyM?.toInt()?.let { "$it m" } ?: "unknown"
- return "fix accuracy is $reported (need under " +
- "${SET_HOME_MAX_ACCURACY_M.toInt()} m) - " +
- "go outside for a better signal, then retry"
- }
- return try {
- val json = JSONObject()
- .put("latitude", fix.latitude)
- .put("longitude", fix.longitude)
- File(context.filesDir, "home_location.json").writeText(json.toString())
- // Never log the coordinates: this is the user's home address and
- // logcat is world-readable to anyone with adb.
- Log.i(FocusDeviceAdminReceiver.TAG, "home location written")
- null
- } catch (e: Exception) {
- Log.e(FocusDeviceAdminReceiver.TAG, "could not write home location", e)
- e.message ?: e::class.java.simpleName
- }
- }
-
- /** Whether home coordinates are provisioned, without revealing them. */
- fun hasHomeLocation(): Boolean = readHomeLocation() != null
-
- /** Coordinates provisioned by `push_home_location.sh`, or null. */
- fun readHomeLocation(): Pair? {
- val file = File(context.filesDir, "home_location.json")
- if (!file.exists()) return null
- return try {
- val json = JSONObject(file.readText())
- val lat = json.getDouble("latitude")
- val lon = json.getDouble("longitude")
- if (lat !in -90.0..90.0 || lon !in -180.0..180.0) null else lat to lon
- } catch (e: Exception) {
- // Unreadable coordinates read as "no home", which the decision
- // layer treats as location-unknown and therefore fails closed.
- Log.w(FocusDeviceAdminReceiver.TAG, "home_location.json unreadable", e)
- null
- }
- }
-
- private companion object {
- /**
- * Much tighter than [LocationAcquisition.FRESH_WINDOW_MS], for
- * anchoring home.
- *
- * An enforcement pass tolerates an older fix because it re-runs every
- * 15 minutes and a wrong call self-corrects. Home is written once and
- * silently inverts the geofence everywhere if it is wrong, and there
- * is a user present who can just retry -- so it demands a fresh fix.
- */
- const val SET_HOME_MAX_AGE_MS = 2L * 60L * 1000L
-
- /** Refuse to anchor home to a fix vaguer than the fence is wide. */
- const val SET_HOME_MAX_ACCURACY_M = 100.0
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt
deleted file mode 100644
index 7710cebd..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt
+++ /dev/null
@@ -1,198 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.Manifest
-import android.content.Context
-import android.content.pm.PackageManager
-import android.location.Location
-import android.location.LocationManager
-import android.util.Log
-import androidx.core.content.ContextCompat
-
-/**
- * The location-fix acquisition half of [EnforcementRunner].
- *
- * Split out to keep [EnforcementRunner] under the 250-line cap. Owns getting
- * a [LocationFix] out of [LocationManager] -- preferring a fresh cache,
- * falling back to an active request, then to a stale cache -- while
- * [EnforcementRunner] keeps the decide/apply orchestration and delegates
- * acquisition to an instance of this class.
- */
-internal class LocationAcquisition(private val context: Context) {
-
- /**
- * Acquires a fix, preferring a fresh one over whatever happens to be cached.
- *
- * The old implementation only read [LocationManager.getLastKnownLocation]
- * and took the newest across providers, with no age check and no request
- * of its own. That produced both reported failures: at the office with no
- * navigation app running the cache was empty, so the pass fell through to
- * LOCATION_UNKNOWN and blocked everything; and a cached fix from hours ago
- * would have classified the office as home just as readily.
- *
- * Bounded by [ACQUIRE_TIMEOUT_MS] so a pass can never hang waiting for a
- * fix that is not coming. On expiry it falls back to the cache, tagged so
- * the decision layer and the log can both say the acquisition timed out
- * rather than silently presenting a stale fix as current.
- */
- fun acquireLocation(
- timeoutMs: Long = ACQUIRE_TIMEOUT_MS,
- freshWindowMs: Long = FRESH_WINDOW_MS,
- maxAccuracyM: Double? = null,
- ): LocationFix {
- val fine = ContextCompat.checkSelfPermission(
- context,
- Manifest.permission.ACCESS_FINE_LOCATION,
- ) == PackageManager.PERMISSION_GRANTED
- val coarse = ContextCompat.checkSelfPermission(
- context,
- Manifest.permission.ACCESS_COARSE_LOCATION,
- ) == PackageManager.PERMISSION_GRANTED
- if (!fine && !coarse) return LocationFix.unavailable(FixOutcome.NO_PERMISSION)
- val manager = context.getSystemService(LocationManager::class.java)
- ?: return LocationFix.unavailable(FixOutcome.NO_PROVIDER)
-
- // A recent cached fix is both cheaper and no less accurate than one we
- // would request right now, so it short-circuits acquisition entirely.
- // This is the common case whenever any app has used location lately.
- //
- // The caller's own thresholds are applied HERE, not only after the
- // fact: "Set home" demands a fix under 2 minutes old and accurate to
- // 100 m, and if this returned a 5-minute-old cache regardless, every
- // retry would hand back the identical rejected fix and the button
- // could never succeed until the cache aged out on its own.
- cachedFix(manager, freshWindowMs)
- ?.takeIf { it.ageMs <= freshWindowMs }
- // Unknown accuracy is treated as unacceptable, matching `best`.
- // The alternative reading (0.0, i.e. perfect) would let a fix of
- // unknown precision anchor home, which silently inverts the
- // geofence everywhere and forever.
- ?.takeIf {
- maxAccuracyM == null ||
- (it.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM
- }
- ?.let { return it.copy(outcome = FixOutcome.CACHED_FRESH) }
-
- val active = runCatching { requestCurrentFix(manager, timeoutMs) }
- .getOrElse { error ->
- Log.w(FocusDeviceAdminReceiver.TAG, "location request failed", error)
- null
- }
- // The caller's accuracy bound applies to a freshly requested fix too,
- // not just the cache: a brand-new fix that cannot say where it is to
- // within maxAccuracyM answers the question no better than an old one.
- if (active != null &&
- (maxAccuracyM == null || (active.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM)
- ) {
- return active.copy(outcome = FixOutcome.ACTIVE_OK)
- }
-
- // Nothing fresh. Return the stale cache anyway, labelled: the decision
- // layer decides what to do with it, and the log needs to say what was
- // actually available rather than reporting a bare "no fix".
- // Window passed through so `best` takes its no-fresh-candidates branch
- // and reports the NEWEST stale fix. Without it every candidate counts
- // as fresh and the most accurate one wins, which does not change the
- // decision (the caller rejects anything stale either way) but makes
- // the log and the set-home error quote a six-hour-old fix when a
- // forty-minute-old one was available.
- val stale = cachedFix(manager, freshWindowMs)
- return stale?.copy(outcome = FixOutcome.TIMEOUT)
- ?: LocationFix.unavailable(FixOutcome.TIMEOUT)
- }
-
- /** The newest cached fix across enabled providers, or null. */
- private fun cachedFix(
- manager: LocationManager,
- freshWindowMs: Long = Long.MAX_VALUE,
- ): LocationFix? = try {
- manager.getProviders(true)
- .asSequence()
- .mapNotNull { provider ->
- manager.getLastKnownLocation(provider)?.let { provider to it }
- }
- .map { (provider, location) -> location.toFix(provider) }
- .toList()
- .let { EnforcementRunner.best(it, freshWindowMs) }
- } catch (e: SecurityException) {
- Log.w(FocusDeviceAdminReceiver.TAG, "location denied", e)
- null
- }
-
- /**
- * Asks the platform for a fix now, blocking up to [timeoutMs].
- *
- * Uses [LocationManager] rather than the fused Play Services client on
- * purpose: a device owner should not depend on Play Services, which is
- * itself inside this app's blast radius.
- *
- * The caller must not be on the main thread -- [EnforcementService] runs
- * the pass on a background executor for exactly this reason.
- */
- private fun requestCurrentFix(manager: LocationManager, timeoutMs: Long): LocationFix? {
- val provider = PROVIDER_PREFERENCE.firstOrNull { manager.isProviderEnabled(it) }
- ?: return null
- val latch = java.util.concurrent.CountDownLatch(1)
- val holder = java.util.concurrent.atomic.AtomicReference()
- val signal = android.os.CancellationSignal()
- // The main executor rather than a fresh one per call: the consumer
- // only stores a reference and counts down, so it does no work worth a
- // thread, and creating one per pass leaked ~96 threads a day.
- manager.getCurrentLocation(
- provider,
- signal,
- androidx.core.content.ContextCompat.getMainExecutor(context),
- ) { location ->
- holder.set(location)
- latch.countDown()
- }
- val answered = latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)
- if (!answered) {
- // Stop the provider working on a request nobody is waiting for.
- runCatching { signal.cancel() }
- return null
- }
- return holder.get()?.toFix(provider)
- }
-
- /** Converts a platform [Location] into the fix the decision layer sees. */
- private fun Location.toFix(provider: String): LocationFix = LocationFix(
- latitude = latitude,
- longitude = longitude,
- // elapsedRealtimeNanos is monotonic, so a clock change cannot make a
- // fix look fresher than it is.
- ageMs = (android.os.SystemClock.elapsedRealtimeNanos() - elapsedRealtimeNanos) /
- 1_000_000L,
- accuracyM = if (hasAccuracy()) accuracy.toDouble() else null,
- provider = provider,
- outcome = FixOutcome.CACHED_FRESH,
- )
-
- companion object {
- /** Longest a pass will wait for a fresh fix before falling back. */
- const val ACQUIRE_TIMEOUT_MS = 20_000L
-
- /**
- * A fix at most this old is trusted as describing "now".
- *
- * Deliberately longer than the 15-minute enforcement cadence. A window
- * below the cadence would mark the newest available cache stale on
- * every pass of a phone sitting still, sending every pass to
- * LOCATION_UNKNOWN and blocking everything -- turning the fail-closed
- * default from a safety net into the normal case.
- */
- const val FRESH_WINDOW_MS = 30L * 60L * 1000L
-
- /**
- * Providers tried in order when requesting a fix.
- *
- * GPS first because it is the only one accurate enough for a 150 m
- * fence; fused and network are fallbacks that at least distinguish
- * "same city" from "10 km away".
- */
- private val PROVIDER_PREFERENCE = listOf(
- LocationManager.GPS_PROVIDER,
- LocationManager.FUSED_PROVIDER,
- LocationManager.NETWORK_PROVIDER,
- )
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationFix.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationFix.kt
deleted file mode 100644
index 0ed5c4f5..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationFix.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.kuhy.focus_owner
-
-/** How the current fix was obtained, or why there is none. */
-enum class FixOutcome {
- /** A cached fix younger than the freshness window; no request was made. */
- CACHED_FRESH,
-
- /** The platform answered a request made during this pass. */
- ACTIVE_OK,
-
- /** The request did not answer in time; any fix here is the stale cache. */
- TIMEOUT,
-
- /** Neither fine nor coarse location is granted. */
- NO_PERMISSION,
-
- /** No location provider is enabled, or the service is missing. */
- NO_PROVIDER,
-}
-
-/**
- * A location fix plus everything needed to judge and explain it.
- *
- * Age and accuracy are carried rather than discarded because they are what
- * separates a trustworthy classification from a guess: the geofence is 150 m
- * wide, so a fix that is an hour old or accurate to 2 km cannot answer the
- * question being asked of it.
- *
- * Coordinates never leave this object -- they are used to compute a distance
- * and are deliberately absent from the log record and the UI, since logcat is
- * readable by anyone with adb and this is the user's home address.
- */
-data class LocationFix(
- val latitude: Double?,
- val longitude: Double?,
- /** Milliseconds since the fix was taken, from the monotonic clock. */
- val ageMs: Long,
- /** Reported accuracy radius in metres, or null when unknown. */
- val accuracyM: Double?,
- /** Provider name (`gps`, `fused`, `network`), or null when there is no fix. */
- val provider: String?,
- val outcome: FixOutcome,
-) {
- /** Whether there are usable coordinates at all. */
- val hasCoordinates: Boolean get() = latitude != null && longitude != null
-
- /**
- * Whether this fix is recent enough to describe where the phone is now.
- *
- * The window must exceed the enforcement cadence. A shorter one would mark
- * the newest available cache stale on every pass of an idle phone, sending
- * every pass to LOCATION_UNKNOWN -- which blocks everything, the exact
- * failure this rewrite exists to remove.
- */
- fun isFresh(windowMs: Long): Boolean = hasCoordinates && ageMs <= windowMs
-
- companion object {
- /** A fix that could not be obtained. */
- fun unavailable(outcome: FixOutcome): LocationFix = LocationFix(
- latitude = null,
- longitude = null,
- ageMs = Long.MAX_VALUE,
- accuracyM = null,
- provider = null,
- outcome = outcome,
- )
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt
deleted file mode 100644
index bf167b97..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package com.kuhy.focus_owner
-
-import io.flutter.embedding.android.FlutterActivity
-import io.flutter.embedding.engine.FlutterEngine
-import io.flutter.plugin.common.MethodChannel
-
-class MainActivity : FlutterActivity() {
-
- /** For channel calls that block; the platform thread must stay free. */
- private val worker = java.util.concurrent.Executors.newSingleThreadExecutor()
-
- override fun onDestroy() {
- worker.shutdown()
- super.onDestroy()
- }
-
- override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
- super.configureFlutterEngine(flutterEngine)
- val bridge = DevicePolicyBridge(applicationContext)
- MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
- .setMethodCallHandler { call, result ->
- when (call.method) {
- "status" -> result.success(bridge.status())
- "releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner())
- // Off the platform thread: setting home now waits for a
- // genuinely fresh fix (up to ACQUIRE_TIMEOUT_MS), and
- // blocking the main thread for that long is an ANR on the
- // one button that provisions the geofence.
- "setHomeToCurrentLocation" -> worker.execute {
- val outcome = runCatching {
- EnforcementRunner(applicationContext).setHomeToCurrentLocation()
- }.getOrElse { it.message ?: it::class.java.simpleName }
- // The work outlives a shutdown() (it only stops new
- // submissions), so the activity can be gone by now.
- // Replying to a dead activity's channel throws, and
- // the home write has already happened either way.
- if (!isDestroyed && !isFinishing) {
- runOnUiThread { result.success(outcome) }
- }
- }
- "hasHomeLocation" ->
- result.success(
- EnforcementRunner(applicationContext).hasHomeLocation(),
- )
- "setAlwaysOnVpn" -> {
- val pkg = call.argument("package")
- if (pkg == null) {
- result.error("bad_args", "package required", null)
- } else {
- // Returns the failure message, or null on success,
- // so the UI can say why rather than just "failed".
- result.success(bridge.setAlwaysOnVpn(pkg))
- }
- }
- "setVpnConfigBlocked" -> {
- val blocked = call.argument("blocked")
- if (blocked == null) {
- result.error("bad_args", "blocked required", null)
- } else {
- result.success(bridge.setVpnConfigBlocked(blocked))
- }
- }
- "setSelfUninstallBlocked" -> {
- val blocked = call.argument("blocked")
- if (blocked == null) {
- result.error("bad_args", "blocked required", null)
- } else {
- result.success(bridge.setSelfUninstallBlocked(blocked))
- }
- }
- "setApplicationHidden" -> {
- val pkg = call.argument("package")
- val hidden = call.argument("hidden")
- if (pkg == null || hidden == null) {
- result.error("bad_args", "package and hidden required", null)
- } else {
- result.success(bridge.setApplicationHidden(pkg, hidden))
- }
- }
- "isApplicationHidden" -> {
- val pkg = call.argument("package")
- if (pkg == null) {
- result.error("bad_args", "package required", null)
- } else {
- result.success(bridge.isApplicationHidden(pkg))
- }
- }
- "runEnforcementNow" -> {
- // Also the only way to arm the schedule on a device
- // that has not rebooted since install: the service
- // schedules the next run at the end of each pass.
- EnforcementService.start(applicationContext)
- result.success(true)
- }
- "cancelEnforcement" -> {
- EnforcementScheduler(applicationContext).cancel()
- result.success(true)
- }
- // Returned as raw JSON strings rather than platform maps:
- // the channel codec stays trivial and the Dart side owns
- // one well-defined parse, so the record format and the UI
- // can be versioned together.
- "readEnforcementLog" -> {
- val limit = call.argument("limit") ?: 200
- result.success(
- EnforcementLog(applicationContext).readRecent(limit),
- )
- }
- else -> result.notImplemented()
- }
- }
- }
-
- companion object {
- private const val CHANNEL = "com.kuhy.focus_owner/device_policy"
- }
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt
deleted file mode 100644
index 3186f47c..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.util.Log
-
-/**
- * Re-pins the network-lockdown and self-uninstall-block policy onto the
- * device every pass.
- *
- * Split out of [EnforcementRunner.apply] to keep that file under the 250-line
- * cap. Called with the freshly-loaded [policy] (which may be null if it
- * failed to load -- the VPN/DNS pins are skipped in that case, but the
- * self-uninstall-block re-assertion still runs since it depends only on
- * [decision], not on the policy).
- */
-internal fun pinPolicyToDevice(
- policy: FocusPolicy?,
- decision: EnforcementDecision,
- bridge: DevicePolicyBridge,
-) {
- // Re-pinned every pass, unconditionally rather than tracking the
- // decision: the network filter is the layer that reaches youtube.com
- // in Firefox, in a webview, and in clients nobody has enumerated, so
- // it must not lift when the geofence says AWAY. DISALLOW_CONFIG_VPN is
- // deliberately NOT applied here -- it is a separate, later step, so a
- // misconfigured VPN stays repairable from the device.
- policy?.alwaysOnVpnPackage?.let { vpn ->
- bridge.setAlwaysOnVpn(vpn, lockdown = policy.vpnLockdown)?.let { failure ->
- Log.w(FocusDeviceAdminReceiver.TAG, "always-on VPN not pinned: $failure")
- }
- // Measured hole: `pm uninstall --user 0` removed the provider
- // while it was pinned, taking the network filter with it and
- // leaving the pin aimed at a package that no longer existed.
- bridge.setPackageUninstallBlocked(vpn, true)
- }
-
- // Pinned every pass for the same reason as the VPN: this is where the
- // domain rules actually live now, so it must not depend on anyone
- // remembering to set it. DISALLOW_CONFIG_PRIVATE_DNS is applied
- // separately, only once the host is confirmed answering.
- policy?.privateDnsHost?.let { host ->
- bridge.setPrivateDns(host)?.let { failure ->
- Log.w(FocusDeviceAdminReceiver.TAG, "private DNS not pinned: $failure")
- }
- }
-
- // Re-asserted every pass rather than set once at provisioning time, so
- // the protection self-heals: `adb uninstall` while enforcing is the one
- // route that strands device ownership with no holder, and a factory
- // reset is then the only exit. Tracking the decision means going away
- // from home lifts it, which keeps the app removable in exactly the
- // state where enforcement is already off.
- bridge.setSelfUninstallBlocked(decision.isEnforcing)
-}
-
-/** The packages actually changed by [applyVisibilitySweep], named not counted. */
-internal data class VisibilitySweepResult(
- val hiddenNow: List,
- val restoredNow: List,
-)
-
-/**
- * Applies the decision's show/hide deltas to the device.
- *
- * Split out of [EnforcementRunner.apply] to keep that file under the 250-line
- * cap. Show is applied before hide by the caller's ordering of these two
- * loops -- if a pass is interrupted part-way, the half that has run is the
- * half that restores access rather than the half that removes it.
- */
-internal fun applyVisibilitySweep(
- decision: EnforcementDecision,
- bridge: DevicePolicyBridge,
- selfPackage: String,
-): VisibilitySweepResult {
- val restoredNow = mutableListOf()
- val hiddenNow = mutableListOf()
- for (pkg in decision.packagesToShow) {
- if (bridge.isApplicationHidden(pkg)) {
- if (bridge.setApplicationHidden(pkg, false)) restoredNow.add(pkg)
- }
- }
- for (pkg in decision.packagesToHide) {
- // The exporter injects this package into both allowlists, so the
- // decision layer should never propose it. Kept as a belt-and-braces
- // check because it guards the unrecoverable case: hiding the
- // enforcer takes the escape hatch and the trigger with it, and a
- // policy asset rendered before that fix -- or hand-edited -- would
- // otherwise hide the app on the first enforcing pass.
- if (pkg == selfPackage) continue
- if (!bridge.isApplicationHidden(pkg)) {
- if (bridge.setApplicationHidden(pkg, true)) hiddenNow.add(pkg)
- }
- }
- return VisibilitySweepResult(hiddenNow = hiddenNow, restoredNow = restoredNow)
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt
deleted file mode 100644
index dd15577b..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.kuhy.focus_owner
-
-/**
- * The policy's value types: the schema version this build understands, the
- * failure it raises, and the two records [FocusPolicy] is built from.
- *
- * Same package as FocusPolicy, so nothing imports these explicitly and no call
- * site changes by their moving here.
- */
-
-/** Schema version this build understands. */
-const val SUPPORTED_SCHEMA_VERSION = 1
-
-/** Thrown when the bundled policy cannot be trusted. */
-class PolicyFormatException(message: String) : Exception(message)
-
-/** A nightly window that may wrap midnight (23:00 -> 05:00). */
-data class CurfewWindow(val startMinutes: Int, val endMinutes: Int) {
- /**
- * Whether [minutesSinceMidnight] falls inside the window.
- *
- * Wrapping windows are the normal case, so "inside" means at or after the
- * start *or* before the end, not both.
- */
- fun contains(minutesSinceMidnight: Int): Boolean =
- if (startMinutes <= endMinutes) {
- minutesSinceMidnight in startMinutes until endMinutes
- } else {
- minutesSinceMidnight >= startMinutes || minutesSinceMidnight < endMinutes
- }
-}
-
-/** The home anchor. Coordinates are null in a redacted policy. */
-data class HomeLocation(
- val latitude: Double?,
- val longitude: Double?,
- val radiusM: Double,
- val hysteresisM: Double,
-) {
- /**
- * Whether this policy carries real coordinates.
- *
- * The committed asset is redacted, so enforcement must check this rather
- * than treating a missing coordinate as (0, 0) — that would put "home" in
- * the Atlantic and silently disable the gate.
- */
- val hasCoordinates: Boolean get() = latitude != null && longitude != null
-}
diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt
deleted file mode 100644
index 7ccf2966..00000000
--- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.kuhy.focus_owner
-
-import android.content.Context
-import android.content.pm.PackageManager
-
-/**
- * Packages the allowlist sweep considers: every third-party app, plus the
- * system apps the policy names in `blockable_system_packages`.
- *
- * Split out of [EnforcementRunner.decide] to keep that file under the
- * 250-line cap.
- *
- * System apps are default-deny rather than filtered out entirely, because
- * the apps most worth blocking are preinstalled — YouTube ships at
- * `/product/app/YouTube` with `FLAG_SYSTEM`, and so does Chrome — while
- * most of the rest are platform components. Measured on this device: 320
- * system packages, of which 243 match no allowlist entry and no
- * `never_disable_prefix`, including `com.android.cellbroadcastreceiver`
- * (emergency alerts) and `com.android.devicelockcontroller`. Sweeping
- * those under Device Owner risks an unrecoverable phone, so eligibility is
- * opt-in per package.
- *
- * [PackageManager.MATCH_UNINSTALLED_PACKAGES] is required, not optional: a
- * package hidden by `setApplicationHidden` is dropped from the default
- * enumeration entirely, even though it remains `installed=true`. Querying
- * with flags `0` therefore returns a set that can never contain anything
- * this app has already hidden — so [EnforcementDecision.packagesToShow]
- * would come back empty and nothing could ever be unhidden again. Measured
- * on device: after a pass hid three apps, the following pass reported
- * `hid 0, restored 0` and left them hidden permanently.
- */
-internal fun sweepablePackages(context: Context, policy: FocusPolicy): Set {
- val pm = context.packageManager
- return pm.getInstalledApplications(PackageManager.MATCH_UNINSTALLED_PACKAGES)
- .filter { info ->
- val isSystem =
- (info.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
- !isSystem || info.packageName in policy.blockableSystemPackages
- }
- .map { it.packageName }
- .toSet()
-}
diff --git a/focus_owner/android/app/src/main/res/drawable-v21/launch_background.xml b/focus_owner/android/app/src/main/res/drawable-v21/launch_background.xml
deleted file mode 100644
index f74085f3..00000000
--- a/focus_owner/android/app/src/main/res/drawable-v21/launch_background.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/focus_owner/android/app/src/main/res/drawable/launch_background.xml b/focus_owner/android/app/src/main/res/drawable/launch_background.xml
deleted file mode 100644
index 304732f8..00000000
--- a/focus_owner/android/app/src/main/res/drawable/launch_background.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/focus_owner/android/app/src/main/res/values-night/styles.xml b/focus_owner/android/app/src/main/res/values-night/styles.xml
deleted file mode 100644
index 06952be7..00000000
--- a/focus_owner/android/app/src/main/res/values-night/styles.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
diff --git a/focus_owner/android/app/src/main/res/values/styles.xml b/focus_owner/android/app/src/main/res/values/styles.xml
deleted file mode 100644
index cb1ef880..00000000
--- a/focus_owner/android/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
diff --git a/focus_owner/android/app/src/main/res/xml/device_admin.xml b/focus_owner/android/app/src/main/res/xml/device_admin.xml
deleted file mode 100644
index d28751e8..00000000
--- a/focus_owner/android/app/src/main/res/xml/device_admin.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
diff --git a/focus_owner/android/app/src/profile/AndroidManifest.xml b/focus_owner/android/app/src/profile/AndroidManifest.xml
deleted file mode 100644
index 399f6981..00000000
--- a/focus_owner/android/app/src/profile/AndroidManifest.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionCurfewTest.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionCurfewTest.kt
deleted file mode 100644
index 4b3069bc..00000000
--- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionCurfewTest.kt
+++ /dev/null
@@ -1,223 +0,0 @@
-// Curfew and always-blocked decision cases.
-//
-// Split out of EnforcementDecisionTest.kt to keep each file under the cap.
-
-package com.kuhy.focus_owner
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-class EnforcementDecisionCurfewTest {
-
- private val homeLat = EnforcementFixtures.homeLat
- private val homeLon = EnforcementFixtures.homeLon
- private val metresPerDegreeLat = EnforcementFixtures.metresPerDegreeLat
-
- private fun policy(withCoordinates: Boolean = true, curfew: String? = """{"start":"23:00","end":"05:00"}""") =
- EnforcementFixtures.policy(withCoordinates, curfew)
-
- private fun policyWithAlwaysBlocked() = EnforcementFixtures.policyWithAlwaysBlocked()
-
- private val installed = EnforcementFixtures.installed
-
- private fun latOffset(metres: Double) = EnforcementFixtures.latOffset(metres)
-
-
- @Test
- fun `workout releases youtube and nothing else`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(
- installed,
- 23 * 60 + 30,
- homeLat,
- homeLon,
- workoutActive = true,
- ),
- )
- assertEquals(EnforcementReason.WORKOUT, decision.reason)
- assertTrue("com.google.android.youtube" in decision.packagesToShow)
- assertTrue("com.discord" in decision.packagesToHide)
- assertTrue("com.evil" in decision.packagesToHide)
- }
-
- @Test
- fun `hide and show partition the installed set`() {
- // packagesToShow is the complete allowed set, never a delta, so a
- // missed run self-repairs on the next one.
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 12 * 60, homeLat, homeLon),
- )
- assertEquals(installed, decision.packagesToHide + decision.packagesToShow)
- assertTrue(decision.packagesToHide.intersect(decision.packagesToShow).isEmpty())
- }
-
- @Test
- fun `an unknown schema version is rejected`() {
- val thrown = try {
- FocusPolicy.parse("""{"schema_version": 999, "home": {}}""")
- false
- } catch (_: PolicyFormatException) {
- true
- }
- assertTrue("expected PolicyFormatException", thrown)
- }
-
- @Test
- fun `prefix protection matches whole labels only`() {
- val p = policy()
- assertTrue(p.isProtected("com.android.settings"))
- assertTrue(p.isProtected("com.android.settings.sub"))
- assertFalse(p.isProtected("com.android.settingsomething"))
- }
-
- @Test
- fun `distance and threshold are reported so the phone can explain itself`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
- )
- // ~5 km away, and the value it was actually compared against.
- assertEquals(5000.0, decision.distanceM!!, 50.0)
- assertEquals(150.0, decision.thresholdM!!, 0.001)
- assertEquals(false, decision.insideFence)
- }
-
- @Test
- fun `hysteresis widens the threshold only for a phone already inside`() {
- // 160 m out: outside the bare 150 m radius, inside 150 + 30. This is
- // what stops a phone parked on the boundary from flapping.
- val outside = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(
- installed,
- 12 * 60,
- latOffset(160.0),
- homeLon,
- currentlyEnforcing = false,
- ),
- )
- assertEquals(150.0, outside.thresholdM!!, 0.001)
- assertEquals(EnforcementReason.AWAY, outside.reason)
-
- val inside = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(
- installed,
- 12 * 60,
- latOffset(160.0),
- homeLon,
- currentlyEnforcing = true,
- ),
- )
- assertEquals(180.0, inside.thresholdM!!, 0.001)
- assertEquals(EnforcementReason.AT_HOME, inside.reason)
- }
-
- @Test
- fun `an unanswerable fence reports null rather than outside`() {
- // No fix at all. Recording "outside" here would drop hysteresis on the
- // next pass at exactly the moment the input is least reliable.
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 12 * 60, latitude = null, longitude = null),
- )
- assertEquals(EnforcementReason.LOCATION_UNKNOWN, decision.reason)
- assertEquals(null, decision.insideFence)
- assertEquals(null, decision.distanceM)
- }
-
- @Test
- fun `no home coordinates reports an unknown distance, not zero`() {
- // Fabricating 0 m would read on screen as "you are exactly at home".
- val decision = EnforcementDecision.evaluate(
- policy(withCoordinates = false),
- EnforcementInputs(installed, 12 * 60, homeLat, homeLon),
- )
- assertEquals(null, decision.distanceM)
- assertEquals(true, decision.insideFence)
- }
-
- @Test
- fun `every hidden package carries a reason`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 12 * 60, homeLat, homeLon),
- )
- // The UI lists these per package, so a gap would render as a blank
- // explanation next to a hidden app.
- assertEquals(decision.packagesToHide, decision.hideReasons.keys)
- assertTrue(
- decision.hideReasons.values.all { it == HideReason.NOT_IN_ALLOWLIST },
- )
- }
-
- @Test
- fun `curfew hiding is attributed to the night list`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 23 * 60 + 30, homeLat, homeLon),
- )
- assertEquals(EnforcementReason.CURFEW, decision.reason)
- // com.discord is day-allowed but not night-allowed, so the distinction
- // matters: "not in allowlist" would be misleading.
- assertEquals(
- HideReason.NOT_IN_NIGHT_ALLOWLIST,
- decision.hideReasons["com.discord"],
- )
- }
-
- @Test
- fun `always-blocked is attributed as such even when away`() {
- val decision = EnforcementDecision.evaluate(
- policyWithAlwaysBlocked(),
- EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
- )
- assertEquals(EnforcementReason.AWAY, decision.reason)
- assertEquals(
- HideReason.ALWAYS_BLOCKED,
- decision.hideReasons["com.google.android.youtube"],
- )
- }
-
- @Test
- fun `allowed prefixes cover a family of packages`() {
- val p = FocusPolicy.parse(
- """
- {
- "schema_version": 1,
- "home": {
- "latitude": $homeLat, "longitude": $homeLon,
- "radius_m": 150.0, "hysteresis_m": 30.0
- },
- "curfew": {"start":"23:00","end":"05:00"},
- "launcher_package": "com.launcher",
- "allowed_packages": ["com.launcher"],
- "night_allowed_packages": ["com.launcher"],
- "never_disable_prefixes": [],
- "workout_unblock_domains": [],
- "allowed_prefixes": ["eu.kanade.tachiyomi"],
- "night_allowed_prefixes": ["eu.kanade.tachiyomi"],
- "browser_packages": []
- }
- """.trimIndent(),
- )
- assertTrue(p.isAllowed("eu.kanade.tachiyomi.sy", false))
- assertTrue(p.isAllowed("eu.kanade.tachiyomi.extension.all.mangadex", false))
- // Chosen 2026-08-14: manga stays available during the curfew.
- assertTrue(p.isAllowed("eu.kanade.tachiyomi.sy", true))
- // Whole labels only.
- assertFalse(p.isAllowed("eu.kanade.tachiyomisomething", false))
- }
-
- @Test
- fun `an asset without prefix keys still parses`() {
- // Back-compat: a policy rendered before prefixes existed must degrade
- // to exact matching rather than failing to parse at all, since an
- // unparsable policy skips the pass entirely.
- assertTrue(policy().allowedPrefixes.isEmpty())
- assertTrue(policy().nightAllowedPrefixes.isEmpty())
- }
-}
diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt
deleted file mode 100644
index 06b972bb..00000000
--- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt
+++ /dev/null
@@ -1,169 +0,0 @@
-package com.kuhy.focus_owner
-
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-/**
- * Mirrors `test/enforcement_test.dart` case for case.
- *
- * The Kotlin decision layer exists because a background Service has no Flutter
- * engine. That duplication is only safe if the two implementations are pinned
- * to the same expectations, so these tests deliberately restate the Dart suite
- * rather than testing something adjacent. A behaviour that changes on one side
- * and not the other fails here.
- */
-class EnforcementDecisionTest {
-
- private val homeLat = EnforcementFixtures.homeLat
- private val homeLon = EnforcementFixtures.homeLon
- private val metresPerDegreeLat = EnforcementFixtures.metresPerDegreeLat
-
- private fun policy(withCoordinates: Boolean = true, curfew: String? = """{"start":"23:00","end":"05:00"}""") =
- EnforcementFixtures.policy(withCoordinates, curfew)
-
- private fun policyWithAlwaysBlocked() = EnforcementFixtures.policyWithAlwaysBlocked()
-
- private val installed = EnforcementFixtures.installed
-
- private fun latOffset(metres: Double) = EnforcementFixtures.latOffset(metres)
-
- @Test
- fun `away from home restores everything except the always-blocked set`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
- )
- assertEquals(EnforcementReason.AWAY, decision.reason)
- // The base policy names nothing as always-blocked, so this is the
- // original behaviour: leaving home restores the whole device.
- assertTrue(decision.packagesToHide.isEmpty())
- assertEquals(installed, decision.packagesToShow)
- }
-
- @Test
- fun `always-blocked packages stay hidden away from home`() {
- val decision = EnforcementDecision.evaluate(
- policyWithAlwaysBlocked(),
- EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
- )
- assertEquals(EnforcementReason.AWAY, decision.reason)
- assertEquals(setOf("com.google.android.youtube"), decision.packagesToHide)
- assertFalse(decision.packagesToShow.contains("com.google.android.youtube"))
- // Everything else still comes back, which is the point of the geofence.
- assertTrue(decision.packagesToShow.contains("com.discord"))
- }
-
- @Test
- fun `always-blocked still counts as enforcing away from home`() {
- val decision = EnforcementDecision.evaluate(
- policyWithAlwaysBlocked(),
- EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
- )
- // The uninstall block is keyed off isEnforcing. If AWAY reported false
- // here, leaving the house would make `adb uninstall` an off switch for
- // the one set that is meant not to have one.
- assertTrue(decision.isEnforcing)
- }
-
- @Test
- fun `a workout does not release an always-blocked package`() {
- val decision = EnforcementDecision.evaluate(
- policyWithAlwaysBlocked(),
- EnforcementInputs(
- installed,
- 12 * 60,
- homeLat,
- homeLon,
- workoutActive = true,
- ),
- )
- assertEquals(EnforcementReason.WORKOUT, decision.reason)
- assertTrue(decision.packagesToHide.contains("com.google.android.youtube"))
- }
-
- @Test
- fun `at home only non-allowlisted apps are hidden`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 12 * 60, homeLat, homeLon),
- )
- assertEquals(EnforcementReason.AT_HOME, decision.reason)
- assertEquals(setOf("com.evil"), decision.packagesToHide)
- }
-
- @Test
- fun `protected packages and the launcher are never hidden`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 23 * 60 + 30, homeLat, homeLon),
- )
- assertTrue("com.android.settings" in decision.packagesToShow)
- assertTrue("com.launcher" in decision.packagesToShow)
- }
-
- @Test
- fun `curfew narrows the allowlist`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 23 * 60 + 30, homeLat, homeLon),
- )
- assertEquals(EnforcementReason.CURFEW, decision.reason)
- assertTrue("com.discord" in decision.packagesToHide)
- assertTrue("pl.mbank" in decision.packagesToShow)
- }
-
- @Test
- fun `curfew wraps past midnight`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 3 * 60, homeLat, homeLon),
- )
- assertEquals(EnforcementReason.CURFEW, decision.reason)
- }
-
- @Test
- fun `after curfew the day list applies again`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 6 * 60, homeLat, homeLon),
- )
- assertEquals(EnforcementReason.AT_HOME, decision.reason)
- assertTrue("com.discord" in decision.packagesToShow)
- }
-
- @Test
- fun `hysteresis keeps the boundary from flapping`() {
- // 160m: outside the 150m radius, inside radius+hysteresis (180m).
- val arriving = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 12 * 60, latOffset(160.0), homeLon, currentlyEnforcing = false),
- )
- val leaving = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 12 * 60, latOffset(160.0), homeLon, currentlyEnforcing = true),
- )
- assertEquals(EnforcementReason.AWAY, arriving.reason)
- assertEquals(EnforcementReason.AT_HOME, leaving.reason)
- }
-
- @Test
- fun `unknown location enforces rather than releasing`() {
- val decision = EnforcementDecision.evaluate(
- policy(),
- EnforcementInputs(installed, 12 * 60),
- )
- assertEquals(EnforcementReason.LOCATION_UNKNOWN, decision.reason)
- assertTrue("com.evil" in decision.packagesToHide)
- }
-
- @Test
- fun `redacted coordinates still enforce`() {
- val decision = EnforcementDecision.evaluate(
- policy(withCoordinates = false),
- EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon),
- )
- assertTrue("com.evil" in decision.packagesToHide)
- }
-}
diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementFixtures.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementFixtures.kt
deleted file mode 100644
index 4bba3ee5..00000000
--- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementFixtures.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-// Shared fixtures for the enforcement decision tests.
-//
-// Split out of EnforcementDecisionTest.kt to keep each file under the
-// 250-line cap; both test classes build policies from these.
-
-package com.kuhy.focus_owner
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-internal object EnforcementFixtures {
- val homeLat = 52.2297
- val homeLon = 21.0122
- val metresPerDegreeLat = 111_320.0
-
- fun policy(withCoordinates: Boolean = true, curfew: String? = """{"start":"23:00","end":"05:00"}""") =
- FocusPolicy.parse(
- """
- {
- "schema_version": 1,
- "home": {
- "latitude": ${if (withCoordinates) homeLat else "null"},
- "longitude": ${if (withCoordinates) homeLon else "null"},
- "radius_m": 150.0,
- "hysteresis_m": 30.0
- },
- "curfew": ${curfew ?: "null"},
- "launcher_package": "com.launcher",
- "allowed_packages": ["com.launcher","pl.mbank","com.discord","com.google.android.youtube"],
- "night_allowed_packages": ["com.launcher","pl.mbank"],
- "never_disable_prefixes": ["com.android.settings"],
- "workout_unblock_domains": ["youtube.com","googlevideo.com"],
- "browser_packages": []
- }
- """.trimIndent(),
- )
-
- /**
- * A policy that names YouTube as always-blocked and drops it from the
- * allowlist, since the exporter guarantees those two never overlap.
- */
- fun policyWithAlwaysBlocked() =
- FocusPolicy.parse(
- """
- {
- "schema_version": 1,
- "home": {
- "latitude": $homeLat,
- "longitude": $homeLon,
- "radius_m": 150.0,
- "hysteresis_m": 30.0
- },
- "curfew": {"start":"23:00","end":"05:00"},
- "launcher_package": "com.launcher",
- "allowed_packages": ["com.launcher","pl.mbank","com.discord"],
- "night_allowed_packages": ["com.launcher","pl.mbank"],
- "never_disable_prefixes": ["com.android.settings"],
- "workout_unblock_domains": ["youtube.com"],
- "browser_packages": [],
- "blockable_system_packages": ["com.google.android.youtube"],
- "always_blocked_packages": ["com.google.android.youtube"]
- }
- """.trimIndent(),
- )
-
- val installed = setOf(
- "com.launcher",
- "pl.mbank",
- "com.discord",
- "com.google.android.youtube",
- "com.evil",
- "com.android.settings",
- )
-
- fun latOffset(metres: Double) = homeLat + metres / metresPerDegreeLat
-
- /**
- * A candidate location fix at home, varying only in the fields that fix
- * selection actually orders on. Shared so the home coordinates stay in
- * one place: `EnforcementLogTest` asserts they never reach a log record,
- * and that assertion means less if the literals are copied around.
- */
- fun candidate(ageMs: Long, accuracyM: Double?, provider: String) = LocationFix(
- latitude = homeLat,
- longitude = homeLon,
- ageMs = ageMs,
- accuracyM = accuracyM,
- provider = provider,
- outcome = FixOutcome.CACHED_FRESH,
- )
-}
diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementLogTest.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementLogTest.kt
deleted file mode 100644
index 6ecb5827..00000000
--- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementLogTest.kt
+++ /dev/null
@@ -1,169 +0,0 @@
-package com.kuhy.focus_owner
-
-import org.json.JSONObject
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Rule
-import org.junit.Test
-import org.junit.rules.TemporaryFolder
-
-/**
- * The log is the only way this app can explain itself after the fact.
- *
- * `run-as` is refused on the release build device owner requires, and logcat
- * rotates -- measured empty while 82 alarms had fired. So these tests cover
- * the two properties that matter: a record survives to be read back, and no
- * coordinate ever reaches it.
- */
-class EnforcementLogTest {
-
- @get:Rule
- val folder = TemporaryFolder()
-
- private val homeLat = EnforcementFixtures.homeLat
- private val homeLon = EnforcementFixtures.homeLon
-
- private fun log() = EnforcementLog(folder.root)
-
- private fun decision(
- reason: EnforcementReason = EnforcementReason.AT_HOME,
- distanceM: Double? = 42.0,
- ) = EnforcementDecision(
- reason = reason,
- packagesToHide = setOf("com.google.android.youtube"),
- packagesToShow = setOf("com.launcher"),
- distanceM = distanceM,
- thresholdM = 180.0,
- insideFence = true,
- hideReasons = mapOf("com.google.android.youtube" to HideReason.ALWAYS_BLOCKED),
- )
-
- private fun fix() = LocationFix(
- latitude = homeLat,
- longitude = homeLon,
- ageMs = 45_000L,
- accuracyM = 12.0,
- provider = "gps",
- outcome = FixOutcome.ACTIVE_OK,
- )
-
- private fun record(
- decision: EnforcementDecision = decision(),
- fix: LocationFix? = fix(),
- ) = EnforcementLog.record(
- timestampMs = 1_786_000_000_000L,
- decision = decision,
- fix = fix,
- policy = null,
- homeConfigured = true,
- permissions = setOf("android.permission.ACCESS_FINE_LOCATION"),
- hidDelta = listOf("com.google.android.youtube"),
- restoredDelta = emptyList(),
- )
-
- @Test
- fun `a record round-trips through the file`() {
- log().append(record())
- val lines = log().readRecent()
- assertEquals(1, lines.size)
- val parsed = JSONObject(lines.first())
- assertEquals("AT_HOME", parsed.getString("reason"))
- assertEquals(42.0, parsed.getDouble("distance_m"), 0.001)
- assertEquals(180.0, parsed.getDouble("threshold_m"), 0.001)
- assertEquals("gps", parsed.getJSONObject("fix").getString("provider"))
- assertEquals("ACTIVE_OK", parsed.getJSONObject("fix").getString("outcome"))
- }
-
- @Test
- fun `the record never contains coordinates`() {
- // The single most important property here: logcat and this file are
- // both reachable by anyone with the phone, and this is a home address.
- val serialised = record().toString()
- assertFalse(serialised.contains("52.2"))
- assertFalse(serialised.contains("21.0"))
- assertFalse(serialised.contains("latitude"))
- assertFalse(serialised.contains("longitude"))
- }
-
- @Test
- fun `every hidden package carries why it is hidden`() {
- val parsed = JSONObject(record().toString())
- val hidden = parsed.getJSONArray("hidden")
- assertEquals(1, hidden.length())
- assertEquals("com.google.android.youtube", hidden.getJSONObject(0).getString("pkg"))
- assertEquals("ALWAYS_BLOCKED", hidden.getJSONObject(0).getString("why"))
- }
-
- @Test
- fun `records read back newest first`() {
- val subject = log()
- subject.append(record(decision(reason = EnforcementReason.AWAY)))
- subject.append(record(decision(reason = EnforcementReason.CURFEW)))
- val lines = subject.readRecent()
- assertEquals("CURFEW", JSONObject(lines[0]).getString("reason"))
- assertEquals("AWAY", JSONObject(lines[1]).getString("reason"))
- }
-
- @Test
- fun `readRecent respects the limit`() {
- val subject = log()
- repeat(5) { subject.append(record()) }
- assertEquals(2, subject.readRecent(limit = 2).size)
- }
-
- @Test
- fun `a corrupt line is skipped rather than losing the history`() {
- // A process killed mid-append must not cost the whole log, which is
- // exactly when the log is most wanted.
- val subject = log()
- subject.append(record())
- java.io.File(folder.root, "enforcement_log.jsonl").appendText("{half-written\n")
- subject.append(record(decision(reason = EnforcementReason.AWAY)))
- val lines = subject.readRecent()
- assertEquals(2, lines.size)
- assertEquals("AWAY", JSONObject(lines[0]).getString("reason"))
- }
-
- @Test
- fun `an empty log reads as empty rather than failing`() {
- assertTrue(log().readRecent().isEmpty())
- }
-
- @Test
- fun `the file is capped and rotates on whole lines`() {
- val subject = log()
- // Enough records to trigger several rotations.
- repeat(1200) { subject.append(record()) }
- val file = java.io.File(folder.root, "enforcement_log.jsonl")
- val lines = file.readLines().filter { it.isNotBlank() }
- // Growth is bounded, but by neither constant alone: a trim drops the
- // file to KEEP_LINES (400) and appends then resume until MAX_BYTES is
- // next crossed, so the steady state is 400 plus one trim interval.
- // What matters is that 1200 appends do not accumulate.
- assertTrue("expected rotation, got ${lines.size} lines", lines.size < 1200)
- assertTrue("expected a bounded file, got ${lines.size}", lines.size <= 800)
- assertTrue(lines.isNotEmpty())
- // Rotating mid-record would make the oldest survivor unparsable.
- lines.forEach { JSONObject(it) }
- }
-
- @Test
- fun `a pass with no fix records the outcome rather than a bare null`() {
- val parsed = JSONObject(
- record(
- decision = decision(
- reason = EnforcementReason.LOCATION_UNKNOWN,
- distanceM = null,
- ),
- fix = LocationFix.unavailable(FixOutcome.TIMEOUT),
- ).toString(),
- )
- assertEquals("LOCATION_UNKNOWN", parsed.getString("reason"))
- assertTrue(parsed.isNull("distance_m"))
- // This is the field that separates "at the office, cache empty" from
- // "fuzzed fix at home" -- the two symptoms that looked identical.
- assertEquals("TIMEOUT", parsed.getJSONObject("fix").getString("outcome"))
- assertTrue(parsed.getJSONObject("fix").isNull("age_ms"))
- }
-}
diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementRunnerBestTest.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementRunnerBestTest.kt
deleted file mode 100644
index b133a063..00000000
--- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementRunnerBestTest.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.kuhy.focus_owner
-
-import org.junit.Assert.assertEquals
-import org.junit.Test
-
-/**
- * Fix selection: which of several candidate locations a pass actually uses.
- *
- * Split out of `EnforcementLogTest`, which had grown to cover two unrelated
- * subjects. These cases never touch the log -- they pin the ordering rule that
- * decides whether a pass sees a usable fix at all, which is upstream of
- * anything being written down.
- */
-class EnforcementRunnerBestTest {
-
- private fun candidate(ageMs: Long, accuracyM: Double?, provider: String) =
- EnforcementFixtures.candidate(ageMs, accuracyM, provider)
-
- private val window = 30L * 60L * 1000L
-
- @Test
- fun `a recent coarse fix beats an old precise one`() {
- // The ordering that matters. Sorting by accuracy first would surface
- // the morning GPS fix, the age gate would then reject it, the active
- // request times out indoors, and the pass falls to LOCATION_UNKNOWN
- // and blocks everything -- the office symptom, reintroduced.
- val chosen = EnforcementRunner.best(
- listOf(
- candidate(ageMs = 6L * 60 * 60 * 1000, accuracyM = 10.0, provider = "gps"),
- candidate(ageMs = 2L * 60 * 1000, accuracyM = 500.0, provider = "network"),
- ),
- window,
- )
- assertEquals("network", chosen?.provider)
- }
-
- @Test
- fun `within the window the more accurate fix wins`() {
- // A 2 km fix cannot answer a 150 m question, so a slightly older but
- // far more precise fix is the better input.
- val chosen = EnforcementRunner.best(
- listOf(
- candidate(ageMs = 10_000, accuracyM = 2000.0, provider = "network"),
- candidate(ageMs = 60_000, accuracyM = 10.0, provider = "gps"),
- ),
- window,
- )
- assertEquals("gps", chosen?.provider)
- }
-
- @Test
- fun `unknown accuracy loses to a known one`() {
- val chosen = EnforcementRunner.best(
- listOf(
- candidate(ageMs = 10_000, accuracyM = null, provider = "passive"),
- candidate(ageMs = 60_000, accuracyM = 80.0, provider = "gps"),
- ),
- window,
- )
- assertEquals("gps", chosen?.provider)
- }
-
- @Test
- fun `with nothing fresh the newest stale fix is reported`() {
- // The caller classifies it unusable regardless; returning the newest
- // makes the log record describe what was actually available.
- val chosen = EnforcementRunner.best(
- listOf(
- candidate(ageMs = 6L * 60 * 60 * 1000, accuracyM = 10.0, provider = "gps"),
- candidate(ageMs = 40L * 60 * 1000, accuracyM = 500.0, provider = "network"),
- ),
- window,
- )
- assertEquals("network", chosen?.provider)
- }
-
- @Test
- fun `no candidates yields no fix`() {
- assertEquals(null, EnforcementRunner.best(emptyList(), window))
- }
-
- @Test
- fun `a single fix is returned even with unknown accuracy`() {
- val chosen = EnforcementRunner.best(
- listOf(candidate(ageMs = 10_000, accuracyM = null, provider = "passive")),
- window,
- )
- assertEquals("passive", chosen?.provider)
- }
-}
diff --git a/focus_owner/android/build.gradle.kts b/focus_owner/android/build.gradle.kts
deleted file mode 100644
index dbee657b..00000000
--- a/focus_owner/android/build.gradle.kts
+++ /dev/null
@@ -1,24 +0,0 @@
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
-
-val newBuildDir: Directory =
- rootProject.layout.buildDirectory
- .dir("../../build")
- .get()
-rootProject.layout.buildDirectory.value(newBuildDir)
-
-subprojects {
- val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
- project.layout.buildDirectory.value(newSubprojectBuildDir)
-}
-subprojects {
- project.evaluationDependsOn(":app")
-}
-
-tasks.register("clean") {
- delete(rootProject.layout.buildDirectory)
-}
diff --git a/focus_owner/android/gradle.properties b/focus_owner/android/gradle.properties
deleted file mode 100644
index e96108cf..00000000
--- a/focus_owner/android/gradle.properties
+++ /dev/null
@@ -1,6 +0,0 @@
-org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
-android.useAndroidX=true
-# This newDsl flag was added by the Flutter template
-android.newDsl=false
-# This builtInKotlin flag was added by the Flutter template
-android.builtInKotlin=false
diff --git a/focus_owner/android/gradle/wrapper/gradle-wrapper.properties b/focus_owner/android/gradle/wrapper/gradle-wrapper.properties
deleted file mode 100644
index 2d428bfb..00000000
--- a/focus_owner/android/gradle/wrapper/gradle-wrapper.properties
+++ /dev/null
@@ -1,5 +0,0 @@
-distributionBase=GRADLE_USER_HOME
-distributionPath=wrapper/dists
-zipStoreBase=GRADLE_USER_HOME
-zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
diff --git a/focus_owner/android/settings.gradle.kts b/focus_owner/android/settings.gradle.kts
deleted file mode 100644
index c21f0c5b..00000000
--- a/focus_owner/android/settings.gradle.kts
+++ /dev/null
@@ -1,26 +0,0 @@
-pluginManagement {
- val flutterSdkPath =
- run {
- val properties = java.util.Properties()
- file("local.properties").inputStream().use { properties.load(it) }
- val flutterSdkPath = properties.getProperty("flutter.sdk")
- require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
- flutterSdkPath
- }
-
- includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
-
- repositories {
- google()
- mavenCentral()
- gradlePluginPortal()
- }
-}
-
-plugins {
- id("dev.flutter.flutter-plugin-loader") version "1.0.0"
- id("com.android.application") version "9.0.1" apply false
- id("org.jetbrains.kotlin.android") version "2.3.20" apply false
-}
-
-include(":app")
diff --git a/focus_owner/assets/policy.json b/focus_owner/assets/policy.json
deleted file mode 100644
index 563b2d96..00000000
--- a/focus_owner/assets/policy.json
+++ /dev/null
@@ -1,153 +0,0 @@
-{
- "allowed_packages": [
- "com.android.vending",
- "com.celzero.bravedns",
- "com.facebook.orca",
- "com.google.android.apps.maps",
- "com.google.android.apps.nexuslauncher",
- "com.google.android.calendar",
- "com.google.android.deskclock",
- "com.ichi2.anki",
- "com.kuhy.diet_guard_app",
- "com.kuhy.focus_owner",
- "com.kuhy.home_inventory",
- "com.kuhy.lyricanki",
- "com.kuhy.untools",
- "com.kuhy.wake_alarm_sync",
- "com.kuhy.workout_app",
- "com.kunzisoft.keepass.libre",
- "com.metrolist.music",
- "com.revolut.revolut",
- "com.sosauce.cutecalc",
- "com.zaneschepke.wireguardautotunnel",
- "dev.kuhy.todo",
- "eu.kanade.tachiyomi.sy",
- "org.fossify.contacts",
- "org.fossify.messages",
- "org.fossify.phone",
- "org.runnerup",
- "org.runnerup.free",
- "pl.infakt.infakt",
- "pl.mbank",
- "pl.nask.mobywatel"
- ],
- "allowed_prefixes": [
- "eu.kanade.tachiyomi"
- ],
- "always_blocked_packages": [
- "com.android.chrome",
- "com.google.android.apps.youtube.music",
- "com.google.android.youtube"
- ],
- "always_on_vpn_package": "com.celzero.bravedns",
- "blockable_system_packages": [
- "com.android.chrome",
- "com.android.vending",
- "com.google.android.apps.youtube.music",
- "com.google.android.videos",
- "com.google.android.youtube"
- ],
- "browser_packages": [
- "com.android.chrome",
- "org.mozilla.fenix"
- ],
- "curfew": {
- "end": "05:00",
- "start": "23:00"
- },
- "home": {
- "hysteresis_m": 30.0,
- "latitude": null,
- "longitude": null,
- "radius_m": 150.0
- },
- "launcher_package": "com.google.android.apps.nexuslauncher",
- "never_disable_prefixes": [
- "android",
- "com.android.bluetooth",
- "com.android.camera",
- "com.android.captiveportallogin",
- "com.android.certinstaller",
- "com.android.contacts",
- "com.android.dialer",
- "com.android.documentsui",
- "com.android.emergency",
- "com.android.externalstorage",
- "com.android.inputmethod",
- "com.android.keychain",
- "com.android.launcher",
- "com.android.location",
- "com.android.messaging",
- "com.android.mms",
- "com.android.networkstack",
- "com.android.nfc",
- "com.android.permissioncontroller",
- "com.android.phone",
- "com.android.providers",
- "com.android.se",
- "com.android.settings",
- "com.android.shell",
- "com.android.systemui",
- "com.android.vpndialogs",
- "com.android.wifi",
- "com.celzero.bravedns",
- "com.google.android.apps.messaging",
- "com.google.android.contacts",
- "com.google.android.deskclock",
- "com.google.android.dialer",
- "com.google.android.ext.services",
- "com.google.android.ext.shared",
- "com.google.android.gms",
- "com.google.android.gsf",
- "com.google.android.inputmethod.latin",
- "com.google.android.permissioncontroller",
- "com.google.android.setupwizard",
- "com.google.android.trichromelibrary",
- "com.google.android.webview",
- "com.mediatek",
- "com.qualcomm",
- "pl.infakt.infakt"
- ],
- "night_allowed_packages": [
- "com.celzero.bravedns",
- "com.google.android.apps.nexuslauncher",
- "com.google.android.deskclock",
- "com.kuhy.focus_owner",
- "com.kuhy.wake_alarm_sync",
- "com.kunzisoft.keepass.libre",
- "com.metrolist.music",
- "com.revolut.revolut",
- "dev.kuhy.todo",
- "org.fossify.contacts",
- "org.fossify.messages",
- "org.fossify.phone",
- "org.runnerup",
- "org.runnerup.free",
- "pl.infakt.infakt",
- "pl.mbank",
- "pl.nask.mobywatel"
- ],
- "night_allowed_prefixes": [
- "eu.kanade.tachiyomi"
- ],
- "private_dns_host": "",
- "schema_version": 1,
- "vpn_lockdown": true,
- "workout_unblock_domains": [
- "googlevideo.com",
- "i.ytimg.com",
- "i9.ytimg.com",
- "m.youtube.com",
- "s.ytimg.com",
- "www.youtube-nocookie.com",
- "www.youtube.com",
- "youtu.be",
- "youtube-nocookie.com",
- "youtube.com",
- "youtube.googleapis.com",
- "youtubei.googleapis.com",
- "yt3.ggpht.com",
- "yt3.googleusercontent.com",
- "ytimg.com"
- ]
-}
diff --git a/focus_owner/docs/profile-owner-verification-2026-08-09.md b/focus_owner/docs/profile-owner-verification-2026-08-09.md
deleted file mode 100644
index 22910cce..00000000
--- a/focus_owner/docs/profile-owner-verification-2026-08-09.md
+++ /dev/null
@@ -1,172 +0,0 @@
-# EnforcementRunner on-device verification (profile-owner harness)
-
-Date: 2026-08-09. Device: Pixel 6a `23181JEGR08034`, Android 16 (SDK 36).
-
-The Device Owner path is still blocked on a factory reset, so this run used a
-removable managed profile as a stand-in: `pm create-user --profileOf 0
---managed` (user 12) → `dpm set-profile-owner` → `pm install-existing --user 12`.
-Profile owner and device owner reach the same `DevicePolicyManager` calls, so
-the wiring under test is identical; what differs is scope, recorded below.
-
-## Verified
-
-| Property | Method | Result |
-| ---------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
-| Policy loads on device | app status screen | 84 allowed (39 night), curfew 23:00-05:00, radius 150 m, 15 workout domains |
-| Enforcement pass runs end-to-end | tap "Run enforcement now" | `applied LOCATION_UNKNOWN: hid 3, restored 0` |
-| Hide is real, not self-reported | `dumpsys package ` per-user `hidden=` | `com.termux`, `org.breezyweather`, `de.duenndns.gmdice` all `hidden=true` |
-| Hidden app is unreachable | `am start --user 12 -n com.termux/...` | `Error type 3 ... does not exist` |
-| Self-hide guard holds | same dumpsys sweep | `com.kuhy.focus_owner` stayed `hidden=false` |
-| **Hide survives reboot** | `adb reboot` → re-check | all three still `hidden=true` |
-| Next pass is armed | `dumpsys alarm` | `*walarm*:com.kuhy.focus_owner/.EnforcementAlarmReceiver` present |
-| **Unhide restores access** | after the fix below | `restored 3`; all `hidden=false`, launchable again |
-| Non-exported service rejects shell | `am start-foreground-service` | `Requires permission not exported from uid 1210324` — as designed; the UI button is the only trigger |
-
-The reboot result is the one that matters: it is the property the whole Device
-Owner design rests on, and it is the one `pm suspend` and `pm disable-user`
-both fail.
-
-## Not covered by this harness
-
-- **The geofence.** Retried after real coordinates were provisioned (see
- below), and still not reachable: a freshly created work profile has its own
- location cache, which starts empty and stays empty because nothing in the
- profile requests a fix. `dumpsys location` showed
- `user 10: last location=null` against a valid `52.228948,20.951184` fix in
- user 0. `EnforcementRunner.lastKnownLocation()` therefore returned null and
- every pass resolved to `LOCATION_UNKNOWN` (fail-closed) regardless. Shell
- mock injection is blocked (`SecurityException: not allowed to perform
-MOCK_LOCATION`) and `cmd location force-location-update` does not exist on
- this build. **The AWAY branch remains unexercised on hardware** and will
- only be testable under a real Device Owner in user 0.
-
- Note the earlier `run-as` failure was mine, not a platform limit: `run-as
---user N` works once the app has been launched in user N. Coordinates were
- successfully written into the profile's `filesDir` on the second attempt.
-
-- **The full 373-package sweep.** The profile saw only the packages
- deliberately installed into it.
-- **Persisted hysteresis.** `wasEnforcing()` starts fresh in a new profile.
-- **System packages.** `com.google.android.dialer` is a system app, so
- `installedThirdPartyPackages()` filters it out; it did not exercise the
- allowlist branch as intended.
-
-## The bug this run found: hidden apps could never be restored
-
-Testing the restore path meant widening the allowlist to cover the three test
-apps and re-running a pass. Expected `restored 3`. Observed:
-
-```
-applied LOCATION_UNKNOWN: hid 0, restored 0
-```
-
-with all three still `hidden=true`. The cause is that
-`pm list packages --user 12 -3` returned _only_ `com.kuhy.focus_owner` — a
-package hidden via `setApplicationHidden` is dropped from the default package
-enumeration entirely, while remaining `installed=true hidden=true` and visible
-under `pm list packages -u`.
-
-`EnforcementRunner.installedThirdPartyPackages()` called
-`getInstalledApplications(0)`. So once an app was hidden it left
-`installedPackages`, which meant it could never appear in
-`EnforcementDecision.packagesToShow`, which meant **nothing this app hid could
-ever be unhidden again** — the exact failure the show-before-hide ordering in
-`apply()` exists to prevent. Leaving home would not have restored anything.
-
-Fix: query with `PackageManager.MATCH_UNINSTALLED_PACKAGES`. Re-verified on
-device after the fix:
-
-```
-setApplicationHidden(de.duenndns.gmdice,false)=true
-setApplicationHidden(org.breezyweather,false)=true
-setApplicationHidden(com.termux,false)=true
-applied LOCATION_UNKNOWN: hid 0, restored 3
-```
-
-All three then read `hidden=false`, reappeared in the default enumeration, and
-`cmd package resolve-activity com.termux` resolved to `TermuxActivity` again.
-
-The flag also admits packages that are uninstalled with data retained, which
-would let `packagesToHide` contain ghosts. Measured on this device:
-`pm list packages -3` and `pm list packages -u -3` both return 49, so there are
-none. `apply()` wraps both bridge calls in `runCatching` regardless, so a ghost
-would be a no-op rather than a crash.
-
-## Second finding: the self-hide guard is not redundant
-
-`EnforcementRunner.kt:79-80` comments the self-hide check as defence in depth —
-_"the policy already refuses to hide these"_. Measured against the committed
-policy, it does not: `com.kuhy.focus_owner` is absent from `allowed_packages`,
-absent from `night_allowed_packages`, and matches none of the 44
-`never_disable_prefixes`. The decision layer therefore places the app in
-`packagesToHide` on **every** enforcing pass, and the `pkg ==
-context.packageName` check at line 81 is the _only_ thing stopping the app from
-hiding itself — which would remove the escape hatch and the "Run enforcement
-now" trigger together.
-
-Fix belongs in the exporter (`python_pkg/focus_policy/export.py`), not a
-hand-edit of the asset, so the guarantee is generated rather than remembered.
-The line-81 check should stay either way; only the comment is wrong.
-
-## Wiring gap found
-
-`EnforcementRunner.decide()` hardcodes `workoutActive = false`
-(`EnforcementRunner.kt:57`). The decision layer honours the flag
-(`EnforcementDecision.kt:83`), but nothing feeds it in the Kotlin path — and
-the Kotlin path is the one that runs at alarm time, since a background service
-has no Flutter engine. **The workout unblock is currently dead in the deployed
-path.** Not fixed in this session.
-
-The Dart side is thinner than it looks. `lib/workout_signal.dart` defines
-`ManualWorkoutSignal`, `GuardedWorkoutSignal` and `CombinedWorkoutSignal`, all
-unit-tested, but none is constructed anywhere in `lib/` — and there is **no
-Health Connect integration at all**: no `androidx.health` dependency, no
-`android.permission.health.*` in the manifest, no query. `'health_connect'`
-appears only as a fixture string in `test/workout_signal_test.dart`. Wiring
-Health Connect is a from-scratch build, not a port.
-
-It is, however, a viable one. Measured on device: `com.stronglifts.app` holds
-`android.permission.health.WRITE_EXERCISE`, `WRITE_ACTIVE_CALORIES_BURNED`,
-`WRITE_WEIGHT`, `WRITE_HEIGHT` and the matching `READ_*` grants, all
-`granted=true`, and `com.google.android.apps.healthdata` is installed. So the
-workout data the rooted system used to read out of StrongLifts' SQLite file is
-reachable through a supported API, which is the natural replacement for that
-lost signal.
-
-## Home coordinates: placeholder found and replaced
-
-`config_secrets.sh` still held `REDACTED_LAT`/`REDACTED_LON`, so the policy
-asset could not be regenerated. Real coordinates were taken from the Pixel 6a's
-own fix rather than a map lookup — `52.228948, 20.951184`, network provider,
-`hAcc=21.7 m`, consistent across the fused and network providers.
-
-That surfaced a second staleness problem: the coordinates already provisioned
-into the app's private storage were `52.2297, 21.0122` — Warsaw city centre, a
-placeholder. **4156 m from the real location, against a 150 m radius**, so the
-geofence would have read as permanently away from home. Re-provisioned via
-`scripts/push_home_location.sh` and verified on device.
-
-Regenerating `assets/policy.json` then confirmed the exporter fix end-to-end:
-`com.kuhy.focus_owner` now appears in both allowlists. The regeneration also
-picked up `com.kuhy.untools`, which was already in `config.sh:365` — the
-committed asset was simply stale.
-
-The committed asset stays redacted: `latitude: null`, `longitude: null`, and
-zero occurrences of the real values. The unredacted export (stdout only, used
-by the provisioning script) carries them.
-
-## Teardown (done)
-
-`pm remove-user 12` → `Success: removed user`; a clean build (committed policy
-asset, no widened allowlist) reinstalled into user 0; staged
-`/data/local/tmp/home_location.json` removed. Verified afterwards:
-
-- `pm list users` → user 0 only
-- `Device Owner Type: -1`, no profile owner
-- `com.termux`, `org.breezyweather`, `de.duenndns.gmdice`,
- `com.stronglifts.app` all `hidden=false` in user 0
-
-Three `EnforcementAlarmReceiver` alarms remain registered by the user-0
-instance. They are inert: `DevicePolicyBridge.setApplicationHidden` returns
-early when the caller is neither device owner nor profile owner, which user 0
-is not.
diff --git a/focus_owner/lib/debug_log_page.dart b/focus_owner/lib/debug_log_page.dart
deleted file mode 100644
index ca4d8f13..00000000
--- a/focus_owner/lib/debug_log_page.dart
+++ /dev/null
@@ -1,191 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-
-import 'package:focus_owner/enforcement_record.dart';
-import 'package:focus_owner/theme.dart';
-
-/// The enforcement history, newest first.
-///
-/// This screen is the deliverable for "extensive debug log to see when it does
-/// not work". It has to live in the app: logcat rotates away within minutes
-/// (measured empty while `dumpsys alarm` reported 82 alarms had fired) and
-/// `run-as` is refused on the release build device owner requires, so neither
-/// adb route can read the history.
-class DebugLogPage extends StatelessWidget {
- const DebugLogPage({required this.records, super.key});
-
- final List records;
-
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- backgroundColor: kField,
- appBar: AppBar(
- backgroundColor: kField,
- foregroundColor: kText,
- title: const Text('Enforcement log'),
- ),
- body: records.isEmpty
- ? const Center(
- child: Padding(
- padding: EdgeInsets.all(kGap * 2),
- child: Text(
- 'No passes recorded yet.\n\n'
- 'Each enforcement pass appends one entry here, so this fills '
- 'up on its own every 15 minutes.',
- textAlign: TextAlign.center,
- style: TextStyle(color: kMuted, height: 1.4),
- ),
- ),
- )
- : ListView.separated(
- padding: const EdgeInsets.all(kGap),
- itemCount: records.length,
- separatorBuilder: (_, _) => const SizedBox(height: 8),
- itemBuilder: (context, index) => _Entry(record: records[index]),
- ),
- );
- }
-}
-
-class _Entry extends StatelessWidget {
- const _Entry({required this.record});
-
- final EnforcementRecord record;
-
- @override
- Widget build(BuildContext context) {
- // Material, not a decorated box: ExpansionTile paints its ink on the
- // nearest Material ancestor and a DecoratedBox in between hides it.
- return Material(
- color: kSurface,
- borderRadius: BorderRadius.circular(6),
- child: Theme(
- data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
- child: ExpansionTile(
- title: Row(
- children: [
- Text(
- _clock(record.timestamp),
- style: const TextStyle(color: kMuted, fontSize: 12),
- ),
- const SizedBox(width: kGap / 2),
- Expanded(
- child: Text(
- record.failure != null
- ? 'NO DECISION'
- : record.reason.replaceAll('_', ' '),
- style: TextStyle(
- color: record.failure != null || record.locationUnknown
- ? kDanger
- : kText,
- fontSize: 13,
- fontWeight: FontWeight.w500,
- ),
- ),
- ),
- Text(
- record.distanceLabel,
- style: const TextStyle(color: kMuted, fontSize: 12),
- ),
- ],
- ),
- children: [
- Padding(
- padding: const EdgeInsets.fromLTRB(kGap, 0, kGap, kGap),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _line('When', record.timestamp.toString()),
- _line('Fix', record.fixLabel),
- _line(
- 'Curfew',
- switch (record.curfewActive) {
- true => 'active',
- false => 'inactive',
- null => 'unknown',
- },
- ),
- _line('Home set', '${record.homeConfigured ?? "unknown"}'),
- _line('Inside fence', '${record.insideFence ?? "unanswerable"}'),
- _line(
- 'Counts',
- '${record.hideCount} hidden / ${record.showCount} shown · '
- 'hid ${record.hidNow}, restored ${record.restoredNow}',
- ),
- if (record.failure != null)
- _line('Failure', record.failure!, danger: true),
- if (record.hidden.isNotEmpty) ...[
- const SizedBox(height: 6),
- const Text(
- 'Hidden',
- style: TextStyle(color: kMuted, fontSize: 12),
- ),
- for (final h in record.hidden)
- Text(
- ' ${h.package} — ${h.reason.explanation}',
- style: const TextStyle(color: kText, fontSize: 11),
- ),
- ],
- const SizedBox(height: 6),
- // Lets a record leave the phone without adb, which is the
- // only other way it could be shared.
- TextButton(
- onPressed: () => Clipboard.setData(
- ClipboardData(text: _asText(record)),
- ),
- child: const Text('Copy'),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
-
- static Widget _line(String label, String value, {bool danger = false}) =>
- Padding(
- padding: const EdgeInsets.only(bottom: 3),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- SizedBox(
- width: 110,
- child: Text(
- label,
- style: const TextStyle(color: kMuted, fontSize: 12),
- ),
- ),
- Expanded(
- child: Text(
- value,
- style: TextStyle(
- color: danger ? kDanger : kText,
- fontSize: 12,
- ),
- ),
- ),
- ],
- ),
- );
-
- static String _clock(DateTime at) =>
- '${at.hour.toString().padLeft(2, '0')}:'
- '${at.minute.toString().padLeft(2, '0')}';
-
- static String _asText(EnforcementRecord r) => [
- 'when: ${r.timestamp}',
- 'reason: ${r.reason}',
- 'distance: ${r.distanceLabel}',
- 'fix: ${r.fixLabel}',
- 'curfew active: ${r.curfewActive}',
- 'home configured: ${r.homeConfigured}',
- 'inside fence: ${r.insideFence}',
- 'hidden: ${r.hideCount}, shown: ${r.showCount}',
- 'hid now: ${r.hidNow}, restored now: ${r.restoredNow}',
- if (r.failure != null) 'failure: ${r.failure}',
- for (final h in r.hidden) ' ${h.package} (${h.reason.explanation})',
- ].join('\n');
-}
diff --git a/focus_owner/lib/device_policy.dart b/focus_owner/lib/device_policy.dart
deleted file mode 100644
index 0ff3ad9a..00000000
--- a/focus_owner/lib/device_policy.dart
+++ /dev/null
@@ -1,127 +0,0 @@
-import 'package:flutter/services.dart';
-
-/// Provisioning state reported by the native side.
-class DevicePolicyStatus {
- const DevicePolicyStatus({
- required this.packageName,
- required this.isDeviceOwner,
- required this.isAdminActive,
- required this.sdkInt,
- required this.restrictionsApplied,
- this.hasAccounts = true,
- });
-
- /// Builds a status from the raw platform-channel map.
- factory DevicePolicyStatus.fromMap(Map