Extract reverse_survivors, kcd2_dice_solver and focus_owner

Three self-contained apps move to their own public repos with full
history (8, 14 and 30 commits, each matching the monorepo count):

  github.com/kuhyx/reverse-survivors
  github.com/kuhyx/kcd2-dice-solver   (cloned to ~/kcd2-dice-solver)
  github.com/kuhyx/focus-owner

Each gained a CI workflow. Only reverse_survivors had one here; the other
two were never gated by anything, which is part of why they no longer
belong in a repo whose CI is shared.

What stayed: setup_kcd2_dice_solver.sh, which deploys the solver rather
than being part of it. It now builds from ~/kcd2-dice-solver, and the
post-commit hook it generates drops its `git diff -- kcd2_dice_solver/`
filter -- in a standalone repo every commit is solver code, so the old
filter could never match again.

focus_owner's two references to python_pkg.focus_policy now name the
phone-focus-mode repo, where that generator is headed.

Running the live kcd2 unit -- rather than reading it -- found three real
breakages that no amount of grepping would have: pnpm ignoring the
package.json "pnpm" field, pnpm refusing to purge node_modules without a
TTY, and the Docker container still bind-mounting the deleted monorepo
path. All three are fixed; the unit exits 0 and
https://dice.kuhy.duckdns.org/ serves the freshly built bundle.

Depth violations 417 -> 303.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 17:34:02 +02:00
parent ec5d3aba34
commit a0459b99a0
196 changed files with 84 additions and 28803 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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."
}

View File

@ -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 <dir>/: 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/<name>",
"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."
]
}

View File

@ -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

View File

@ -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'

View File

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

View File

@ -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

View File

@ -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

View File

@ -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")
}

View File

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -1,134 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- Required to start the pass with foregroundServiceType="location".
Without it startForeground throws SecurityException and the whole
service crashes on every alarm; measured 2026-08-14. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- USE_EXACT_ALARM rather than SCHEDULE_EXACT_ALARM: the latter is
user-revocable and was observed denied by default on Android 16,
which turned a curfew boundary into a +26 minute window. This app's
whole function is acting at specific times, which is the documented
qualifying use, and it is granted at install with no runtime prompt. -->
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- FINE is required, not a nicety. Coarse was assumed "enough for a 150 m
geofence"; it is not. Android 12+ fuzzes ACCESS_COARSE_LOCATION to a
~1-2 km grid that is re-randomised periodically, so a coarse fix taken
at home routinely lands outside a 150 m fence, which reads as AWAY
and restores every app the geofence exists to hide. Measured 2026-08-14:
only COARSE was held, and the phone alternated between blocking nothing
at home and blocking everything at the office.
COARSE stays declared so a partial grant still yields a (fuzzed) fix
rather than none at all. Both are self-granted by the device owner in
DevicePolicyBridge.grantLocationPermissions(); background access is
what makes the gate work while the screen is off. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Needed to enumerate installed packages for the allowlist sweep. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<application
android:label="focus_owner"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Device admin component. Declaring it makes device-owner
provisioning possible; it applies no restrictions on its own.
BIND_DEVICE_ADMIN must be held by the system, and the receiver must
be exported, or `dpm set-device-owner` cannot find it. -->
<receiver
android:name=".FocusDeviceAdminReceiver"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
</intent-filter>
</receiver>
<!-- Applies one enforcement pass, then schedules the next. Not
resident: the alarm brings it back. -->
<service
android:name=".EnforcementService"
android:exported="false"
android:foregroundServiceType="specialUse|location">
<!-- "location" as well as "specialUse": the pass actively requests
a fix, and background location delivery is throttled to a few
times an hour unless it happens under a location-typed
foreground service. Without it the request usually times out,
which fails closed and blocks everything. -->
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Applies the device-owner focus policy at scheduled times" />
</service>
<receiver
android:name=".EnforcementAlarmReceiver"
android:exported="false" />
<!-- directBootAware so LOCKED_BOOT_COMPLETED can re-arm the schedule
before the user unlocks; BOOT_COMPLETED alone does not arrive
until first unlock. -->
<receiver
android:name=".BootReceiver"
android:directBootAware="true"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<!-- Installing over the app cancels its pending alarms, so
without this an `adb install -r` silently stops
enforcement until someone opens the app. Measured
2026-08-14: an install at 13:31 dropped the 13:34 alarm,
and the chain only resumed because the app was launched.
A deploy during curfew would otherwise leave the curfew
allowlist applied indefinitely. -->
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -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}")
}
}
}

View File

@ -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<String> = 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<String, Any> = 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
}
}
}

View File

@ -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<String> {
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,
)
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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<String>,
val packagesToShow: Set<String>,
/**
* 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<String, HideReason> = 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<String>()
val show = mutableSetOf<String>()
val why = mutableMapOf<String, HideReason>()
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))
}
}
}

View File

@ -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<String> = 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<String>,
hidDelta: List<String>,
restoredDelta: List<String>,
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)
}
}

View File

@ -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<LocationFix>, 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<LocationFix> { it.accuracyM ?: Double.MAX_VALUE }
.thenBy { it.ageMs },
).first()
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<String>,
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,
)

View File

@ -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"
}
}

View File

@ -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<String>,
val nightAllowedPackages: Set<String>,
val neverDisablePrefixes: Set<String>,
/**
* 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<String> = emptySet(),
/** Prefixes that survive the curfew. Subset of [allowedPrefixes]. */
val nightAllowedPrefixes: Set<String> = emptySet(),
val workoutUnblockDomains: Set<String>,
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<String> = 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<String> = 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<String>
get() {
val mapping = mapOf(
"youtube.com" to listOf(
"com.google.android.youtube",
"com.google.android.apps.youtube.music",
),
)
val result = mutableSetOf<String>()
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<String>): 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<String> {
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<String> {
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
}
}
}

View File

@ -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<Double, Double>? {
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
}
}

View File

@ -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<Location?>()
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,
)
}
}

View File

@ -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,
)
}
}

View File

@ -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<String>("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<Boolean>("blocked")
if (blocked == null) {
result.error("bad_args", "blocked required", null)
} else {
result.success(bridge.setVpnConfigBlocked(blocked))
}
}
"setSelfUninstallBlocked" -> {
val blocked = call.argument<Boolean>("blocked")
if (blocked == null) {
result.error("bad_args", "blocked required", null)
} else {
result.success(bridge.setSelfUninstallBlocked(blocked))
}
}
"setApplicationHidden" -> {
val pkg = call.argument<String>("package")
val hidden = call.argument<Boolean>("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<String>("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<Int>("limit") ?: 200
result.success(
EnforcementLog(applicationContext).readRecent(limit),
)
}
else -> result.notImplemented()
}
}
}
companion object {
private const val CHANNEL = "com.kuhy.focus_owner/device_policy"
}
}

View File

@ -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<String>,
val restoredNow: List<String>,
)
/**
* 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<String>()
val hiddenNow = mutableListOf<String>()
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)
}

View File

@ -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
}

View File

@ -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<String> {
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()
}

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Device admin policy declaration.
Intentionally minimal: no <force-lock>, <wipe-data>, <disable-camera> or
password policies are requested. This build only needs the component to exist
so device owner can be provisioned and, more importantly, released again.
Capabilities are added alongside the enforcement that needs them.
-->
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
<uses-policies />
</device-admin>

View File

@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -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())
}
}

View File

@ -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)
}
}

View File

@ -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,
)
}

View File

@ -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"))
}
}

View File

@ -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)
}
}

View File

@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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"
]
}

View File

@ -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 <pkg>` 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.

View File

@ -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<EnforcementRecord> 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');
}

View File

@ -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<Object?, Object?> map) {
return DevicePolicyStatus(
packageName: map['packageName']! as String,
isDeviceOwner: map['isDeviceOwner']! as bool,
isAdminActive: map['isAdminActive']! as bool,
sdkInt: map['sdkInt']! as int,
restrictionsApplied: map['restrictionsApplied']! as bool,
// Absent from an older native side; default to the scarier reading.
hasAccounts: map['hasAccounts'] as bool? ?? true,
);
}
final String packageName;
final bool isDeviceOwner;
final bool isAdminActive;
final int sdkInt;
/// Whether any user restriction is currently enforced.
///
/// Hardcoded false by the native side and NOT a statement about enforcement
/// — the app is live and hiding packages, and it does apply user
/// restrictions (`DISALLOW_CONFIG_VPN`, `DISALLOW_CONFIG_PRIVATE_DNS`) once
/// those layers are locked. Nothing reads this back from the system yet, so
/// treat it as "not reported" rather than "nothing applied"; the honest
/// enforcement state is the hidden-app count in the latest
/// [EnforcementRecord], which is what the status screen shows.
final bool restrictionsApplied;
/// Whether any account exists, which decides if releasing is reversible.
///
/// `dpm set-device-owner` refuses while accounts exist, so once one is
/// added, releasing can only be undone by a factory reset.
final bool hasAccounts;
}
/// Dart side of the device-policy platform channel.
class DevicePolicy {
const DevicePolicy([this.channel = _defaultChannel]);
static const MethodChannel _defaultChannel =
MethodChannel('com.kuhy.focus_owner/device_policy');
final MethodChannel channel;
/// Reads the current provisioning state.
Future<DevicePolicyStatus> status() async {
final result =
await channel.invokeMethod<Map<Object?, Object?>>('status');
return DevicePolicyStatus.fromMap(result!);
}
/// Runs one enforcement pass now and arms the schedule.
///
/// The service schedules the following run at the end of each pass, so this
/// is also how the chain is started on a device that has not rebooted since
/// the app was installed.
Future<bool> runEnforcementNow() async =>
await channel.invokeMethod<bool>('runEnforcementNow') ?? false;
/// Cancels any pending scheduled evaluation.
Future<bool> cancelEnforcement() async =>
await channel.invokeMethod<bool>('cancelEnforcement') ?? false;
/// Reads the durable enforcement log, newest first.
///
/// This is the only way to see what the enforcer has been doing: logcat
/// rotates (measured empty while 82 alarms had fired) and `run-as` is
/// refused on the release build device owner requires, so neither adb route
/// works. Returns raw JSON lines for [EnforcementRecord.parseLines].
Future<List<String>> readEnforcementLog({int limit = 200}) async {
final lines = await channel.invokeMethod<List<Object?>>(
'readEnforcementLog',
{'limit': limit},
);
return lines?.whereType<String>().toList() ?? const [];
}
/// Relinquishes device ownership without wiping the device.
///
/// This is the escape hatch. It is deliberately reachable from the app's
/// main screen with no PC attached, because the situation it exists for is
/// one where ADB may already be disallowed.
Future<bool> releaseDeviceOwner() async {
final released = await channel.invokeMethod<bool>('releaseDeviceOwner');
return released ?? false;
}
/// Records the current location as home, enabling the geofence.
///
/// Returns null on success, or a message explaining the failure. Lives in
/// the app rather than a script because `run-as` does not work on the
/// release build that device owner requires.
Future<String?> setHomeToCurrentLocation() async =>
channel.invokeMethod<String>('setHomeToCurrentLocation');
/// Whether home coordinates are provisioned.
///
/// Without them every pass decides LOCATION_UNKNOWN and fails closed, so
/// enforcement applies everywhere rather than only at home.
Future<bool> hasHomeLocation() async =>
await channel.invokeMethod<bool>('hasHomeLocation') ?? false;
/// Prevents the user from turning the always-on VPN off in Settings.
///
/// Applied only after the VPN filter is confirmed working: pinning it over
/// a broken configuration would lock the broken state in.
Future<bool> setVpnConfigBlocked({required bool blocked}) async =>
await channel.invokeMethod<bool>(
'setVpnConfigBlocked',
{'blocked': blocked},
) ??
false;
}

View File

@ -1,219 +0,0 @@
import 'dart:convert';
/// Why a package is hidden, mirroring Kotlin's `HideReason`.
enum HideReason {
alwaysBlocked,
notInAllowlist,
notInNightAllowlist,
unknown;
static HideReason parse(String? raw) => switch (raw) {
'ALWAYS_BLOCKED' => HideReason.alwaysBlocked,
'NOT_IN_ALLOWLIST' => HideReason.notInAllowlist,
'NOT_IN_NIGHT_ALLOWLIST' => HideReason.notInNightAllowlist,
_ => HideReason.unknown,
};
/// Plain-English explanation shown next to a hidden app.
String get explanation => switch (this) {
HideReason.alwaysBlocked => 'always blocked, everywhere',
HideReason.notInAllowlist => 'not on the day allowlist',
HideReason.notInNightAllowlist => 'not on the curfew allowlist',
HideReason.unknown => 'reason not recorded',
};
}
/// One hidden package and why.
class HiddenPackage {
const HiddenPackage(this.package, this.reason);
final String package;
final HideReason reason;
}
/// One enforcement pass, as written by the Kotlin runner.
///
/// Deliberately a *parser*, not a second decision layer. The Kotlin side is
/// what actually enforces; re-deriving the outcome in Dart is how the two
/// drifted before, and a status screen that computes its own answer can
/// confidently report "nothing blocked" while YouTube is in fact hidden.
/// Everything here comes from the record.
///
/// Parsing is tolerant: a field this build does not recognise, or one written
/// by an older build, renders as unknown rather than throwing. A debug screen
/// that crashes on the malformed record is useless precisely when it matters.
class EnforcementRecord {
const EnforcementRecord({
required this.timestamp,
required this.reason,
required this.distanceM,
required this.thresholdM,
required this.insideFence,
required this.homeConfigured,
required this.fixAgeMs,
required this.fixProvider,
required this.fixAccuracyM,
required this.fixOutcome,
required this.curfewActive,
required this.curfewWindow,
required this.hideCount,
required this.showCount,
required this.hidNow,
required this.restoredNow,
required this.hidden,
required this.failure,
});
/// Parses one JSON-lines record.
factory EnforcementRecord.fromJson(Map<String, Object?> json) {
final fix = json['fix'];
final fixMap = fix is Map<String, Object?> ? fix : const <String, Object?>{};
final counts = json['counts'];
final countMap =
counts is Map<String, Object?> ? counts : const <String, Object?>{};
return EnforcementRecord(
timestamp: DateTime.fromMillisecondsSinceEpoch(
(json['ts'] as num?)?.toInt() ?? 0,
),
reason: json['reason'] as String? ?? 'UNKNOWN',
distanceM: (json['distance_m'] as num?)?.toDouble(),
thresholdM: (json['threshold_m'] as num?)?.toDouble(),
insideFence: json['inside_fence'] as bool?,
homeConfigured: json['home_configured'] as bool?,
fixAgeMs: (fixMap['age_ms'] as num?)?.toInt(),
fixProvider: fixMap['provider'] as String?,
fixAccuracyM: (fixMap['accuracy_m'] as num?)?.toDouble(),
fixOutcome: fixMap['outcome'] as String?,
curfewActive: json['curfew_active'] as bool?,
curfewWindow: json['curfew_window'] as String?,
hideCount: (countMap['to_hide'] as num?)?.toInt() ?? 0,
showCount: (countMap['to_show'] as num?)?.toInt() ?? 0,
hidNow: (countMap['hid_delta'] as num?)?.toInt() ?? 0,
restoredNow: (countMap['restored_delta'] as num?)?.toInt() ?? 0,
hidden: _parseHidden(json['hidden']),
failure: json['failure'] as String?,
);
}
/// Parses the raw lines returned by the platform channel, newest first.
///
/// A line that will not parse is skipped rather than failing the batch: one
/// truncated record must not cost the whole history.
static List<EnforcementRecord> parseLines(List<String> lines) {
final records = <EnforcementRecord>[];
for (final line in lines) {
try {
final decoded = jsonDecode(line);
if (decoded is Map<String, Object?>) {
records.add(EnforcementRecord.fromJson(decoded));
}
// Deliberately broad. Catching only FormatException let a
// syntactically valid line with a wrong-typed field (say
// `"reason": 42` from a future schema, or a half-overwritten record)
// throw TypeError instead, and that escaped all the way out of
// _refresh(), leaving the status page stuck busy -- which disables
// the release-device-owner button, whose entire purpose is to be
// reachable when everything else has gone wrong. One unreadable
// record must never cost the escape hatch.
} catch (_) {
continue;
}
}
return records;
}
static List<HiddenPackage> _parseHidden(Object? raw) {
if (raw is! List) return const [];
return raw
.whereType<Map<String, Object?>>()
.map(
(e) => HiddenPackage(
e['pkg'] as String? ?? '?',
HideReason.parse(e['why'] as String?),
),
)
.toList();
}
final DateTime timestamp;
final String reason;
final double? distanceM;
final double? thresholdM;
final bool? insideFence;
final bool? homeConfigured;
final int? fixAgeMs;
final String? fixProvider;
final double? fixAccuracyM;
final String? fixOutcome;
final bool? curfewActive;
final String? curfewWindow;
final int hideCount;
final int showCount;
final int hidNow;
final int restoredNow;
final List<HiddenPackage> hidden;
final String? failure;
/// Whether this pass could not place the phone.
bool get locationUnknown => reason == 'LOCATION_UNKNOWN';
/// Whether home was never provisioned, which enforces everywhere.
///
/// Kept distinct from every other state: with no coordinates the fence
/// reports "inside" and the reason reads AT_HOME, so without this the screen
/// would claim the phone is at home wherever it actually is.
bool get homeMissing => homeConfigured == false;
/// A one-line explanation of [reason], in the user's terms.
String get explanation {
if (failure != null) return 'No decision was made: $failure';
if (homeMissing) {
return 'No home location is set, so everything is enforced everywhere. '
'Use "Set home to current location" while you are at home.';
}
return switch (reason) {
'AWAY' => 'Away from home. Everything is available except the '
'always-blocked apps, which stay hidden wherever you are.',
'AT_HOME' => 'At home. Only allowlisted apps are available.',
'CURFEW' => 'Night curfew. The shorter night allowlist applies.',
'WORKOUT' => 'Workout in progress, so the workout exceptions apply.',
'LOCATION_UNKNOWN' =>
'No usable location fix, so the phone is blocking as if you were at '
'home. Losing GPS must not become a way to switch enforcement off.',
_ => 'Unrecognised state: $reason',
};
}
/// Distance rendered for humans, or why there is none.
String get distanceLabel {
if (homeMissing) return 'no home set';
final metres = distanceM;
if (metres == null) return 'unknown - no location fix';
final threshold = thresholdM;
final distance =
metres >= 1000 ? '${(metres / 1000).toStringAsFixed(1)} km' : '${metres.round()} m';
if (threshold == null) return distance;
return '$distance (fence ${threshold.round()} m)';
}
/// Fix age, provider and accuracy on one line.
String get fixLabel {
final age = fixAgeMs;
if (age == null) return fixOutcome?.toLowerCase() ?? 'no fix';
final seconds = age ~/ 1000;
final agePart = seconds >= 60 ? '${seconds ~/ 60} min old' : '$seconds s old';
final parts = <String>[
agePart,
?fixProvider,
if (fixAccuracyM != null) '±${fixAccuracyM!.round()} m',
if (fixOutcome != null) fixOutcome!.toLowerCase(),
];
return parts.join(' · ');
}
/// Whether the fix was too vague to trust against a 150 m fence.
///
/// Surfaced because it is the tell that FINE location is not actually in
/// effect: coarse location is fuzzed to a ~1-2 km grid.
bool get fixLooksFuzzed => (fixAccuracyM ?? 0) > 500;
}

View File

@ -1,148 +0,0 @@
import 'package:flutter/material.dart';
import 'package:focus_owner/enforcement_record.dart';
import 'package:focus_owner/theme.dart';
part 'status_card_parts.dart';
/// Renders the most recent enforcement pass, and why it decided what it did.
///
/// Exists because the three states were indistinguishable from the phone:
/// LOCATION_UNKNOWN applies the identical sweep to AT_HOME, so "no GPS" and
/// "at home" looked the same, and nothing on screen showed the distance the
/// geofence had actually measured. Every field here answers a question that
/// previously required reading logcat, which does not survive long enough.
class EnforcementStatusCard extends StatelessWidget {
const EnforcementStatusCard({
required this.record,
required this.onOpenLog,
super.key,
});
/// The latest pass, or null when none has been recorded yet.
final EnforcementRecord? record;
final VoidCallback onOpenLog;
@override
Widget build(BuildContext context) {
final latest = record;
if (latest == null) {
return const _Card(
child: Text(
'No enforcement pass recorded yet.\n'
'Tap "Run enforcement now" to make one.',
style: TextStyle(color: kMuted),
),
);
}
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_headline(latest),
style: TextStyle(
color: _reasonColour(latest),
fontSize: 22,
fontWeight: FontWeight.w600,
),
),
),
Text(
_ago(latest.timestamp),
style: const TextStyle(color: kMuted, fontSize: 12),
),
],
),
const SizedBox(height: kGap / 2),
Text(
latest.explanation,
style: const TextStyle(color: kText, height: 1.35),
),
const SizedBox(height: kGap),
_Field(label: 'Distance from home', value: latest.distanceLabel),
_Field(
label: 'Location fix',
value: latest.fixLabel,
danger: latest.fixLooksFuzzed,
),
if (latest.fixLooksFuzzed)
const _Note(
text: 'That accuracy is far wider than the fence, so the '
'geofence cannot be trusted. Precise location is probably '
'not in effect.',
),
_Field(
label: 'Night curfew',
value: switch (latest.curfewActive) {
true => 'active${_window(latest)}',
false => 'inactive${_window(latest)}',
null => 'unknown',
},
),
_Field(
label: 'Apps',
value: '${latest.hideCount} hidden, ${latest.showCount} available',
),
if (latest.hidNow > 0 || latest.restoredNow > 0)
_Field(
label: 'Changed this pass',
value: 'hid ${latest.hidNow}, restored ${latest.restoredNow}',
),
if (_isStale(latest.timestamp))
const _Note(
text: 'The last pass was over 30 minutes ago. Enforcement runs '
'every 15 minutes, so the schedule may have stalled.',
danger: true,
),
if (latest.hidden.isNotEmpty) ...[
const SizedBox(height: kGap / 2),
_HiddenList(hidden: latest.hidden),
],
const SizedBox(height: kGap / 2),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: onOpenLog,
child: const Text('Debug log'),
),
),
],
),
);
}
String _headline(EnforcementRecord r) {
if (r.failure != null) return 'NO DECISION';
if (r.homeMissing) return 'NO HOME SET';
return r.reason.replaceAll('_', ' ');
}
/// Colour-coded so the state that needs attention stands out.
Color _reasonColour(EnforcementRecord r) {
if (r.failure != null || r.homeMissing) return kDanger;
return switch (r.reason) {
'LOCATION_UNKNOWN' => kDanger,
'AWAY' => kMuted,
'CURFEW' => kWarn,
_ => kAccent,
};
}
String _window(EnforcementRecord r) =>
r.curfewWindow == null ? '' : ' (${r.curfewWindow})';
static bool _isStale(DateTime at) =>
DateTime.now().difference(at) > const Duration(minutes: 30);
static String _ago(DateTime at) {
final delta = DateTime.now().difference(at);
if (delta.inMinutes < 1) return 'just now';
if (delta.inMinutes < 60) return '${delta.inMinutes} min ago';
if (delta.inHours < 24) return '${delta.inHours} h ago';
return '${delta.inDays} d ago';
}
}

View File

@ -1,197 +0,0 @@
/// The FocusPolicy aggregate and its JSON parsing.
///
/// Split out of policy.dart to keep it under the 250-line cap. A `part`
/// rather than a separate library: Dart privates are library-scoped, and
/// this code uses policy.dart's _requireNum and _parseHhMm.
part of 'policy.dart';
/// The device-independent focus policy, as generated from `config.sh`.
class FocusPolicy {
const FocusPolicy({
required this.home,
required this.allowedPackages,
required this.nightAllowedPackages,
required this.neverDisablePrefixes,
required this.workoutUnblockDomains,
required this.curfew,
required this.launcherPackage,
this.allowedPrefixes = const {},
this.nightAllowedPrefixes = const {},
});
/// Parses a rendered policy document.
factory FocusPolicy.fromJson(Map<String, Object?> json) {
final version = json['schema_version'];
if (version != kSupportedSchemaVersion) {
throw PolicyFormatException(
'unsupported schema_version $version '
'(this build understands $kSupportedSchemaVersion)',
);
}
final home = json['home'];
if (home is! Map<String, Object?>) {
throw const PolicyFormatException('missing "home" object');
}
final curfew = json['curfew'];
return FocusPolicy(
home: HomeLocation.fromJson(home),
allowedPackages: _stringSet(json['allowed_packages'], 'allowed_packages'),
nightAllowedPackages:
_stringSet(json['night_allowed_packages'], 'night_allowed_packages'),
neverDisablePrefixes: _stringSet(
json['never_disable_prefixes'],
'never_disable_prefixes',
),
workoutUnblockDomains: _stringSet(
json['workout_unblock_domains'],
'workout_unblock_domains',
),
curfew: curfew == null
? null
: CurfewWindow.fromJson(curfew as Map<String, Object?>),
launcherPackage: json['launcher_package'] as String?,
// Optional: absent from an asset rendered before prefixes existed, and
// reading a missing key as empty restores exactly the old exact-match
// behaviour rather than failing to parse the whole policy.
allowedPrefixes: _optionalStringSet(
json,
'allowed_prefixes',
),
nightAllowedPrefixes: _optionalStringSet(
json,
'night_allowed_prefixes',
),
);
}
/// Loads the policy bundled with the app.
static Future<FocusPolicy> load({
AssetBundle? bundle,
String assetKey = 'assets/policy.json',
}) async {
final raw = await (bundle ?? rootBundle).loadString(assetKey);
final decoded = jsonDecode(raw);
if (decoded is! Map<String, Object?>) {
throw const PolicyFormatException('policy asset is not a JSON object');
}
return FocusPolicy.fromJson(decoded);
}
final HomeLocation home;
final Set<String> allowedPackages;
final Set<String> nightAllowedPackages;
final Set<String> neverDisablePrefixes;
final Set<String> workoutUnblockDomains;
final CurfewWindow? curfew;
final String? launcherPackage;
/// Prefix-matched day allowlist, for apps shipping as a package family.
///
/// Tachiyomi installs every source as its own apk, so an exact list goes
/// stale the moment a new extension is installed.
final Set<String> allowedPrefixes;
/// Prefixes that survive the curfew. Subset of [allowedPrefixes].
final Set<String> nightAllowedPrefixes;
/// Whether [package] 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.
static bool _matchesPrefix(String package, Set<String> prefixes) =>
prefixes.any(
(prefix) => package == prefix || package.startsWith('$prefix.'),
);
/// Whether a package must never be disabled.
bool isProtected(String package) =>
_matchesPrefix(package, neverDisablePrefixes);
/// Whether a package may run under the given conditions.
bool isAllowed(String package, {bool duringCurfew = false}) {
if (isProtected(package)) return true;
// Hiding the launcher leaves no home screen, so it outranks the lists --
// matching FocusPolicy.kt, which has always had this check.
if (package == launcherPackage) return true;
final allowed = duringCurfew ? nightAllowedPackages : allowedPackages;
if (allowed.contains(package)) return true;
final prefixes = duringCurfew ? nightAllowedPrefixes : allowedPrefixes;
return _matchesPrefix(package, prefixes);
}
/// Whether the curfew is in force at [minutesSinceMidnight].
bool isCurfewActive(int minutesSinceMidnight) =>
curfew?.contains(minutesSinceMidnight) ?? false;
/// Packages released while a workout is in progress.
///
/// The rooted system expressed this exception as *domains*, because it
/// enforced through a hosts file. Hiding apps is a coarser instrument: there
/// is no way to unblock youtube.com without unhiding the YouTube app, so the
/// exception is expressed as packages here.
///
/// Derived from [workoutUnblockDomains] rather than configured separately,
/// so that the shell config stays the single source of truth for *intent*
/// even though the mechanism differs.
Set<String> get workoutExemptPackages {
const domainToPackages = <String, List<String>>{
'youtube.com': [
'com.google.android.youtube',
'com.google.android.apps.youtube.music',
],
};
final packages = <String>{};
for (final entry in domainToPackages.entries) {
final covered = workoutUnblockDomains.any(
(domain) => domain == entry.key || domain.endsWith('.${entry.key}'),
);
if (covered) packages.addAll(entry.value);
}
return packages;
}
}
Set<String> _stringSet(Object? value, String field) {
if (value is! List) {
throw PolicyFormatException('"$field" must be a list');
}
return value.map((e) => e! as String).toSet();
}
/// 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 is worse than a missing
/// field: it disables the feature entirely rather than degrading to the
/// previous behaviour. A present-but-wrong type is still an error, since that
/// is a real mistake. Mirrors `optionalStringSet` in `FocusPolicy.kt`.
Set<String> _optionalStringSet(Map<String, Object?> json, String field) {
if (!json.containsKey(field)) return const {};
return _stringSet(json[field], field);
}
double _requireNum(Object? value, String field) {
if (value is! num) {
throw PolicyFormatException('"$field" must be a number');
}
return value.toDouble();
}
int _parseHhMm(Object? value, String field) {
if (value is! String) {
throw PolicyFormatException('"$field" must be a "HH:MM" string');
}
final parts = value.split(':');
if (parts.length != 2) {
throw PolicyFormatException('"$field" must be "HH:MM", got "$value"');
}
final hour = int.tryParse(parts[0]);
final minute = int.tryParse(parts[1]);
if (hour == null || minute == null || hour > 23 || minute > 59) {
throw PolicyFormatException('"$field" is not a valid time: "$value"');
}
return hour * 60 + minute;
}

View File

@ -1,60 +0,0 @@
import 'dart:convert';
import 'dart:io';
/// The home coordinates, kept out of the committed policy asset.
///
/// `assets/policy.json` ships with `latitude`/`longitude` null so that the
/// repository never records where the user lives. The real values are pushed
/// to the app's private storage at provisioning time by
/// `scripts/push_home_location.sh`, which reads them from the untracked
/// `phone_focus_mode/config_secrets.sh`.
///
/// Private app storage is not world-readable and does not survive an uninstall,
/// which is the right lifetime for this: reinstalling the app should require
/// re-provisioning rather than silently inheriting a stale home.
class HomeLocationStore {
const HomeLocationStore(this.file);
/// Filename written by the provisioning script.
static const String fileName = 'home_location.json';
final File file;
/// Reads the stored coordinates, or null when none have been provisioned.
///
/// Returns null rather than throwing on a malformed or unreadable file. The
/// caller treats an absent home the same way it treats an absent GPS fix —
/// as "cannot determine location", which fails closed into enforcing rather
/// than releasing.
Future<({double latitude, double longitude})?> read() async {
if (!file.existsSync()) return null;
try {
final decoded = jsonDecode(await file.readAsString());
if (decoded is! Map<String, Object?>) return null;
final lat = decoded['latitude'];
final lon = decoded['longitude'];
if (lat is! num || lon is! num) return null;
if (lat.abs() > 90 || lon.abs() > 180) return null;
return (latitude: lat.toDouble(), longitude: lon.toDouble());
} on FormatException {
return null;
} on FileSystemException {
return null;
}
}
/// Writes coordinates, rejecting values that cannot describe a real place.
Future<void> write(double latitude, double longitude) async {
if (latitude.abs() > 90 || longitude.abs() > 180) {
throw ArgumentError('coordinates out of range: $latitude, $longitude');
}
await file.writeAsString(
jsonEncode({'latitude': latitude, 'longitude': longitude}),
);
}
/// Removes any stored coordinates.
Future<void> clear() async {
if (file.existsSync()) await file.delete();
}
}

View File

@ -1,70 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:focus_owner/debug_log_page.dart';
import 'package:focus_owner/device_policy.dart';
import 'package:focus_owner/enforcement_record.dart';
import 'package:focus_owner/enforcement_status_card.dart';
import 'package:focus_owner/policy.dart';
import 'package:focus_owner/theme.dart';
part 'status_page_state.dart';
part 'status_page_dialogs.dart';
part 'status_body.dart';
void main() => runApp(const FocusOwnerApp());
// The palette used to be duplicated here as `kField`…`kDanger`, byte for
// byte identical to theme.dart's copy. Both are gone: theme.dart now aliases
// the shared design_system palette, and this file reads those aliases.
/// How often to re-read the log while waiting for a pass to land.
const Duration _kRefreshInterval = Duration(seconds: 2);
/// Gives up after this many polls, ~30 s — a little longer than the pass's own
/// location timeout, so a slow fix is waited out rather than reported stale.
const int _kRefreshAttempts = 15;
class FocusOwnerApp extends StatelessWidget {
const FocusOwnerApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Focus Owner',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: kField,
colorScheme: const ColorScheme.dark(
primary: kAccent,
surface: kSurface,
error: kDanger,
),
),
home: const StatusPage(),
);
}
}
/// Shows provisioning state and offers the one action this build supports:
/// giving up device ownership.
class StatusPage extends StatefulWidget {
const StatusPage({
super.key,
this.policy = const DevicePolicy(),
this.loadFocusPolicy = FocusPolicy.load,
});
final DevicePolicy policy;
/// Injectable so tests can supply a policy without a rootBundle asset.
final Future<FocusPolicy> Function() loadFocusPolicy;
@override
State<StatusPage> createState() => _StatusPageState();
}

View File

@ -1,85 +0,0 @@
import 'dart:convert';
import 'package:flutter/services.dart' show AssetBundle, rootBundle;
part 'focus_policy.dart';
/// Schema version this build understands.
///
/// The asset is generated by `python3 -m python_pkg.focus_policy`, which is the
/// single source of truth. Refusing an unknown version is deliberate: silently
/// accepting a newer schema would mean enforcing a policy this code has
/// misread, and a misread allowlist is an allowlist that blocks the dialer.
const int kSupportedSchemaVersion = 1;
/// Raised when the bundled policy cannot be trusted.
class PolicyFormatException implements Exception {
const PolicyFormatException(this.message);
final String message;
@override
String toString() => 'PolicyFormatException: $message';
}
/// A nightly window that may wrap midnight (23:00 -> 05:00).
class CurfewWindow {
const CurfewWindow({required this.startMinutes, required this.endMinutes});
/// Parses an `{"start": "23:00", "end": "05:00"}` object.
factory CurfewWindow.fromJson(Map<String, Object?> json) {
return CurfewWindow(
startMinutes: _parseHhMm(json['start'], 'curfew.start'),
endMinutes: _parseHhMm(json['end'], 'curfew.end'),
);
}
/// Minutes since midnight.
final int startMinutes;
final int endMinutes;
/// Whether [minutesSinceMidnight] falls inside the window.
///
/// Wrapping windows are the normal case here, so "inside" means at or after
/// the start *or* before the end, not both.
bool contains(int minutesSinceMidnight) {
if (startMinutes <= endMinutes) {
return minutesSinceMidnight >= startMinutes &&
minutesSinceMidnight < endMinutes;
}
return minutesSinceMidnight >= startMinutes ||
minutesSinceMidnight < endMinutes;
}
}
/// The home anchor. Coordinates are null in a redacted policy.
class HomeLocation {
const HomeLocation({
required this.latitude,
required this.longitude,
required this.radiusM,
required this.hysteresisM,
});
/// Parses the `home` object.
factory HomeLocation.fromJson(Map<String, Object?> json) {
return HomeLocation(
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
radiusM: _requireNum(json['radius_m'], 'home.radius_m'),
hysteresisM: _requireNum(json['hysteresis_m'], 'home.hysteresis_m'),
);
}
final double? latitude;
final double? longitude;
final double radiusM;
final double hysteresisM;
/// Whether this policy carries real coordinates.
///
/// The committed asset is redacted, so the location gate cannot run against
/// it. Enforcement must check this rather than treating a missing coordinate
/// as (0, 0), which would put "home" in the Atlantic.
bool get hasCoordinates => latitude != null && longitude != null;
}

View File

@ -1,179 +0,0 @@
/// The status page body and its sub-widgets.
///
/// Split out of main.dart to keep it under the 250-line cap. A `part`
/// because Dart privates are library-scoped and these widgets are private.
part of 'main.dart';
class _StatusBody extends StatelessWidget {
const _StatusBody({
required this.status,
required this.focusPolicy,
required this.policyError,
required this.busy,
required this.onRelease,
required this.onRunNow,
required this.onSetHome,
required this.onLockVpn,
required this.hasHome,
this.log = const [],
this.onOpenLog,
});
final DevicePolicyStatus status;
final FocusPolicy? focusPolicy;
final String? policyError;
final bool busy;
final Future<void> Function() onRelease;
final Future<void> Function() onRunNow;
final Future<void> Function() onSetHome;
final Future<void> Function() onLockVpn;
/// Whether home coordinates exist; without them enforcement is permanent.
final bool hasHome;
/// Recorded passes, newest first.
final List<EnforcementRecord> log;
final VoidCallback? onOpenLog;
@override
Widget build(BuildContext context) {
final policy = focusPolicy;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// First, and largest: what the enforcer is doing right now and why.
// Everything below is provisioning detail that only matters once this
// question is answered.
EnforcementStatusCard(
record: log.isEmpty ? null : log.first,
onOpenLog: onOpenLog ?? () {},
),
const SizedBox(height: kGap),
_Row(label: 'Package', value: status.packageName),
_Row(label: 'Device owner', value: status.isDeviceOwner ? 'yes' : 'no'),
_Row(label: 'Admin active', value: status.isAdminActive ? 'yes' : 'no'),
_Row(label: 'Android SDK', value: '${status.sdkInt}'),
// "none (inert build)" was a leftover from when this app really did
// apply nothing. It has been enforcing for weeks, so the honest value
// is whether the last recorded pass hid anything.
_Row(
label: 'Enforcing',
value: switch (log.isEmpty ? null : log.first.hideCount) {
null => 'no pass recorded yet',
0 => 'nothing hidden',
final int n => '$n apps hidden',
},
),
const SizedBox(height: kGap),
const Divider(color: kMuted),
const SizedBox(height: kGap / 2),
if (policyError != null)
_Message(text: 'Policy: $policyError', color: kDanger)
else if (policy == null)
const _Message(text: 'Policy: loading...', color: kMuted)
else ...[
_Row(
label: 'Allowed apps',
value:
'${policy.allowedPackages.length}'
' (${policy.nightAllowedPackages.length} at night)',
),
_Row(
label: 'Curfew',
value: policy.curfew == null
? 'disabled'
: '${_hhmm(policy.curfew!.startMinutes)}'
'-${_hhmm(policy.curfew!.endMinutes)}',
),
_Row(
label: 'Home radius',
value: policy.home.hasCoordinates
? '${policy.home.radiusM.round()} m'
: '${policy.home.radiusM.round()} m (coords redacted)',
),
_Row(
label: 'Workout domains',
value: '${policy.workoutUnblockDomains.length}',
),
// The distinction that decides whether enforcement is geofenced or
// permanent, so it belongs on screen rather than only in logcat.
_Row(
label: 'Geofence',
value: hasHome ? 'active' : 'no home set - enforcing everywhere',
),
],
const SizedBox(height: kGap * 2),
OutlinedButton(
onPressed: busy ? null : onRunNow,
child: const Text('Run enforcement now'),
),
const SizedBox(height: kGap),
if (status.isDeviceOwner) ...[
OutlinedButton(
onPressed: busy ? null : onSetHome,
child: Text(
hasHome ? 'Update home to here' : 'Set home to current location',
),
),
const SizedBox(height: kGap),
OutlinedButton(
onPressed: busy ? null : onLockVpn,
child: const Text('Lock VPN configuration'),
),
const SizedBox(height: kGap),
],
if (status.isDeviceOwner)
FilledButton(
onPressed: busy ? null : onRelease,
style: FilledButton.styleFrom(backgroundColor: kDanger),
child: const Text('Release device owner'),
)
else
const _Message(
text: 'Not provisioned as device owner. Nothing is enforced.',
color: kMuted,
),
],
);
}
}
/// Formats minutes-since-midnight as HH:MM.
String _hhmm(int minutes) {
final h = (minutes ~/ 60).toString().padLeft(2, '0');
final m = (minutes % 60).toString().padLeft(2, '0');
return '$h:$m';
}
class _Row extends StatelessWidget {
const _Row({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: kGap / 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(color: kMuted)),
Text(value, style: const TextStyle(color: kText)),
],
),
);
}
}
class _Message extends StatelessWidget {
const _Message({required this.text, required this.color});
final String text;
final Color color;
@override
Widget build(BuildContext context) =>
Text(text, style: TextStyle(color: color));
}

View File

@ -1,128 +0,0 @@
/// The card's list, field and note sub-widgets.
///
/// Split out of enforcement_status_card.dart to keep it under the 250-line cap. A `part`
/// because Dart privates are library-scoped and these widgets are private.
part of 'enforcement_status_card.dart';
/// The hidden apps, each with the rule that hid it.
class _HiddenList extends StatelessWidget {
const _HiddenList({required this.hidden});
final List<HiddenPackage> hidden;
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text(
'Why these ${hidden.length} apps are hidden',
style: const TextStyle(color: kAccent, fontSize: 14),
),
children: [
for (final entry in hidden)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
entry.package,
style: const TextStyle(color: kText, fontSize: 12),
),
),
const SizedBox(width: kGap / 2),
Text(
entry.reason.explanation,
style: TextStyle(
color: entry.reason == HideReason.alwaysBlocked
? kDanger
: kMuted,
fontSize: 12,
),
),
],
),
),
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
// A Material rather than a plain decorated box: the hidden-apps
// ExpansionTile paints its ink on the nearest Material ancestor, and a
// DecoratedBox in between swallows the ripple.
@override
Widget build(BuildContext context) => Material(
color: kSurface,
borderRadius: BorderRadius.circular(8),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(kGap),
child: child,
),
);
}
class _Field extends StatelessWidget {
const _Field({required this.label, required this.value, this.danger = false});
final String label;
final String value;
final bool danger;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 150,
child: Text(
label,
style: const TextStyle(color: kMuted, fontSize: 13),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
color: danger ? kDanger : kText,
fontSize: 13,
),
),
),
],
),
);
}
class _Note extends StatelessWidget {
const _Note({required this.text, this.danger = false});
final String text;
final bool danger;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: 4, bottom: 6),
child: Text(
text,
style: TextStyle(
color: danger ? kDanger : kWarn,
fontSize: 12,
height: 1.3,
),
),
);
}

View File

@ -1,103 +0,0 @@
/// Confirmation dialogs and the body-selection helper for the StatusPage.
///
/// Split out of status_page_state.dart to keep it under the 250-line cap. A
/// `part` because these builders are library-private, same as the rest of
/// the StatusPage split.
part of 'main.dart';
/// Picks the StatusPage's body widget for the current load state.
Widget _statusPageBody({
required DevicePolicyStatus? status,
required String? error,
required FocusPolicy? focusPolicy,
required String? policyError,
required bool busy,
required Future<void> Function() onRelease,
required Future<void> Function() onRunNow,
required Future<void> Function() onSetHome,
required Future<void> Function() onLockVpn,
required bool hasHome,
required List<EnforcementRecord> log,
required VoidCallback onOpenLog,
}) {
if (error != null) {
return const _Message(text: 'Could not read state', color: kDanger);
}
if (status != null) {
return _StatusBody(
status: status,
focusPolicy: focusPolicy,
policyError: policyError,
busy: busy,
onRelease: onRelease,
onRunNow: onRunNow,
onSetHome: onSetHome,
onLockVpn: onLockVpn,
hasHome: hasHome,
log: log,
onOpenLog: onOpenLog,
);
}
return const Center(child: CircularProgressIndicator());
}
Widget _lockVpnDialog(BuildContext context) {
return AlertDialog(
backgroundColor: kSurface,
title: const Text('Lock VPN configuration?'),
content: const Text(
'The always-on VPN can no longer be turned off from Settings. '
'Releasing device owner lifts this.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Lock'),
),
],
);
}
Widget _releaseDialog(BuildContext context, {required bool hasAccounts}) {
return AlertDialog(
backgroundColor: kSurface,
title: const Text('Release device owner?'),
// Deliberately concrete, and honest in both directions. "Restored only
// by a factory reset" is true but abstract, and this dialog is read in
// the moment the block is most inconvenient -- the price has to be
// legible right then, not inferred. While no account exists the price
// really is low, and overstating it there would teach the user to
// ignore the warning that matters later.
content: Text(
hasAccounts
? 'Enforcement stops. YouTube comes back.\n\n'
'Accounts exist on this device, so device owner can NEVER '
'be set again without a factory reset. That means '
're-pairing mBank, Revolut and inFakt over SMS, '
're-activating mObywatel, signing in to every account '
'again, and losing Signal history.\n\n'
'This is the way out if something is broken. It is a bad '
'trade for wanting to watch a video.'
: 'Enforcement stops and YouTube comes back.\n\n'
'No account exists yet, so device owner can be set again '
'straight afterwards without a wipe. This is the moment to '
'test the release path, before signing in.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: kDanger),
child: const Text('Release'),
),
],
);
}

View File

@ -1,248 +0,0 @@
/// The StatusPage state: polling, refresh and the device-status loads.
///
/// Split out of main.dart to keep it under the 250-line cap. A `part`
/// because the class and its fields are library-private.
part of 'main.dart';
class _StatusPageState extends State<StatusPage> {
DevicePolicyStatus? _status;
FocusPolicy? _focusPolicy;
String? _policyError;
String? _error;
bool _busy = false;
bool _hasHome = false;
List<EnforcementRecord> _log = const [];
@override
void initState() {
super.initState();
unawaited(_refresh());
}
Future<void> _refresh() async {
setState(() => _busy = true);
try {
await Future.wait([_loadDeviceStatus(), _loadPolicy(), _loadLog()]);
} finally {
// In a finally because _busy gates the release-device-owner button. If
// any loader throws -- a malformed log record used to be enough -- the
// flag would stay true for the life of the process, and since _refresh
// runs from initState that means the escape hatch is dead on every
// launch. Diagnostics failing must never disable the way out.
if (mounted) setState(() => _busy = false);
}
}
/// Reads the durable record of past passes.
///
/// Failing to read it must not blank the rest of the screen: the log is
/// diagnostics, while the provisioning state above it is what the user needs
/// to reach the escape hatch.
Future<void> _loadLog() async {
try {
final lines = await widget.policy.readEnforcementLog();
if (!mounted) return;
setState(() => _log = EnforcementRecord.parseLines(lines));
} on PlatformException {
if (!mounted) return;
setState(() => _log = const []);
}
}
Future<void> _loadDeviceStatus() async {
try {
final status = await widget.policy.status();
final hasHome = await widget.policy.hasHomeLocation();
if (!mounted) return;
setState(() {
_status = status;
_hasHome = hasHome;
_error = null;
});
} on PlatformException catch (e) {
if (!mounted) return;
setState(
() => _error = e.message ?? 'Could not read device policy state',
);
}
}
Future<void> _loadPolicy() async {
try {
final policy = await widget.loadFocusPolicy();
if (!mounted) return;
setState(() {
_focusPolicy = policy;
_policyError = null;
});
} on PolicyFormatException catch (e) {
// A policy that cannot be parsed must be reported, never silently
// treated as an empty allowlist - that would block everything.
if (!mounted) return;
setState(() => _policyError = e.message);
} on Exception catch (e) {
if (!mounted) return;
setState(() => _policyError = '$e');
}
}
Future<void> _runNow() async {
setState(() => _busy = true);
final started = await widget.policy.runEnforcementNow();
if (started) await _awaitNewRecord();
if (!mounted) return;
setState(() => _busy = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
started ? 'Enforcement run finished' : 'Could not start enforcement',
),
),
);
}
/// Waits for the pass to append a record, then shows it.
///
/// `runEnforcementNow` only *starts* the service, and a pass now waits up to
/// [EnforcementRunner.ACQUIRE_TIMEOUT_MS] for a location fix, so re-reading
/// immediately returns the record from the PREVIOUS pass. That is not a
/// cosmetic lag: after re-anchoring home the screen kept showing the old
/// distance, which reads as "the button did nothing" at exactly the moment
/// the user is checking whether it worked.
///
/// Polls rather than waits for a callback because the pass runs in a
/// separate service process with no channel back to the UI.
Future<void> _awaitNewRecord() async {
final before = _log.isEmpty ? null : _log.first.timestamp;
for (var attempt = 0; attempt < _kRefreshAttempts; attempt++) {
await Future<void>.delayed(_kRefreshInterval);
if (!mounted) return;
await _loadLog();
if (!mounted) return;
final latest = _log.isEmpty ? null : _log.first.timestamp;
if (latest != null && latest != before) break;
}
// Provisioning rows (home set / hidden counts) move with the pass too.
await _loadDeviceStatus();
}
Future<void> _setHome() async {
setState(() => _busy = true);
final failure = await widget.policy.setHomeToCurrentLocation();
if (!mounted) return;
setState(() => _busy = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
failure ?? 'Home set. Enforcement now applies here, not everywhere.',
),
),
);
if (failure != null) {
await _refresh();
return;
}
// Re-evaluate rather than only re-reading. The stored record still
// describes the OLD home, so refreshing alone leaves the previous
// distance on screen -- measured: after re-anchoring home the card still
// read "AWAY, 766 m" until a pass ran, which looks like the button
// failed. A new home is exactly when the geofence answer changes.
setState(() => _busy = true);
if (await widget.policy.runEnforcementNow()) await _awaitNewRecord();
if (!mounted) return;
setState(() => _busy = false);
await _refresh();
}
Future<void> _lockVpn() async {
final confirmed = await showDialog<bool>(
context: context,
builder: _lockVpnDialog,
);
if (confirmed != true) return;
setState(() => _busy = true);
final ok = await widget.policy.setVpnConfigBlocked(blocked: true);
if (!mounted) return;
setState(() => _busy = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok ? 'VPN configuration locked' : 'Could not lock - see logcat',
),
),
);
await _refresh();
}
Future<void> _release() async {
final hasAccounts = _status?.hasAccounts ?? true;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => _releaseDialog(context, hasAccounts: hasAccounts),
);
if (confirmed != true) return;
setState(() => _busy = true);
final released = await widget.policy.releaseDeviceOwner();
if (!mounted) return;
setState(() => _busy = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
released ? 'Device owner released' : 'Release failed - see logcat',
),
),
);
await _refresh();
}
void _openLog() {
unawaited(
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => DebugLogPage(records: _log)),
),
);
}
@override
Widget build(BuildContext context) {
final body = _statusPageBody(
status: _status,
error: _error,
focusPolicy: _focusPolicy,
policyError: _policyError,
busy: _busy,
onRelease: _release,
onRunNow: _runNow,
onSetHome: _setHome,
onLockVpn: _lockVpn,
hasHome: _hasHome,
log: _log,
onOpenLog: _openLog,
);
return Scaffold(
appBar: AppBar(
backgroundColor: kField,
foregroundColor: kText,
title: const Text('Focus Owner'),
actions: [
IconButton(
onPressed: _busy ? null : _refresh,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
// Scrollable because the action list grew past a short screen: an
// unreachable "Release device owner" button is the one control that
// must never be clipped off the bottom.
body: SingleChildScrollView(
padding: const EdgeInsets.all(kGap),
child: body,
),
);
}
}

View File

@ -1,43 +0,0 @@
/// The app's palette, re-exported from the shared design system.
///
/// This file used to define its own colours — a cool blue accent (`#5B9DD9`)
/// on a `#1B1D21` field — while its doc comment claimed to match the other
/// `com.kuhy.*` apps. It did not: every other app is on the frozen gold
/// (`#B8862E`) palette. The same six constants were also duplicated verbatim
/// in `main.dart`, which the old comment said had been "lifted out" but never
/// deleted.
///
/// Both copies are gone. These names remain as aliases so the ~80 existing
/// call sites keep reading, but the values now come from `design_system` and
/// cannot drift again.
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Page background.
const Color kField = AppPalette.ink;
/// Raised surface (cards, sheets).
const Color kSurface = AppPalette.inkRaised1;
/// The single accent hue.
const Color kAccent = AppPalette.accent;
/// Primary text.
const Color kText = AppPalette.textOnDark;
/// Secondary/caption text, and the "AWAY" status.
const Color kMuted = AppPalette.mutedOnDark;
/// Failure states: a missing home location, an unknown location, an
/// enforcement pass that threw.
const Color kDanger = AppPalette.danger;
/// Used for states that are working as intended but restrictive — i.e. the
/// night curfew.
const Color kWarn = AppPalette.warning;
/// Base spacing unit. Call sites use `kGap`, `kGap / 2` and `kGap * 2`,
/// which land on the shared scale's `md`, `sm` and `xl`.
const double kGap = AppSpacing.md;

View File

@ -1,117 +0,0 @@
import 'dart:async';
/// Something that can report whether a workout is currently in progress.
///
/// The rooted system read StrongLifts' SQLite database directly, which an
/// unrooted app cannot do — another app's private storage is unreachable. The
/// replacement is several weaker signals OR'd together.
///
/// Every source must fail *closed*: an error or an unknown state reports "no
/// workout", so a broken source cannot silently unlock YouTube. A source that
/// always returns false is useless but harmless; one that always returns true
/// would quietly disable the exception it exists to gate.
abstract class WorkoutSignal {
/// Short name, for logging and for the UI to explain why YouTube is open.
String get name;
/// Whether this source currently reports a workout in progress.
Future<bool> isActive();
}
/// A workout the user declared, valid for a fixed window.
///
/// The only source that works unconditionally, so it is the fallback the
/// others are layered on top of. It is time-boxed rather than a plain toggle
/// because an untimed "workout mode" left on by accident is indistinguishable
/// from having no exception at all.
class ManualWorkoutSignal implements WorkoutSignal {
ManualWorkoutSignal({
required this.maxDuration,
DateTime Function()? clock,
}) : _clock = clock ?? DateTime.now;
/// How long a declared workout stays valid before it lapses.
final Duration maxDuration;
final DateTime Function() _clock;
DateTime? _startedAt;
@override
String get name => 'manual';
/// Declares a workout as starting now.
void start() => _startedAt = _clock();
/// Ends a declared workout immediately.
void stop() => _startedAt = null;
/// How much of the window remains, or null when no workout is declared.
Duration? get remaining {
final started = _startedAt;
if (started == null) return null;
final left = maxDuration - _clock().difference(started);
return left.isNegative ? null : left;
}
@override
Future<bool> isActive() async => remaining != null;
}
/// A source backed by an asynchronous lookup that may fail.
///
/// Wraps [probe] so that a thrown exception or a timeout reads as "no
/// workout" rather than propagating into the enforcement loop. Used for the
/// Health Connect path, where the query crosses a process boundary and can
/// fail for reasons that have nothing to do with whether the user is training.
class GuardedWorkoutSignal implements WorkoutSignal {
const GuardedWorkoutSignal({
required this.name,
required this.probe,
this.timeout = const Duration(seconds: 5),
});
@override
final String name;
/// The underlying lookup.
final Future<bool> Function() probe;
/// How long to wait before treating the lookup as failed.
final Duration timeout;
@override
Future<bool> isActive() async {
try {
return await probe().timeout(timeout);
} on Object {
// Deliberately broad: this sits in the enforcement loop, and no failure
// of a workout probe should be able to stop enforcement running.
return false;
}
}
}
/// Combines several sources, reporting active if any of them does.
class CombinedWorkoutSignal implements WorkoutSignal {
const CombinedWorkoutSignal(this.sources);
final List<WorkoutSignal> sources;
@override
String get name => 'combined';
/// The name of the first source reporting a workout, or null if none is.
///
/// Exposed so the UI can say *which* signal opened the exception; "YouTube
/// is available because Health Connect sees a session" is far easier to
/// trust, or to disbelieve, than an unexplained unlock.
Future<String?> activeSource() async {
for (final source in sources) {
if (await source.isActive()) return source.name;
}
return null;
}
@override
Future<bool> isActive() async => (await activeSource()) != null;
}

View File

@ -1,222 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
design_system:
dependency: "direct main"
description:
path: design_system
ref: "design_system-v0.1.0"
resolved-ref: e6c9af81e9ac3000d87d9168d3ef87ba2979258b
url: "https://github.com/kuhyx/utils"
source: git
version: "0.1.0"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.4.2"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"

View File

@ -1,100 +0,0 @@
name: focus_owner
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.12.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# The shared palette. This app had drifted entirely off it -- a cool blue
# accent while every other app is on the frozen gold -- so lib/theme.dart
# is now a thin alias layer over this package rather than its own values.
design_system:
git:
url: https://github.com/kuhyx/utils
ref: design_system-v0.1.0
path: design_system
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# Generated from phone_focus_mode/config.sh, which stays the source of truth:
# python3 -m python_pkg.focus_policy \
# --config phone_focus_mode/config.sh --redact-home \
# --output focus_owner/assets/policy.json
# The committed copy is redacted, so it carries no home coordinates.
assets:
- assets/policy.json
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

View File

@ -1,144 +0,0 @@
#!/bin/bash
# ============================================================================
# Push the home coordinates into focus_owner's private storage.
#
# Why this exists:
# The geofence needs HOME_LAT/HOME_LON, but those say where the user lives,
# so they are deliberately absent from the committed policy asset - it is
# generated with --redact-home. They live in phone_focus_mode/config_secrets.sh,
# which is gitignored, and are pushed to the device separately.
#
# The file lands in the app's private data directory, which is not
# world-readable and does not survive an uninstall. That is the right
# lifetime: reinstalling should require re-provisioning rather than silently
# inheriting a stale home.
#
# Usage:
# ./push_home_location.sh [--serial <id>] [--secrets <path>] [--check]
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly DEFAULT_SERIAL="23181JEGR08034"
readonly PACKAGE="com.kuhy.focus_owner"
readonly REMOTE_NAME="home_location.json"
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
readonly REPO_ROOT
SECRETS="$REPO_ROOT/phone_focus_mode/config_secrets.sh"
SERIAL="$DEFAULT_SERIAL"
CHECK_ONLY=0
log() { printf '[home-loc] %s\n' "$1"; }
die() {
printf '[home-loc] ERROR: %s\n' "$1" >&2
exit 1
}
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS]
--serial <id> adb serial (default: $DEFAULT_SERIAL)
--secrets <path> config_secrets.sh (default: $SECRETS)
--check Report whether coordinates are provisioned; change nothing
-h, --help Show this help
EOF
exit 0
}
adb_sh() { adb -s "$SERIAL" shell "$@" 2>/dev/null | tr -d '\r'; }
require_device() {
command -v adb >/dev/null 2>&1 || die "adb not found; install android-tools"
adb -s "$SERIAL" get-state >/dev/null 2>&1 ||
die "device $SERIAL not connected"
adb_sh "pm list packages $PACKAGE" | grep -q . ||
die "$PACKAGE is not installed on $SERIAL"
}
read_coordinate() {
# Echo one exported coordinate from the secrets file. Deliberately a grep
# rather than sourcing the file: it holds other secrets, and this script
# has no business evaluating them.
local name="$1" raw
raw="$(grep -oP "(?<=^export ${name}=)[^ #]*" "$SECRETS" | tail -1 | tr -d '"'"'"'')"
[[ -n "$raw" ]] || die "$name not found in $SECRETS"
# The shipped secrets file contains REDACTED_* placeholders. Catching that
# here gives a useful message instead of writing a nonsense coordinate that
# would silently put "home" wherever the parse happened to land.
[[ "$raw" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] ||
die "$name is '$raw', not a coordinate - fill in $SECRETS"
printf '%s' "$raw"
}
run_check() {
local out
out="$(adb_sh "run-as $PACKAGE cat files/$REMOTE_NAME")" || true
if [[ -z "$out" ]]; then
log "FAIL no coordinates provisioned"
return 1
fi
# Report presence and shape without echoing the coordinates themselves;
# this output ends up in terminal scrollback and session transcripts.
if grep -q '"latitude"' <<<"$out" && grep -q '"longitude"' <<<"$out"; then
log "OK coordinates provisioned (${#out} bytes)"
return 0
fi
log "FAIL provisioned file is malformed"
return 1
}
main() {
require_device
if [[ $CHECK_ONLY -eq 1 ]]; then
run_check
exit $?
fi
[[ -f "$SECRETS" ]] || die "secrets file not found: $SECRETS"
local lat lon tmp
lat="$(read_coordinate HOME_LAT)"
lon="$(read_coordinate HOME_LON)"
tmp="$(mktemp)"
# shellcheck disable=SC2064 # expand $tmp now, not at trap time
trap "rm -f '$tmp'" EXIT
printf '{"latitude": %s, "longitude": %s}\n' "$lat" "$lon" >"$tmp"
# Staged via /data/local/tmp because adb push cannot write into another
# app's private directory; run-as then moves it in as the app's own uid.
adb -s "$SERIAL" push "$tmp" "/data/local/tmp/$REMOTE_NAME" >/dev/null ||
die "adb push failed"
# chmod 600 because the default umask leaves it 666. The directory is
# already private, so this is belt-and-braces rather than the only barrier,
# but a world-writable file holding the geofence anchor is worth avoiding.
adb_sh "run-as $PACKAGE sh -c 'mkdir -p files && cat /data/local/tmp/$REMOTE_NAME > files/$REMOTE_NAME && chmod 600 files/$REMOTE_NAME'" ||
die "run-as failed - is this a debuggable build?"
adb_sh "rm -f /data/local/tmp/$REMOTE_NAME"
log "Coordinates written to $PACKAGE private storage"
run_check
}
while [[ $# -gt 0 ]]; do
case $1 in
--serial)
SERIAL="$2"
shift 2
;;
--secrets)
SECRETS="$2"
shift 2
;;
--check)
CHECK_ONLY=1
shift
;;
-h | --help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
main "$@"

View File

@ -1,193 +0,0 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/enforcement_record.dart';
Map<String, Object?> _doc({
String reason = 'AT_HOME',
Object? distanceM = 42.0,
Object? fixAgeMs = 45000,
Object? fixAccuracyM = 12.0,
Object? curfewActive = false,
Object? homeConfigured = true,
Object? failure,
}) =>
{
'v': 1,
'ts': 1786000000000,
'reason': reason,
'distance_m': distanceM,
'threshold_m': 180.0,
'inside_fence': true,
'home_configured': homeConfigured,
'fix': {
'age_ms': fixAgeMs,
'provider': 'gps',
'accuracy_m': fixAccuracyM,
'outcome': 'ACTIVE_OK',
},
'curfew_active': curfewActive,
'curfew_window': '23:00-05:00',
'counts': {
'to_hide': 12,
'to_show': 30,
'hid_delta': 1,
'restored_delta': 0,
},
'hidden': [
{'pkg': 'com.google.android.youtube', 'why': 'ALWAYS_BLOCKED'},
{'pkg': 'org.mozilla.fenix', 'why': 'NOT_IN_ALLOWLIST'},
],
'failure': failure,
};
void main() {
group('EnforcementRecord.fromJson', () {
test('parses a full record', () {
final record = EnforcementRecord.fromJson(_doc());
expect(record.reason, 'AT_HOME');
expect(record.distanceM, 42.0);
expect(record.thresholdM, 180.0);
expect(record.fixProvider, 'gps');
expect(record.hideCount, 12);
expect(record.showCount, 30);
expect(record.hidden.length, 2);
expect(record.hidden.first.reason, HideReason.alwaysBlocked);
});
test('tolerates every optional field being absent', () {
// An older build's record must render rather than crash the one screen
// that exists to explain what went wrong.
final record = EnforcementRecord.fromJson({'reason': 'AWAY'});
expect(record.reason, 'AWAY');
expect(record.distanceM, isNull);
expect(record.fixAgeMs, isNull);
expect(record.hidden, isEmpty);
expect(record.hideCount, 0);
});
test('an unknown hide reason degrades rather than throwing', () {
final record = EnforcementRecord.fromJson(
_doc()
..['hidden'] = [
{'pkg': 'com.x', 'why': 'SOMETHING_NEW'},
],
);
expect(record.hidden.single.reason, HideReason.unknown);
expect(record.hidden.single.reason.explanation, isNotEmpty);
});
});
group('parseLines', () {
test('skips a corrupt line instead of losing the history', () {
final lines = [
jsonEncode(_doc(reason: 'AWAY')),
'{half-written',
jsonEncode(_doc()),
];
final records = EnforcementRecord.parseLines(lines);
expect(records.length, 2);
expect(records.first.reason, 'AWAY');
});
test('an empty list parses to no records', () {
expect(EnforcementRecord.parseLines([]), isEmpty);
});
test('a wrong-typed field is skipped, not thrown', () {
// Valid JSON, wrong type. Catching only FormatException let this throw
// TypeError all the way out of _refresh(), which left the page stuck
// busy and DISABLED the release-device-owner button -- the one control
// that has to work when everything else has gone wrong.
final records = EnforcementRecord.parseLines([
'{"v":1,"ts":123,"reason":42}',
jsonEncode(_doc()),
]);
expect(records, hasLength(1));
expect(records.single.reason, 'AT_HOME');
});
test('a wrong-typed nested field is skipped too', () {
expect(
EnforcementRecord.parseLines(['{"ts":1,"reason":"AWAY","counts":7}']),
hasLength(1),
);
expect(
EnforcementRecord.parseLines(['{"ts":"not-a-number","reason":"AWAY"}']),
isEmpty,
);
});
});
group('explanations', () {
test('LOCATION_UNKNOWN says it is blocking as if at home', () {
// The whole point: this state used to be indistinguishable from AT_HOME.
final record = EnforcementRecord.fromJson(
_doc(reason: 'LOCATION_UNKNOWN', distanceM: null, fixAgeMs: null),
);
expect(record.locationUnknown, isTrue);
expect(record.explanation, contains('as if you were at home'));
expect(record.distanceLabel, 'unknown - no location fix');
});
test('a missing home outranks the reason', () {
// With no coordinates the fence reports "inside" and the reason reads
// AT_HOME, which would otherwise claim the phone is home anywhere.
final record = EnforcementRecord.fromJson(_doc(homeConfigured: false));
expect(record.homeMissing, isTrue);
expect(record.explanation, contains('No home location is set'));
expect(record.distanceLabel, 'no home set');
});
test('a failed pass explains itself', () {
final record = EnforcementRecord.fromJson(
_doc(failure: 'policy unreadable - no decision made'),
);
expect(record.explanation, contains('policy unreadable'));
});
test('AWAY notes that always-blocked apps stay hidden', () {
final record = EnforcementRecord.fromJson(_doc(reason: 'AWAY'));
expect(record.explanation, contains('always-blocked'));
});
});
group('formatting', () {
test('distances over a kilometre render as km', () {
final record = EnforcementRecord.fromJson(_doc(distanceM: 10400.0));
expect(record.distanceLabel, '10.4 km (fence 180 m)');
});
test('short distances render as metres', () {
expect(
EnforcementRecord.fromJson(_doc(distanceM: 142.0)).distanceLabel,
'142 m (fence 180 m)',
);
});
test('the fix line carries age, provider, accuracy and outcome', () {
final record = EnforcementRecord.fromJson(_doc());
expect(record.fixLabel, '45 s old · gps · ±12 m · active_ok');
});
test('an older fix renders in minutes', () {
final record = EnforcementRecord.fromJson(_doc(fixAgeMs: 1500000));
expect(record.fixLabel, startsWith('25 min old'));
});
test('no fix falls back to the outcome', () {
final record = EnforcementRecord.fromJson(_doc(fixAgeMs: null));
expect(record.fixLabel, 'active_ok');
});
test('a very vague fix is flagged as probably not precise', () {
// Coarse location is fuzzed to a ~1-2 km grid, which is the tell that
// FINE is not actually in effect against a 150 m fence.
expect(
EnforcementRecord.fromJson(_doc(fixAccuracyM: 1400.0)).fixLooksFuzzed,
isTrue,
);
expect(EnforcementRecord.fromJson(_doc()).fixLooksFuzzed, isFalse);
});
});
}

View File

@ -1,211 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/debug_log_page.dart';
import 'package:focus_owner/enforcement_record.dart';
import 'package:focus_owner/enforcement_status_card.dart';
EnforcementRecord _record({
String reason = 'AT_HOME',
Object? distanceM = 42.0,
Object? fixAccuracyM = 12.0,
Object? curfewActive = false,
Object? homeConfigured = true,
Object? failure,
int hidDelta = 0,
int tsOffsetMinutes = 0,
List<Object?> hidden = const [
{'pkg': 'com.google.android.youtube', 'why': 'ALWAYS_BLOCKED'},
],
}) =>
EnforcementRecord.fromJson({
'ts': DateTime.now()
.subtract(Duration(minutes: tsOffsetMinutes))
.millisecondsSinceEpoch,
'reason': reason,
'distance_m': distanceM,
'threshold_m': 180.0,
'inside_fence': true,
'home_configured': homeConfigured,
'fix': {
'age_ms': 45000,
'provider': 'gps',
'accuracy_m': fixAccuracyM,
'outcome': 'ACTIVE_OK',
},
'curfew_active': curfewActive,
'curfew_window': '23:00-05:00',
'counts': {
'to_hide': 3,
'to_show': 58,
'hid_delta': hidDelta,
'restored_delta': 0,
},
'hidden': hidden,
'failure': failure,
});
Future<void> _pump(WidgetTester tester, EnforcementRecord? record) =>
tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: EnforcementStatusCard(record: record, onOpenLog: () {}),
),
),
),
);
void main() {
group('EnforcementStatusCard', () {
testWidgets('says so when no pass has run yet', (tester) async {
await _pump(tester, null);
expect(find.textContaining('No enforcement pass recorded'), findsOneWidget);
});
testWidgets('shows the reason, distance and curfew state', (tester) async {
await _pump(tester, _record());
expect(find.text('AT HOME'), findsOneWidget);
expect(find.text('42 m (fence 180 m)'), findsOneWidget);
expect(find.text('inactive (23:00-05:00)'), findsOneWidget);
expect(find.text('3 hidden, 58 available'), findsOneWidget);
});
testWidgets('LOCATION_UNKNOWN is distinguishable from AT_HOME',
(tester) async {
// The core of the bug being fixed: these two apply the identical sweep,
// so without this the phone gives no way to tell them apart.
await _pump(tester, _record(reason: 'LOCATION_UNKNOWN', distanceM: null));
expect(find.text('LOCATION UNKNOWN'), findsOneWidget);
expect(find.textContaining('as if you were at home'), findsOneWidget);
expect(find.text('unknown - no location fix'), findsOneWidget);
});
testWidgets('a missing home is called out rather than shown as AT_HOME',
(tester) async {
await _pump(tester, _record(homeConfigured: false));
expect(find.text('NO HOME SET'), findsOneWidget);
expect(find.text('no home set'), findsOneWidget);
});
testWidgets('a failed pass reports the failure', (tester) async {
await _pump(tester, _record(failure: 'policy unreadable'));
expect(find.text('NO DECISION'), findsOneWidget);
expect(find.textContaining('policy unreadable'), findsOneWidget);
});
testWidgets('an implausibly vague fix is flagged', (tester) async {
await _pump(tester, _record(fixAccuracyM: 1400.0));
expect(find.textContaining('far wider than the fence'), findsOneWidget);
});
testWidgets('curfew renders as active when it is', (tester) async {
await _pump(tester, _record(reason: 'CURFEW', curfewActive: true));
expect(find.text('active (23:00-05:00)'), findsOneWidget);
});
testWidgets('a stalled schedule is called out', (tester) async {
await _pump(tester, _record(tsOffsetMinutes: 90));
expect(find.textContaining('schedule may have stalled'), findsOneWidget);
});
testWidgets('changes made this pass are shown when non-zero',
(tester) async {
await _pump(tester, _record(hidDelta: 2));
expect(find.text('hid 2, restored 0'), findsOneWidget);
});
testWidgets('each hidden app can be expanded to show why', (tester) async {
await _pump(tester, _record());
await tester.tap(find.textContaining('Why these 1 apps are hidden'));
await tester.pumpAndSettle();
expect(find.text('com.google.android.youtube'), findsOneWidget);
expect(find.text('always blocked, everywhere'), findsOneWidget);
});
testWidgets('no hidden apps means no expander', (tester) async {
await _pump(tester, _record(hidden: const []));
expect(find.textContaining('Why these'), findsNothing);
});
testWidgets('never renders anything that looks like a coordinate',
(tester) async {
// The record carries no latitude/longitude by design; this pins that the
// screen cannot start showing one.
await _pump(tester, _record());
final texts = tester
.widgetList<Text>(find.byType(Text))
.map((t) => t.data ?? '')
.join(' ');
expect(RegExp(r'\d{1,3}\.\d{4,}').hasMatch(texts), isFalse);
});
testWidgets('the log button is reachable', (tester) async {
var opened = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: EnforcementStatusCard(
record: _record(),
onOpenLog: () => opened = true,
),
),
),
),
);
await tester.tap(find.text('Debug log'));
expect(opened, isTrue);
});
});
group('DebugLogPage', () {
testWidgets('explains itself when empty', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: DebugLogPage(records: [])),
);
expect(find.textContaining('No passes recorded yet'), findsOneWidget);
});
testWidgets('lists passes newest first and expands one', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: DebugLogPage(
records: [
_record(reason: 'AWAY', distanceM: 10400.0),
_record(),
],
),
),
);
expect(find.text('AWAY'), findsOneWidget);
expect(find.text('10.4 km (fence 180 m)'), findsOneWidget);
await tester.tap(find.text('AWAY'));
await tester.pumpAndSettle();
expect(find.text('Fix'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
});
testWidgets('a failed pass is marked in the list', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: DebugLogPage(records: [_record(failure: 'policy unreadable')]),
),
);
expect(find.text('NO DECISION'), findsOneWidget);
await tester.tap(find.text('NO DECISION'));
await tester.pumpAndSettle();
expect(find.text('Failure'), findsOneWidget);
});
testWidgets('copying a record does not throw', (tester) async {
await tester.pumpWidget(
MaterialApp(home: DebugLogPage(records: [_record()])),
);
await tester.tap(find.text('AT HOME'));
await tester.pumpAndSettle();
await tester.tap(find.text('Copy'));
await tester.pumpAndSettle();
});
});
}

View File

@ -1,87 +0,0 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/home_location.dart';
void main() {
late Directory dir;
late HomeLocationStore store;
setUp(() {
dir = Directory.systemTemp.createTempSync('home_loc_test');
store = HomeLocationStore(File('${dir.path}/${HomeLocationStore.fileName}'));
});
tearDown(() => dir.deleteSync(recursive: true));
group('read', () {
test('returns null when nothing is provisioned', () async {
expect(await store.read(), isNull);
});
test('round-trips written coordinates', () async {
await store.write(52.2297, 21.0122);
final read = await store.read();
expect(read?.latitude, closeTo(52.2297, 1e-9));
expect(read?.longitude, closeTo(21.0122, 1e-9));
});
test('returns null on malformed JSON rather than throwing', () async {
// A corrupt file must read as "location unknown", which the enforcement
// layer treats as fail-closed. Throwing here would crash the service.
await store.file.writeAsString('not json at all');
expect(await store.read(), isNull);
});
test('returns null when the document is not an object', () async {
await store.file.writeAsString('[1, 2, 3]');
expect(await store.read(), isNull);
});
test('returns null when a coordinate is missing', () async {
await store.file.writeAsString('{"latitude": 52.2}');
expect(await store.read(), isNull);
});
test('returns null when a coordinate is not a number', () async {
// This is the shape the shipped REDACTED_* placeholder would produce if
// it ever reached the device.
await store.file
.writeAsString('{"latitude": "REDACTED_LAT", "longitude": 21.0}');
expect(await store.read(), isNull);
});
test('returns null for out-of-range coordinates', () async {
await store.file.writeAsString('{"latitude": 91.0, "longitude": 21.0}');
expect(await store.read(), isNull);
});
});
group('write', () {
test('rejects an impossible latitude', () {
expect(() => store.write(91, 0), throwsArgumentError);
});
test('rejects an impossible longitude', () {
expect(() => store.write(0, 181), throwsArgumentError);
});
test('accepts the poles and the antimeridian', () async {
await store.write(90, 180);
expect((await store.read())?.latitude, 90);
});
});
group('clear', () {
test('removes provisioned coordinates', () async {
await store.write(52.2297, 21.0122);
await store.clear();
expect(await store.read(), isNull);
});
test('is a no-op when nothing is stored', () async {
await store.clear();
expect(await store.read(), isNull);
});
});
}

View File

@ -1,184 +0,0 @@
// Allow/deny decision cases.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/policy.dart';
Map<String, Object?> _doc({
Object? schemaVersion = kSupportedSchemaVersion,
Object? curfew = const {'start': '23:00', 'end': '05:00'},
double? latitude,
double? longitude,
}) =>
{
'schema_version': schemaVersion,
'home': {
'latitude': latitude,
'longitude': longitude,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': curfew,
'launcher_package': 'com.qqlabs.minimalistlauncher',
'allowed_packages': ['com.good', 'pl.mbank'],
'night_allowed_packages': ['pl.mbank'],
'never_disable_prefixes': ['com.android.providers'],
'workout_unblock_domains': ['youtube.com'],
'browser_packages': <String>[],
};
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('allow decisions', () {
final policy = FocusPolicy.fromJson(_doc());
test('protects system prefixes on whole labels only', () {
expect(policy.isProtected('com.android.providers'), isTrue);
expect(policy.isProtected('com.android.providers.telephony'), isTrue);
expect(policy.isProtected('com.android.providersomething'), isFalse);
});
test('protected packages are allowed even during curfew', () {
expect(
policy.isAllowed('com.android.providers', duringCurfew: true),
isTrue,
);
});
test('curfew narrows the allowlist', () {
expect(policy.isAllowed('com.good'), isTrue);
expect(policy.isAllowed('com.good', duringCurfew: true), isFalse);
expect(policy.isAllowed('pl.mbank', duringCurfew: true), isTrue);
});
test('unknown packages are blocked', () {
expect(policy.isAllowed('com.evil'), isFalse);
});
test('the launcher is allowed even when absent from the lists', () {
// Hiding it leaves no home screen. FocusPolicy.kt has always had this
// check; Dart had drifted without it.
expect(policy.allowedPackages, isNot(contains(policy.launcherPackage)));
expect(policy.isAllowed('com.qqlabs.minimalistlauncher'), isTrue);
expect(
policy.isAllowed('com.qqlabs.minimalistlauncher', duringCurfew: true),
isTrue,
);
});
test('an asset without prefix keys parses as empty', () {
// Back-compat: a policy rendered before prefixes existed must still
// parse, degrading to exact matching rather than failing entirely.
expect(policy.allowedPrefixes, isEmpty);
expect(policy.nightAllowedPrefixes, isEmpty);
});
test('allowed prefixes cover a family of packages', () {
final prefixed = FocusPolicy.fromJson(
_doc()
..['allowed_prefixes'] = ['eu.kanade.tachiyomi']
..['night_allowed_prefixes'] = ['eu.kanade.tachiyomi'],
);
expect(prefixed.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
expect(
prefixed.isAllowed('eu.kanade.tachiyomi.extension.all.mangadex'),
isTrue,
);
// Whole labels only, so an unrelated package is not swept in.
expect(prefixed.isAllowed('eu.kanade.tachiyomisomething'), isFalse);
// Chosen 2026-08-14: manga stays available during the curfew.
expect(
prefixed.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
isTrue,
);
});
test('a day-only prefix is blocked during curfew', () {
final dayOnly = FocusPolicy.fromJson(
_doc()..['allowed_prefixes'] = ['eu.kanade.tachiyomi'],
);
expect(dayOnly.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
expect(
dayOnly.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
isFalse,
);
});
test('a prefix list of the wrong type is still an error', () {
// Absent is fine; present-but-wrong is a real mistake.
expect(
() => FocusPolicy.fromJson(_doc()..['allowed_prefixes'] = 'nope'),
throwsA(isA<PolicyFormatException>()),
);
});
test('isCurfewActive follows the window', () {
expect(policy.isCurfewActive(23 * 60 + 30), isTrue);
expect(policy.isCurfewActive(12 * 60), isFalse);
});
});
group('FocusPolicy.load', () {
test('reads and parses the bundled asset', () async {
final bundle = _StubBundle(jsonEncode(_doc()));
final policy = await FocusPolicy.load(bundle: bundle);
expect(policy.allowedPackages, contains('pl.mbank'));
});
test('rejects an asset that is not a JSON object', () async {
final bundle = _StubBundle('[]');
await expectLater(
FocusPolicy.load(bundle: bundle),
throwsA(isA<PolicyFormatException>()),
);
});
});
test('the real committed asset parses and is redacted', () async {
// Guards the generator: a policy.json that this app cannot read, or that
// leaks the home coordinates into git, is a build error.
final policy = await FocusPolicy.load(bundle: _RealAssetBundle());
// Guards against an empty or truncated render, not against a specific
// size. The threshold was 50 when the allowlist still described the rooted
// Blackview; the 2026-08-11 rewrite to an explicit list for this phone cut
// it to ~26, so a high bound would only pin the old policy in place.
expect(policy.allowedPackages.length, greaterThan(10));
// The launcher must survive enforcement or the device has no home screen.
expect(policy.launcherPackage, isNotNull);
expect(policy.allowedPackages, contains(policy.launcherPackage));
expect(policy.home.hasCoordinates, isFalse,
reason: 'committed policy.json must not carry home coordinates');
expect(policy.curfew, isNotNull);
});
}
class _StubBundle extends CachingAssetBundle {
_StubBundle(this.contents);
final String contents;
@override
Future<ByteData> load(String key) async =>
ByteData.sublistView(utf8.encode(contents));
@override
Future<String> loadString(String key, {bool cache = true}) async => contents;
}
/// Reads the asset straight off disk, bypassing the (empty) test rootBundle.
class _RealAssetBundle extends CachingAssetBundle {
@override
Future<ByteData> load(String key) async {
throw UnimplementedError(key);
}
@override
Future<String> loadString(String key, {bool cache = true}) async {
final file = File('assets/policy.json');
return file.readAsString();
}
}

View File

@ -1,101 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/policy.dart';
Map<String, Object?> _doc({
Object? schemaVersion = kSupportedSchemaVersion,
Object? curfew = const {'start': '23:00', 'end': '05:00'},
double? latitude,
double? longitude,
}) =>
{
'schema_version': schemaVersion,
'home': {
'latitude': latitude,
'longitude': longitude,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': curfew,
'launcher_package': 'com.qqlabs.minimalistlauncher',
'allowed_packages': ['com.good', 'pl.mbank'],
'night_allowed_packages': ['pl.mbank'],
'never_disable_prefixes': ['com.android.providers'],
'workout_unblock_domains': ['youtube.com'],
'browser_packages': <String>[],
};
void main() {
group('FocusPolicy.fromJson', () {
test('parses a rendered policy', () {
final policy = FocusPolicy.fromJson(_doc());
expect(policy.allowedPackages, {'com.good', 'pl.mbank'});
expect(policy.nightAllowedPackages, {'pl.mbank'});
expect(policy.launcherPackage, 'com.qqlabs.minimalistlauncher');
expect(policy.home.radiusM, 150.0);
});
test('rejects an unknown schema version', () {
// Accepting a newer schema would mean enforcing a misread policy.
expect(
() => FocusPolicy.fromJson(_doc(schemaVersion: 999)),
throwsA(isA<PolicyFormatException>()),
);
});
test('rejects a document with no home object', () {
final doc = _doc()..remove('home');
expect(
() => FocusPolicy.fromJson(doc),
throwsA(isA<PolicyFormatException>()),
);
});
test('rejects a malformed curfew time', () {
expect(
() => FocusPolicy.fromJson(_doc(curfew: {'start': '25:00', 'end': 'x'})),
throwsA(isA<PolicyFormatException>()),
);
});
test('accepts an absent curfew', () {
expect(FocusPolicy.fromJson(_doc(curfew: null)).curfew, isNull);
});
});
group('HomeLocation', () {
test('redacted coordinates are reported as absent', () {
// The committed asset is redacted; enforcement must not treat a missing
// coordinate as (0, 0), which would put "home" in the Atlantic.
final policy = FocusPolicy.fromJson(_doc());
expect(policy.home.hasCoordinates, isFalse);
expect(policy.home.radiusM, 150.0);
});
test('real coordinates are reported as present', () {
final policy =
FocusPolicy.fromJson(_doc(latitude: 52.2297, longitude: 21.0122));
expect(policy.home.hasCoordinates, isTrue);
});
});
group('CurfewWindow', () {
const window = CurfewWindow(startMinutes: 23 * 60, endMinutes: 5 * 60);
test('wraps midnight', () {
expect(window.contains(22 * 60 + 59), isFalse);
expect(window.contains(23 * 60), isTrue);
expect(window.contains(0), isTrue);
expect(window.contains(4 * 60 + 59), isTrue);
expect(window.contains(5 * 60), isFalse);
expect(window.contains(12 * 60), isFalse);
});
test('handles a same-day window', () {
const day = CurfewWindow(startMinutes: 9 * 60, endMinutes: 17 * 60);
expect(day.contains(8 * 60), isFalse);
expect(day.contains(9 * 60), isTrue);
expect(day.contains(17 * 60), isFalse);
});
});
}

View File

@ -1,204 +0,0 @@
// StatusPage release, enforcement and polling behaviour.
// StatusPage widget behaviour.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/device_policy.dart';
import 'package:focus_owner/main.dart';
import 'package:focus_owner/policy.dart';
/// Builds a channel whose handler answers from [responses], recording calls.
({MethodChannel channel, List<MethodCall> calls}) _fakeChannel(
Map<String, Object?> responses,
) {
const channel = MethodChannel('test/device_policy');
final calls = <MethodCall>[];
// hasHomeLocation is read on every status refresh, so it is defaulted here
// rather than restated in each widget test. An explicit stub still wins,
// which is what the geofence-state tests rely on.
final stubs = {'hasHomeLocation': false, ...responses};
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
if (!stubs.containsKey(call.method)) {
throw PlatformException(code: 'unimplemented', message: 'no stub');
}
return stubs[call.method];
});
return (channel: channel, calls: calls);
}
Map<String, Object?> _statusMap({bool isDeviceOwner = false}) => {
'packageName': 'com.kuhy.focus_owner',
'isDeviceOwner': isDeviceOwner,
'isAdminActive': isDeviceOwner,
'sdkInt': 36,
'restrictionsApplied': false,
};
/// Minimal policy so widget tests never touch the (empty) test rootBundle.
Future<FocusPolicy> _stubPolicy() async => FocusPolicy.fromJson({
'schema_version': kSupportedSchemaVersion,
'home': {
'latitude': null,
'longitude': null,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': {'start': '23:00', 'end': '05:00'},
'launcher_package': 'com.launcher',
'allowed_packages': ['com.good'],
'night_allowed_packages': <String>[],
'never_disable_prefixes': <String>[],
'workout_unblock_domains': ['youtube.com'],
'browser_packages': <String>[],
});
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('StatusPage actions', () {
testWidgets('confirming release calls the platform', (tester) async {
final fake = _fakeChannel({
'status': _statusMap(isDeviceOwner: true),
'releaseDeviceOwner': true,
});
await tester.pumpWidget(
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Release device owner'), 100);
await tester.tap(find.text('Release device owner'));
await tester.pumpAndSettle();
await tester.tap(find.text('Release'));
await tester.pumpAndSettle();
expect(
fake.calls.where((c) => c.method == 'releaseDeviceOwner'),
hasLength(1),
);
expect(find.text('Device owner released'), findsOneWidget);
});
testWidgets('running enforcement waits for the new record', (tester) async {
// The pass runs in a separate service process and now waits for a
// location fix, so re-reading immediately returns the PREVIOUS record.
// Measured: after re-anchoring home the card still showed the old
// distance, which reads as "the button did nothing".
var passRan = false;
const channel = MethodChannel('test/refresh');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
switch (call.method) {
case 'status':
return _statusMap(isDeviceOwner: true);
case 'hasHomeLocation':
return true;
case 'runEnforcementNow':
passRan = true;
return true;
case 'readEnforcementLog':
return [_logLine(passRan ? 'AT_HOME' : 'AWAY')];
default:
throw PlatformException(code: 'unimplemented');
}
});
await tester.pumpWidget(
MaterialApp(
home: StatusPage(
policy: DevicePolicy(channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
expect(find.text('AWAY'), findsOneWidget);
await tester.scrollUntilVisible(find.text('Run enforcement now'), 100);
await tester.tap(find.text('Run enforcement now'));
// Long enough for at least one poll interval to elapse.
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text('AT HOME'), findsOneWidget);
expect(find.text('AWAY'), findsNothing);
});
testWidgets('a stuck pass stops polling instead of hanging',
(tester) async {
// If no new record ever lands the UI must still settle, not spin.
const channel = MethodChannel('test/stuck');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
switch (call.method) {
case 'status':
return _statusMap(isDeviceOwner: true);
case 'hasHomeLocation':
return true;
case 'runEnforcementNow':
return true;
case 'readEnforcementLog':
return [_logLine('AWAY')];
default:
throw PlatformException(code: 'unimplemented');
}
});
await tester.pumpWidget(
MaterialApp(
home: StatusPage(
policy: DevicePolicy(channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Run enforcement now'), 100);
await tester.tap(find.text('Run enforcement now'));
// Past the polling budget (~30 s).
await tester.pump(const Duration(seconds: 40));
await tester.pumpAndSettle();
expect(find.text('AWAY'), findsOneWidget);
});
});
}
/// One JSON-lines record, as the platform channel returns them.
String _logLine(String reason) => jsonEncode({
'ts': DateTime.now().millisecondsSinceEpoch,
'reason': reason,
'distance_m': reason == 'AWAY' ? 766.0 : 0.0,
'threshold_m': 150.0,
'inside_fence': reason != 'AWAY',
'home_configured': true,
'fix': {
'age_ms': 5000,
'provider': 'gps',
'accuracy_m': 11.0,
'outcome': 'ACTIVE_OK',
},
'curfew_active': false,
'curfew_window': '23:00-05:00',
'counts': {
'to_hide': 3,
'to_show': 58,
'hid_delta': 0,
'restored_delta': 0,
},
'hidden': const <Object?>[],
'failure': null,
});

View File

@ -1,174 +0,0 @@
// StatusPage widget behaviour.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/device_policy.dart';
import 'package:focus_owner/main.dart';
import 'package:focus_owner/policy.dart';
/// Builds a channel whose handler answers from [responses], recording calls.
({MethodChannel channel, List<MethodCall> calls}) _fakeChannel(
Map<String, Object?> responses,
) {
const channel = MethodChannel('test/device_policy');
final calls = <MethodCall>[];
// hasHomeLocation is read on every status refresh, so it is defaulted here
// rather than restated in each widget test. An explicit stub still wins,
// which is what the geofence-state tests rely on.
final stubs = {'hasHomeLocation': false, ...responses};
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
if (!stubs.containsKey(call.method)) {
throw PlatformException(code: 'unimplemented', message: 'no stub');
}
return stubs[call.method];
});
return (channel: channel, calls: calls);
}
Map<String, Object?> _statusMap({bool isDeviceOwner = false}) => {
'packageName': 'com.kuhy.focus_owner',
'isDeviceOwner': isDeviceOwner,
'isAdminActive': isDeviceOwner,
'sdkInt': 36,
'restrictionsApplied': false,
};
/// Minimal policy so widget tests never touch the (empty) test rootBundle.
Future<FocusPolicy> _stubPolicy() async => FocusPolicy.fromJson({
'schema_version': kSupportedSchemaVersion,
'home': {
'latitude': null,
'longitude': null,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': {'start': '23:00', 'end': '05:00'},
'launcher_package': 'com.launcher',
'allowed_packages': ['com.good'],
'night_allowed_packages': <String>[],
'never_disable_prefixes': <String>[],
'workout_unblock_domains': ['youtube.com'],
'browser_packages': <String>[],
});
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('StatusPage', () {
testWidgets('hides the release button when not device owner',
(tester) async {
final fake = _fakeChannel({'status': _statusMap()});
await tester.pumpWidget(
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Release device owner'), findsNothing);
expect(find.textContaining('Nothing is enforced'), findsOneWidget);
});
testWidgets('shows the release button when device owner', (tester) async {
final fake = _fakeChannel({'status': _statusMap(isDeviceOwner: true)});
await tester.pumpWidget(
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Release device owner'), findsOneWidget);
// The old "none (inert build)" label was a leftover from before this app
// enforced anything, and it stayed on screen for weeks while YouTube was
// in fact being hidden. With no pass recorded the honest answer is that
// nothing is known yet, not that nothing is applied.
expect(find.text('no pass recorded yet'), findsOneWidget);
});
testWidgets('reports when no home is set, since that means everywhere',
(tester) async {
// Without coordinates every pass decides LOCATION_UNKNOWN and fails
// closed, so enforcement is permanent rather than geofenced. That is a
// large behavioural difference and must be visible on screen.
final fake = _fakeChannel({
'status': _statusMap(isDeviceOwner: true),
'hasHomeLocation': false,
});
await tester.pumpWidget(
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('enforcing everywhere'), findsOneWidget);
expect(find.text('Set home to current location'), findsOneWidget);
});
testWidgets('offers to update home once one is set', (tester) async {
final fake = _fakeChannel({
'status': _statusMap(isDeviceOwner: true),
'hasHomeLocation': true,
});
await tester.pumpWidget(
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
expect(find.text('active'), findsOneWidget);
expect(find.text('Update home to here'), findsOneWidget);
});
testWidgets('release is confirmed before it runs', (tester) async {
final fake = _fakeChannel({
'status': _statusMap(isDeviceOwner: true),
'releaseDeviceOwner': true,
});
await tester.pumpWidget(
MaterialApp(
home: StatusPage(
policy: DevicePolicy(fake.channel),
loadFocusPolicy: _stubPolicy,
),
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Release device owner'), 100);
await tester.tap(find.text('Release device owner'));
await tester.pumpAndSettle();
expect(find.text('Release device owner?'), findsOneWidget);
// Cancelling must not call through to the platform.
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(
fake.calls.where((c) => c.method == 'releaseDeviceOwner'),
isEmpty,
);
});
});
}
/// One JSON-lines record, as the platform channel returns them.

View File

@ -1,88 +0,0 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/device_policy.dart';
/// Builds a channel whose handler answers from [responses], recording calls.
({MethodChannel channel, List<MethodCall> calls}) _fakeChannel(
Map<String, Object?> responses,
) {
const channel = MethodChannel('test/device_policy');
final calls = <MethodCall>[];
// hasHomeLocation is read on every status refresh, so it is defaulted here
// rather than restated in each widget test. An explicit stub still wins,
// which is what the geofence-state tests rely on.
final stubs = {'hasHomeLocation': false, ...responses};
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
if (!stubs.containsKey(call.method)) {
throw PlatformException(code: 'unimplemented', message: 'no stub');
}
return stubs[call.method];
});
return (channel: channel, calls: calls);
}
Map<String, Object?> _statusMap({bool isDeviceOwner = false}) => {
'packageName': 'com.kuhy.focus_owner',
'isDeviceOwner': isDeviceOwner,
'isAdminActive': isDeviceOwner,
'sdkInt': 36,
'restrictionsApplied': false,
};
void main() {
// Explicit since the split moved the testWidgets() group that used to
// initialise the binding implicitly.
TestWidgetsFlutterBinding.ensureInitialized();
group('DevicePolicyStatus', () {
test('parses the native status map', () {
final status = DevicePolicyStatus.fromMap(_statusMap(isDeviceOwner: true));
expect(status.packageName, 'com.kuhy.focus_owner');
expect(status.isDeviceOwner, isTrue);
expect(status.sdkInt, 36);
});
test('this build never reports restrictions as applied', () {
// Not a claim that nothing is enforced -- the app is live and hiding
// packages, and it does apply user restrictions once the VPN and DNS
// layers are locked. The native side simply never reads this back, so
// it is always false; the real state is the hidden-app count.
expect(
DevicePolicyStatus.fromMap(_statusMap()).restrictionsApplied,
isFalse,
);
});
});
group('DevicePolicy', () {
test('status() invokes the native status method', () async {
final fake = _fakeChannel({'status': _statusMap()});
final status = await DevicePolicy(fake.channel).status();
expect(fake.calls.single.method, 'status');
expect(status.isDeviceOwner, isFalse);
});
test('releaseDeviceOwner() forwards the native result', () async {
final fake = _fakeChannel({'releaseDeviceOwner': true});
expect(await DevicePolicy(fake.channel).releaseDeviceOwner(), isTrue);
expect(fake.calls.single.method, 'releaseDeviceOwner');
});
test('releaseDeviceOwner() reports failure rather than throwing', () async {
// The button that calls this is the last resort before a factory reset,
// so a failure must surface in the UI, never as an unhandled error.
final fake = _fakeChannel({'releaseDeviceOwner': false});
expect(await DevicePolicy(fake.channel).releaseDeviceOwner(), isFalse);
});
test('releaseDeviceOwner() treats a null reply as failure', () async {
final fake = _fakeChannel({'releaseDeviceOwner': null});
expect(await DevicePolicy(fake.channel).releaseDeviceOwner(), isFalse);
});
});
}

View File

@ -1,125 +0,0 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/workout_signal.dart';
/// A source with a fixed answer, for exercising the combinator.
class _Fixed implements WorkoutSignal {
const _Fixed(this.name, this.value);
@override
final String name;
final bool value;
@override
Future<bool> isActive() async => value;
}
void main() {
group('ManualWorkoutSignal', () {
late DateTime now;
late ManualWorkoutSignal signal;
setUp(() {
now = DateTime(2026, 8, 9, 12);
signal = ManualWorkoutSignal(
maxDuration: const Duration(minutes: 90),
clock: () => now,
);
});
test('is inactive until a workout is declared', () async {
expect(await signal.isActive(), isFalse);
expect(signal.remaining, isNull);
});
test('is active once started', () async {
signal.start();
expect(await signal.isActive(), isTrue);
expect(signal.remaining, const Duration(minutes: 90));
});
test('lapses when the window expires', () async {
// Time-boxed rather than a plain toggle: a "workout mode" left on by
// accident is indistinguishable from having no exception at all.
signal.start();
now = now.add(const Duration(minutes: 89));
expect(await signal.isActive(), isTrue);
now = now.add(const Duration(minutes: 2));
expect(await signal.isActive(), isFalse);
expect(signal.remaining, isNull);
});
test('stop ends it immediately', () async {
signal.start();
signal.stop();
expect(await signal.isActive(), isFalse);
});
test('restarting extends the window from the new start', () async {
signal.start();
now = now.add(const Duration(minutes: 80));
signal.start();
expect(signal.remaining, const Duration(minutes: 90));
});
});
group('GuardedWorkoutSignal', () {
test('passes through a successful probe', () async {
const signal = GuardedWorkoutSignal(name: 'hc', probe: _alwaysTrue);
expect(await signal.isActive(), isTrue);
});
test('a throwing probe reads as no workout', () async {
// Fail closed: a broken probe must not unlock YouTube.
const signal = GuardedWorkoutSignal(name: 'hc', probe: _alwaysThrows);
expect(await signal.isActive(), isFalse);
});
test('a hanging probe times out to no workout', () async {
const signal = GuardedWorkoutSignal(
name: 'hc',
probe: _neverCompletes,
timeout: Duration(milliseconds: 50),
);
expect(await signal.isActive(), isFalse);
});
});
group('CombinedWorkoutSignal', () {
test('is inactive when every source is', () async {
const combined = CombinedWorkoutSignal([
_Fixed('a', false),
_Fixed('b', false),
]);
expect(await combined.isActive(), isFalse);
expect(await combined.activeSource(), isNull);
});
test('is active when any source is', () async {
const combined = CombinedWorkoutSignal([
_Fixed('a', false),
_Fixed('b', true),
]);
expect(await combined.isActive(), isTrue);
});
test('names the source that opened the exception', () async {
// So the UI can explain the unlock rather than presenting it as magic.
const combined = CombinedWorkoutSignal([
_Fixed('manual', false),
_Fixed('health_connect', true),
]);
expect(await combined.activeSource(), 'health_connect');
});
test('with no sources it is inactive', () async {
const combined = CombinedWorkoutSignal([]);
expect(await combined.isActive(), isFalse);
});
});
}
Future<bool> _alwaysTrue() async => true;
Future<bool> _alwaysThrows() async => throw StateError('probe failed');
Future<bool> _neverCompletes() => Completer<bool>().future;

View File

@ -1,6 +0,0 @@
node_modules
dist
dist.staging
.deploy.lock
coverage
*.tsbuildinfo

View File

@ -1,162 +0,0 @@
# KCD2 Dice Solver
Pick which six dice and which badge to bring to a game of dice in
_Kingdom Come: Deliverance II_. Enter what you own; the solver evaluates every
loadout your inventory allows and reports the best one with the numbers behind
it.
**Live at <https://dice.kuhy.duckdns.org/>** — no terminal needed, and usable on
a phone. See [Deployment](#deployment) for how it gets there.
```bash
pnpm install
pnpm dev # http://localhost:5174 — for working on it
pnpm test # full suite
pnpm coverage # 100% statements / branches / functions / lines
pnpm lint # tsc --noEmit + eslint (strictTypeChecked)
pnpm build
```
## Entering an inventory
Three ways, all wired to the same state:
- **Click** a die's row to add one.
- **Scroll** the mouse wheel over its counter to step it up or down.
- **Search** by name — a fuzzy match, so `wei` finds _Weighted die_ and
`paintb` finds _Painter's die B_. The same box filters the badge list.
The inventory is saved to `localStorage`, so it survives a reload.
All 43 dice are on screen at once at every width, phone included — there is no
inner scroll box. The column count and the type size are picked together from a
container query on the inventory panel (not the viewport: at a 1000px viewport
the panel is ~918px wide and one pixel later it drops to ~472px). Two rules
govern the choice: the list must fit above the fold, and the name column must
stay wide enough to tell `Painter's die B`, `G` and `R` apart, which is the
tightest constraint in the layout.
**The left column holds the dice grid and nothing else.** The title, the search
box and the badges all live on the right, where horizontal room is going spare,
rather than spending the vertical budget the grid needs. Badges are collapsed by
default — open, they are the tallest thing on the page. Stacked on a phone the
order becomes title/search, then the dice, then the results.
Because the grid owns that column outright, it is sized to **fill** the height
rather than sit in a band at the top: the 22 rows divide up whatever the
viewport has left, so the row height, the type and the −/+ buttons all scale
together off one `--die-row` value. A 1296px-tall screen gets a 52px row, 20px
type and 44px buttons — the accessibility minimum this layout cannot afford at
phone width, where the same rows are 20px. The type stops growing at 20px while
the row keeps going to 56px: past that the names start truncating, whereas a
taller row is pure gain. The scaling is deliberately **not** applied to the
stacked layout, where the title and search box sit above the grid and the height
is not the grid's to take.
## Moving an inventory between devices
`Import / export`, below the badges, offers three routes — set your dice up once
and carry them anywhere:
- **Download `.json`** and load it back with the file picker.
- **Copy JSON** to the clipboard, paste it into the box on the other device.
(If the clipboard is unavailable — an insecure origin, say — the payload is
put in the box so it can still be copied by hand.)
- **Copy link**: the inventory is base64url-encoded into the URL fragment.
Opening that link elsewhere loads it. A realistic inventory is a ~150–260
character URL.
Everything imported goes through one validator (`src/lib/inventoryIo.ts`), which
**keeps what it can and tells you what it dropped** rather than rejecting a
whole payload over one bad entry — a die added in a future game patch, or a link
a chat client truncated, should not cost you the rest of your inventory.
A link never silently overwrites what is already saved here. It loads, shows a
banner, and is only written to `localStorage` once you press **Keep** or edit
something. The fragment is cleared on arrival so a later reload does not
resurrect it.
## Deployment
`linux_configuration/scripts/single_use/features/setup_kcd2_dice_solver.sh`
builds `dist/` and serves it read-only from a loopback-bound `caddy` container
on `127.0.0.1:8089`, fronted by the shared `gitea-caddy` edge. It is idempotent;
`setup_kcd2_dice_solver.sh status` self-diagnoses.
Committing to `main` with changes under `kcd2_dice_solver/` republishes the site
via a `post-commit` hook that starts `kcd2-dice-build.service`. Two details that
are load-bearing rather than incidental:
- `deploy_build.sh` builds into `dist.staging/` and `rsync --delete`s into
`dist/`. The container bind-mounts `dist/`, and a bind mount follows the
**inode** — replacing the directory would detach the mount silently, and a
plain `vite build` would empty it mid-request. The script asserts the inode is
unchanged.
- The build unit sets `MemorySwapMax=0`. This box has zram (swap held in RAM),
where a memory-capped cgroup without it thrashes instead of dying cleanly.
The rebuild builds the **working tree**, not the commit — an unrelated
uncommitted edit present at rebuild time will ship.
## How the recommendation is computed
**Scoring** (`src/core/scoring.ts`) is the wiki's table: 100 per one, 50 per
five, `1000` for three ones and `100 × face` otherwise, doubled for each die
beyond three, and 500/750/1500 for the three straights. A roll scores the best
_partition_ of its dice, which is a small memoised search.
**Expected value** (`src/core/evaluate.ts`) is exact, not sampled. Scoring
depends only on the multiset of faces, so the six dice are convolved into a
distribution over count vectors — at most 924 of them, against 6⁶ = 46,656
ordered outcomes. Those vectors stay packed as integers end to end, so the
scorer's memo is keyed on them directly. `evaluate.test.ts` checks the result
against a naive full enumeration that shares none of that machinery.
**The search** (`src/core/search.ts`) evaluates whole sets, because a Farkle
set's value is not the sum of its dice — ranking dice individually and taking
the top six is simply wrong. Dice with identical distributions are pooled
first (nine of the game's dice are plain uniform dice under different names).
Small inventories are enumerated exhaustively and reported as _provably
optimal_; larger ones use a multi-start steepest-ascent local search and are
reported as _not proven_.
**Turn value** (`src/core/simulate.ts`) is a Monte Carlo simulation, and unlike
the above it depends on how you play. The policy is stated explicitly in that
file and its knobs are exposed rather than buried: hold the subset maximising
`points + 60 × dice-left`, bank at 300.
**Badges** (`src/core/badgeValue.ts`) split in two. The five that change the
scoring table are valued on the exact EV delta after re-running the whole dice
search with the rule on — so equipping one can change which dice you should
bring. The rest are simulated one charge at a time, with each turn drawing from
its own seeded stream so the comparison is paired and the measured difference is
the badge rather than a diverged random sequence.
Nothing uses `Math.random`; every random source is a seeded `mulberry32`, so
every number here is reproducible.
## What the numbers are not
- **Balatro's die** publishes no face probabilities: the wiki's dice-effect row
for it is six unfilled `d` placeholders, and Inara does not list the die at
all. Exactly **one** of its faces is the joker (Jimbo's grinning face), so it
is modelled as a fair die whose 1 is replaced by that joker — the same face the
Devil's head die replaces, which is why holding it alone counts as a 1. Only
the uniform weights are an assumption; the joker's face is not.
- **The two joker faces score differently**, and the scorer keeps them apart. A
Devil's head "matches any combination but never scores on its own", so it can
never be the lone 1 or 5 that lets you hold a die; Balatro's joker can. Both
are assumed able to fill in for any face of a combination, including one made
entirely of jokers — the wiki's examples only ever pair a joker with natural
dice, so that part is an assumption.
- **The three "Advantage" formations** (Carpenter's Cut, Executioner's Gallows,
Priest's Eye) and the **Headstart** point leads have no published values. The
constants used are marked `UNVERIFIED` in `src/data/badges.ts`. Correct them
from the game's help screen if you have it open.
- **Badge point-per-game figures** are estimates for ranking badges against each
other, not a prediction of a scoreline.
- The wiki and [Inara](https://inara.cz/kingdom-come-2/items-dice/) **disagree**
on about six dice (Ci/Fer/Lu, Painted, Trinity, Holy Trinity, Devil's head).
The [fandom wiki](https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2)
is treated as canonical; the Inara figures sit in a comment beside each
conflicting entry.

View File

@ -1,131 +0,0 @@
#!/bin/bash
#
# deploy_build.sh — build the static bundle that dice.kuhy.duckdns.org serves.
#
# The single build path, used by both setup_kcd2_dice_solver.sh and the
# post-commit auto-rebuild, so the live site can never be produced two
# different ways.
#
# Why it does not just run `vite build`:
#
# The serving container bind-mounts dist/ at /srv. A bind mount follows the
# INODE, so anything that replaces the directory (rm -rf dist && mv new dist)
# silently detaches the mount until the container is restarted — the site then
# serves the old files forever with no error anywhere. And a plain `vite build`
# empties dist/ before writing, so the site 404s for the length of the build.
#
# Building into dist.staging/ and rsyncing into dist/ avoids both, but only
# with --delete-after --delay-updates: plain --delete removes stale files
# BEFORE writing the new ones, so a client that fetched index.html moments
# earlier can 404 on the very asset it is asking for.
#
# Preserving the inode is necessary but not sufficient, so the publish is
# followed by a check that the container is still mounted on this directory —
# see assert_mount_live for why that cannot be done by watching the inode here.
#
# Usage:
# deploy_build.sh Build and publish into dist/.
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
readonly STAGING="${SCRIPT_DIR}/dist.staging"
readonly LIVE="${SCRIPT_DIR}/dist"
# Must match setup_kcd2_dice_solver.sh's DICE_CONTAINER.
readonly CONTAINER="kcd2-dice"
die() {
echo "deploy_build: $1" >&2
exit 1
}
# nvm-installed toolchains are not on PATH for systemd/git-hook invocations.
ensure_toolchain() {
if [[ -s ${NVM_DIR:-$HOME/.nvm}/nvm.sh ]]; then
# shellcheck disable=SC1091 # sourced at runtime, path varies by machine
source "${NVM_DIR:-$HOME/.nvm}/nvm.sh"
fi
command -v node >/dev/null || die "node not found."
command -v pnpm >/dev/null || die "pnpm not found."
command -v rsync >/dev/null || die "rsync not found."
}
# Confirm the serving container is still mounted on THIS directory.
#
# Watching dist/'s own inode across the rsync cannot detect a detached mount and
# never could: rsync into an existing directory never changes it, and if dist/
# had already been removed (it is gitignored, so `git clean -xdff` removes it)
# the mkdir above recreates it at a fresh inode before anything is measured.
# Either way the check passes while the container serves the pre-deletion
# snapshot forever, with no error anywhere. The only thing that actually
# answers the question is what the CONTAINER sees.
assert_mount_live() {
local running host_inode container_inode
if ! command -v docker >/dev/null 2>&1; then
echo "deploy_build: docker not found — mount NOT verified." >&2
return 0
fi
running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)"
if ! grep -qx "$CONTAINER" <<<"$running"; then
echo "deploy_build: ${CONTAINER} not running — mount NOT verified." >&2
return 0
fi
host_inode="$(stat -c %i "$LIVE")"
# `docker exec` reports OCI runtime errors on STDOUT, so 2>/dev/null does not
# suppress them and a failure arrives as a long string rather than as empty
# output. Require an actual inode number instead of merely non-empty.
container_inode="$(docker exec "$CONTAINER" stat -c %i /srv 2>/dev/null || true)"
if [[ ! $container_inode =~ ^[0-9]+$ ]]; then
echo "deploy_build: could not read /srv inode from ${CONTAINER} — mount NOT verified." >&2
return 0
fi
if [[ $host_inode != "$container_inode" ]]; then
echo "deploy_build: dist/ was replaced — ${CONTAINER} is serving a detached copy." >&2
echo "deploy_build: restarting ${CONTAINER} to re-attach the bind mount…" >&2
docker restart "$CONTAINER" >/dev/null
container_inode="$(docker exec "$CONTAINER" stat -c %i /srv 2>/dev/null || true)"
[[ $host_inode == "$container_inode" ]] ||
die "${CONTAINER} still not mounted on ${LIVE} after a restart."
echo "deploy_build: ${CONTAINER} re-attached." >&2
fi
}
main() {
# Both entry points run this same script — setup_kcd2_dice_solver.sh calls it
# directly, and the post-commit hook starts a unit that runs it — so one lock
# closes the race between them. Without it, a second run's `vite --emptyOutDir`
# truncates dist.staging while the first run's rsync is still reading it:
# rsync exits 24, `set -e` stops the script before assert_mount_live, and dist/
# is left holding a fresh index.html that references assets which never
# arrived. Nothing downstream notices — `try_files` serves index.html for the
# missing chunks, so the browser is handed HTML where it expects JavaScript and
# no 404 reaches any log.
#
# Wait rather than fail: a rebuild that arrives during a setup run still needs
# to happen, and the unit has no start timeout.
exec 9>"${SCRIPT_DIR}/.deploy.lock"
flock -w 600 9 || die "another deploy_build is still running after 10 minutes."
ensure_toolchain
cd "$SCRIPT_DIR"
echo "Installing dependencies…"
pnpm install --frozen-lockfile
echo "Building into dist.staging…"
pnpm exec tsc -b
pnpm exec vite build --outDir dist.staging --emptyOutDir
# Fail before touching the live directory, never after.
[[ -f "${STAGING}/index.html" ]] || die "build produced no index.html."
mkdir -p "$LIVE"
rsync -a --delete-after --delay-updates "${STAGING}/" "${LIVE}/"
assert_mount_live
echo "Published $(find "$LIVE" -type f | wc -l) files to ${LIVE} (inode $(stat -c %i "$LIVE"))."
}
main "$@"

View File

@ -1,29 +0,0 @@
import js from '@eslint/js';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'coverage'] },
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
plugins: {
'react-hooks': reactHooksPlugin,
},
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
...reactHooksPlugin.configs.recommended.rules,
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
},
},
{
files: ['*.js', '*.mjs', '*.cjs'],
...tseslint.configs.disableTypeChecked,
},
);

View File

@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KCD2 Dice Solver</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,44 +0,0 @@
{
"name": "kcd2-dice-solver",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "tsc --noEmit && eslint src",
"preview": "vite preview",
"test": "vitest run",
"coverage": "vitest run --coverage"
},
"engines": {
"node": ">=22",
"pnpm": ">=10"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29.1.1",
"typescript": "~5.8.3",
"typescript-eslint": "^8.62.0",
"vite": "^6.3.5",
"vitest": "^4.1.9"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,65 +0,0 @@
#!/bin/bash
# ============================================================================
# Installs dependencies (Node via nvm, pnpm via corepack, then npm packages)
# and starts the KCD2 Dice Solver dev server.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly NODE_MIN_MAJOR=22
readonly NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
ensure_node() {
if command -v node >/dev/null 2>&1; then
local major
major="$(node --version | sed -E 's/^v([0-9]+).*/\1/')"
if (( major >= NODE_MIN_MAJOR )); then
return
fi
echo "Node $(node --version) is older than required v${NODE_MIN_MAJOR}; installing a newer version via nvm..."
else
echo "Node not found; installing via nvm..."
fi
if [[ ! -s "${NVM_DIR}/nvm.sh" ]]; then
echo "Error: nvm not found at ${NVM_DIR}/nvm.sh; install nvm first." >&2
exit 1
fi
# shellcheck source=/dev/null
source "${NVM_DIR}/nvm.sh"
nvm install --lts
nvm use --lts
}
ensure_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
return
fi
echo "pnpm not found; enabling via corepack..."
corepack enable
corepack prepare pnpm@latest --activate
}
main() {
# nvm.sh may not be sourced yet in a fresh shell, so load it before the
# version check even if `node` already resolves from a different source.
if [[ -s "${NVM_DIR}/nvm.sh" ]]; then
# shellcheck source=/dev/null
source "${NVM_DIR}/nvm.sh"
fi
ensure_node
ensure_pnpm
cd "$SCRIPT_DIR"
echo "Installing dependencies..."
pnpm install
echo "Starting dev server (http://localhost:5174)..."
exec pnpm dev
}
main "$@"

View File

@ -1,217 +0,0 @@
/**
* KCD2 Dice Solver.
*
* Enter what dice and badges you own; get the best six to bring and the best
* badge for each tier you hold. Owning the inventory — where it came from and
* when it is written back — is `useInventoryState`'s job; this file is the view.
*/
import { useMemo, useState } from "react";
import type { JSX } from "react";
import { BadgePicker } from "./components/BadgePicker.tsx";
import { DiceList } from "./components/DiceList.tsx";
import { InventoryIo } from "./components/InventoryIo.tsx";
import { ResultPanel } from "./components/ResultPanel.tsx";
import { SET_SIZE } from "./core/searchGroups.ts";
import type { SolveRequest } from "./core/solve.ts";
import { useInventoryState } from "./hooks/useInventoryState.ts";
import { useSolver } from "./hooks/useSolver.ts";
import type { SolverPort } from "./hooks/useSolver.ts";
import {
EMPTY,
browserClipboard,
browserSaveFile,
browserUrlHash,
encodeInventoryHash,
} from "./lib/inventoryIo.ts";
import type { ClipboardPort, SaveFilePort, UrlHashPort } from "./lib/inventoryIo.ts";
export interface AppProps {
/** Overridden in tests to avoid depending on jsdom worker support. */
readonly createPort?: () => SolverPort;
/** Overridden in tests to isolate persistence. */
readonly storage?: Pick<Storage, "getItem" | "setItem">;
/** Overridden in tests; jsdom has no navigator.clipboard. */
readonly clipboard?: ClipboardPort;
/** Overridden in tests; jsdom has no URL.createObjectURL. */
readonly saveFile?: SaveFilePort;
/** Overridden in tests to keep location/history changes from leaking. */
readonly urlHash?: UrlHashPort;
}
/**
* The whole application.
*
* @param props - Optional injection points for tests.
* @returns The app element.
*/
export function App({
createPort,
storage,
clipboard = browserClipboard,
saveFile = browserSaveFile,
urlHash = browserUrlHash,
}: AppProps = {}): JSX.Element {
const store = storage ?? window.localStorage;
const [query, setQuery] = useState("");
const {
saved,
stored,
dropped,
linkApplied,
persist,
total,
keep,
replace,
setDieCount,
toggleBadge,
} = useInventoryState(store, urlHash);
const request = useMemo<SolveRequest | null>(
() =>
total < SET_SIZE
? null
: { diceCounts: saved.diceCounts, badgeIds: saved.badgeIds },
[saved, total],
);
const { result, error, solving } = useSolver(request, createPort);
const ownedBadges = useMemo(() => new Set(saved.badgeIds), [saved.badgeIds]);
return (
<div className="app">
{/*
* The left column is the dice list and nothing else. Everything that is
* not "which dice do I own" — the title, the search box, the badges, the
* results — lives on the right, where there is horizontal room going
* spare, rather than eating the vertical budget the 43-row grid needs.
*/}
<div className="columns">
<section className="inventory">
<DiceList query={query} counts={saved.diceCounts} onChange={setDieCount} />
</section>
<div className="side">
<section className="controls">
<header>
<h1>KCD2 Dice Solver</h1>
<p className="subtitle">
Pick what you own; get the best six dice and a badge for each tier.
</p>
</header>
<div className="toolbar">
<input
className="search"
type="search"
placeholder="Search dice and badges…"
aria-label="Search dice and badges"
value={query}
onChange={(event) => {
setQuery(event.target.value);
}}
/>
<span className="count">
{total} {total === 1 ? "die" : "dice"}
</span>
<button
type="button"
className="clear"
disabled={total === 0 && saved.badgeIds.length === 0}
onClick={() => {
replace(EMPTY);
}}
>
Clear
</button>
</div>
{!persist && (
<p className="notice" role="status">
{linkApplied
? "Loaded from a shared link — not saved on this device yet."
: "Some saved entries could not be read — not saved over yet."}{" "}
<button
type="button"
className="clear"
onClick={keep}
>
Keep
</button>{" "}
{/* Without this, turning down a link means pressing Clear —
which writes an empty inventory over dice the visitor never
even saw, with no undo. */}
{linkApplied && (
<button
type="button"
className="clear"
onClick={() => {
replace(stored);
}}
>
Discard
</button>
)}
</p>
)}
{dropped.length > 0 && (
<p className="notice bad" role="status">
{`Ignored: ${dropped.join(" ")}`}
</p>
)}
</section>
<section className="results">
<ResultPanel result={result} error={error} solving={solving} />
{/* Collapsed by default: badges matter far less than the dice, and
open they cost more vertical space than everything else here. */}
<details className="badges">
<summary>Badges</summary>
<BadgePicker query={query} owned={ownedBadges} onToggle={toggleBadge} />
</details>
<InventoryIo
inventory={saved}
onImport={replace}
clipboard={clipboard}
saveFile={saveFile}
shareUrl={urlHash.base() + encodeInventoryHash(saved)}
/>
<footer className="caveats">
<h3>What these numbers are</h3>
<ul>
<li>
Expected score and bust chance are <strong>exact</strong>, computed over
every possible outcome of the six dice — not sampled.
</li>
<li>
Points per turn is a <strong>simulation</strong> of a push-your-luck turn
that banks at 300 points. Change how you play and it changes.
</li>
<li>
Badge values are <strong>estimates</strong> for ranking badges against each
other, not a prediction of a scoreline.
</li>
<li>
Balatro’s die has <strong>one</strong> joker face, not six, and the game
publishes <strong>no face probabilities</strong> for it. It is modelled as a
fair die whose 1 is replaced by the joker — the same shape as the Devil’s
head, but its joker also counts as a 1 when held alone.
</li>
<li>
The three “Advantage” formations and the Headstart leads have{" "}
<strong>no published point values</strong>; the figures used are marked
UNVERIFIED in the source.
</li>
</ul>
</footer>
</section>
</div>
</div>
</div>
);
}

View File

@ -1,249 +0,0 @@
/** Shared-link and layout behaviour of the App shell. */
/**
* End-to-end tests for the app, driving the real flow: search for a die, click
* to add it, scroll the wheel over a counter, and get a real recommendation.
*
* The worker is replaced with a port that runs the *real* solver synchronously —
* jsdom has no module-worker support, but nothing about the solver is stubbed,
* so these exercise the actual recommendation path.
*/
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { App } from "./App.tsx";
import { STORAGE_KEY, encodeInventoryHash, toBase64Url } from "./lib/inventoryIo.ts";
import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts";
import { solve } from "./core/solve.ts";
import type { SolverPort } from "./hooks/useSolver.ts";
import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts";
/**
* A solver port that runs the real solver in-process.
*
* @returns A port usable in place of the Web Worker.
*/
function createInlinePort(): SolverPort {
const listeners = new Set<(event: MessageEvent<WorkerReply>) => void>();
return {
postMessage: ({ id, request }: WorkerRequest) => {
let reply: WorkerReply;
try {
// Keep the simulation short: these tests are about the wiring.
reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) };
} catch (error) {
reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" };
}
for (const listener of listeners) {
listener({ data: reply } as MessageEvent<WorkerReply>);
}
},
addEventListener: (_type, listener) => listeners.add(listener),
removeEventListener: (_type, listener) => listeners.delete(listener),
};
}
/**
* An in-memory Storage stand-in, so tests do not share persisted state.
*
* @param initial - Optional starting contents.
* @returns A minimal storage object.
*/
function memoryStorage(initial?: string): Pick<Storage, "getItem" | "setItem"> {
const store = new Map<string, string>();
if (initial !== undefined) {
store.set(STORAGE_KEY, initial);
}
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
store.set(key, value);
},
};
}
/**
* Read back what the app persisted, typed so assertions are not on `any`.
*
* @param storage - The storage the app was given.
* @returns The parsed inventory.
*/
function readSaved(storage: Pick<Storage, "getItem">): SavedInventory {
return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory;
}
/**
* A URL port that reports a fragment without touching the real location.
*
* @param hash - The fragment the page was "opened with".
* @returns The port plus a record of whether the app cleared the fragment.
*/
function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } {
let clears = 0;
return {
read: () => hash,
clear: () => {
clears += 1;
},
base: () => "https://dice.example/",
cleared: () => clears,
};
}
const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) =>
render(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
describe("shared links", () => {
const shared = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] });
it("prefers a link over whatever this browser had saved", () => {
const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] });
renderApp(mine, stubUrl(shared));
expect(screen.getByText("3 dice")).toBeInTheDocument();
});
it("drops the fragment so a reload does not resurrect the link", () => {
const url = stubUrl(shared);
renderApp(undefined, url);
expect(url.cleared()).toBe(1);
});
it("leaves the URL alone when there was no link", () => {
const url = stubUrl();
renderApp(undefined, url);
expect(url.cleared()).toBe(0);
});
it("does not overwrite the saved inventory until the visitor commits", () => {
// Silently clobbering somebody's own loadout with a link they opened is
// unrecoverable, so nothing is written until they say so.
const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }));
render(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
expect(screen.getByText("3 dice")).toBeInTheDocument();
expect(readSaved(storage).diceCounts).toEqual({ lucky: 1 });
});
it("persists once Keep is pressed", async () => {
const storage = memoryStorage();
render(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
expect(readSaved(storage).diceCounts).toEqual({ weighted: 3 });
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
});
it("persists as soon as the visitor edits anything, banner and all", async () => {
const storage = memoryStorage();
render(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
await userEvent.click(screen.getByLabelText("Remove one Weighted die"));
expect(readSaved(storage).diceCounts).toEqual({ weighted: 2 });
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
});
it("shows no banner on an ordinary visit", () => {
renderApp();
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
});
it("falls through to storage when the fragment carries no inventory", () => {
const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] });
renderApp(mine, stubUrl("#nothing=here"));
expect(screen.getByText("1 die")).toBeInTheDocument();
});
it("keeps the saved inventory when the link is damaged", () => {
const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] });
// "@" is not a base64 character, so decoding throws rather than yielding
// an inventory — the case a truncated or mangled URL actually produces.
renderApp(mine, stubUrl("#i=@@@"));
expect(screen.getByText("1 die")).toBeInTheDocument();
// Nothing was taken from the link, so there is nothing to accept or refuse.
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Discard" })).not.toBeInTheDocument();
});
it("reports what a shared link had to drop", () => {
const link = `#i=${toBase64Url(
JSON.stringify({ diceCounts: { weighted: 2, nosuchdie: 1 }, badgeIds: [] }),
)}`;
renderApp(undefined, stubUrl(link));
expect(screen.getByText("2 dice")).toBeInTheDocument();
expect(screen.getByText(/Unknown die/)).toBeInTheDocument();
});
it("replaces the whole inventory on import", async () => {
const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 2 }, badgeIds: [] }));
render(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />,
);
fireEvent.change(screen.getByLabelText("Inventory JSON"), {
target: { value: '{"diceCounts":{"weighted":4},"badgeIds":[]}' },
});
await userEvent.click(screen.getByRole("button", { name: "Import pasted" }));
expect(screen.getByText("4 dice")).toBeInTheDocument();
expect(readSaved(storage).diceCounts).toEqual({ weighted: 4 });
});
});
describe("layout", () => {
it("gives the left column to the dice and nothing else", () => {
renderApp();
const inventory = document.querySelector("section.inventory");
if (!inventory) {
throw new Error("no inventory section");
}
// The search box, the title and the badges all moved to the right column so
// the 43-row grid gets the whole vertical budget.
expect(inventory.querySelector(".die-list")).toBeInTheDocument();
expect(inventory.querySelector(".toolbar")).toBeNull();
expect(inventory.querySelector("h1")).toBeNull();
expect(inventory.querySelector(".badges")).toBeNull();
});
it("keeps the badges collapsed until asked for", () => {
renderApp();
const badges = document.querySelector("details.badges");
expect(badges).toBeInTheDocument();
expect(badges).not.toHaveAttribute("open");
// Still reachable — collapsed, not removed.
expect(within(badges as HTMLElement).getByText("Badges")).toBeInTheDocument();
});
it("no longer tells touch users to scroll a mouse wheel", () => {
renderApp();
expect(screen.queryByText(/scroll the wheel/i)).not.toBeInTheDocument();
});
});

View File

@ -1,173 +0,0 @@
/** How the App recovers a shared link that would clobber saved state. */
/** Shared-link and layout behaviour of the App shell. */
/**
* End-to-end tests for the app, driving the real flow: search for a die, click
* to add it, scroll the wheel over a counter, and get a real recommendation.
*
* The worker is replaced with a port that runs the *real* solver synchronously —
* jsdom has no module-worker support, but nothing about the solver is stubbed,
* so these exercise the actual recommendation path.
*/
import { render, screen, } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { App } from "./App.tsx";
import { STORAGE_KEY, encodeInventoryHash, } from "./lib/inventoryIo.ts";
import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts";
import { solve } from "./core/solve.ts";
import type { SolverPort } from "./hooks/useSolver.ts";
import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts";
/**
* A solver port that runs the real solver in-process.
*
* @returns A port usable in place of the Web Worker.
*/
function createInlinePort(): SolverPort {
const listeners = new Set<(event: MessageEvent<WorkerReply>) => void>();
return {
postMessage: ({ id, request }: WorkerRequest) => {
let reply: WorkerReply;
try {
// Keep the simulation short: these tests are about the wiring.
reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) };
} catch (error) {
reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" };
}
for (const listener of listeners) {
listener({ data: reply } as MessageEvent<WorkerReply>);
}
},
addEventListener: (_type, listener) => listeners.add(listener),
removeEventListener: (_type, listener) => listeners.delete(listener),
};
}
/**
* An in-memory Storage stand-in, so tests do not share persisted state.
*
* @param initial - Optional starting contents.
* @returns A minimal storage object.
*/
function memoryStorage(initial?: string): Pick<Storage, "getItem" | "setItem"> {
const store = new Map<string, string>();
if (initial !== undefined) {
store.set(STORAGE_KEY, initial);
}
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
store.set(key, value);
},
};
}
/**
* Read back what the app persisted, typed so assertions are not on `any`.
*
* @param storage - The storage the app was given.
* @returns The parsed inventory.
*/
function readSaved(storage: Pick<Storage, "getItem">): SavedInventory {
return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory;
}
/**
* A URL port that reports a fragment without touching the real location.
*
* @param hash - The fragment the page was "opened with".
* @returns The port plus a record of whether the app cleared the fragment.
*/
function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } {
let clears = 0;
return {
read: () => hash,
clear: () => {
clears += 1;
},
base: () => "https://dice.example/",
cleared: () => clears,
};
}
const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) =>
render(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
describe("shared links cannot destroy what is already saved", () => {
it("ignores a link that carries no dice or badges", () => {
// A count of zero parses cleanly, so "did the parser complain?" is the
// wrong question — this used to blank the inventory on screen, disable
// Clear, and leave Keep as the only enabled control, writing the emptiness
// over the visitor's own dice.
const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: [] });
const empty = encodeInventoryHash({ diceCounts: { weighted: 0 }, badgeIds: [] });
const storage = memoryStorage(mine);
render(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl(empty)} />,
);
expect(screen.getByText("4 dice")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 });
});
it("restores the visitor's own inventory when a link is discarded", async () => {
const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: ["tin_might"] });
const link = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] });
const storage = memoryStorage(mine);
render(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl(link)} />,
);
expect(screen.getByText("3 dice")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Discard" }));
expect(screen.getByText("4 dice")).toBeInTheDocument();
expect(readSaved(storage)).toEqual({
diceCounts: { lucky: 4 },
badgeIds: ["tin_might"],
});
});
it("clears a fragment even when it decoded to nothing", () => {
// A fragment left in the bar is read again on the next reload, where it
// would land on top of whatever has been edited since.
for (const hash of ["#i=@@@", "#other=1", "#i="]) {
const url = stubUrl(hash);
renderApp(undefined, url);
expect(url.cleared()).toBe(1);
}
});
});
describe("a saved inventory that no longer parses cleanly", () => {
const stale = JSON.stringify({
diceCounts: { lucky: 4, future_patch_die: 6 },
badgeIds: [],
});
it("is not silently truncated in storage on load", () => {
const storage = memoryStorage(stale);
render(<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />);
// Still the original text: nothing was written over it on first paint.
expect(readSaved(storage).diceCounts).toEqual({ lucky: 4, future_patch_die: 6 });
});
it("says what it could not read, and only commits once accepted", async () => {
const storage = memoryStorage(stale);
render(<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />);
expect(screen.getByText(/Unknown die “future_patch_die”/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 });
});
});

View File

@ -1,227 +0,0 @@
/** The App shell's core behaviour. */
/**
* End-to-end tests for the app, driving the real flow: search for a die, click
* to add it, scroll the wheel over a counter, and get a real recommendation.
*
* The worker is replaced with a port that runs the *real* solver synchronously —
* jsdom has no module-worker support, but nothing about the solver is stubbed,
* so these exercise the actual recommendation path.
*/
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { App } from "./App.tsx";
import { STORAGE_KEY, } from "./lib/inventoryIo.ts";
import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts";
import { solve } from "./core/solve.ts";
import type { SolverPort } from "./hooks/useSolver.ts";
import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts";
/**
* A solver port that runs the real solver in-process.
*
* @returns A port usable in place of the Web Worker.
*/
function createInlinePort(): SolverPort {
const listeners = new Set<(event: MessageEvent<WorkerReply>) => void>();
return {
postMessage: ({ id, request }: WorkerRequest) => {
let reply: WorkerReply;
try {
// Keep the simulation short: these tests are about the wiring.
reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) };
} catch (error) {
reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" };
}
for (const listener of listeners) {
listener({ data: reply } as MessageEvent<WorkerReply>);
}
},
addEventListener: (_type, listener) => listeners.add(listener),
removeEventListener: (_type, listener) => listeners.delete(listener),
};
}
/**
* An in-memory Storage stand-in, so tests do not share persisted state.
*
* @param initial - Optional starting contents.
* @returns A minimal storage object.
*/
function memoryStorage(initial?: string): Pick<Storage, "getItem" | "setItem"> {
const store = new Map<string, string>();
if (initial !== undefined) {
store.set(STORAGE_KEY, initial);
}
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
store.set(key, value);
},
};
}
/**
* Read back what the app persisted, typed so assertions are not on `any`.
*
* @param storage - The storage the app was given.
* @returns The parsed inventory.
*/
function readSaved(storage: Pick<Storage, "getItem">): SavedInventory {
return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory;
}
/**
* A URL port that reports a fragment without touching the real location.
*
* @param hash - The fragment the page was "opened with".
* @returns The port plus a record of whether the app cleared the fragment.
*/
function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } {
let clears = 0;
return {
read: () => hash,
clear: () => {
clears += 1;
},
base: () => "https://dice.example/",
cleared: () => clears,
};
}
const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) =>
render(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
describe("App", () => {
it("asks for dice before anything is entered", () => {
renderApp();
expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument();
expect(screen.getByText("0 dice")).toBeInTheDocument();
});
it("finds a die by fuzzy search, adds six by clicking, and solves", async () => {
renderApp();
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
const row = screen.getAllByRole("listitem")[0];
const add = within(row).getByRole("button", { name: "Weighted die" });
for (let i = 0; i < 6; i += 1) {
await userEvent.click(add);
}
expect(screen.getByText("6 dice")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Best loadout")).toBeInTheDocument();
});
// Six Weighted dice is the right answer and the panel must name it.
expect(screen.getByText("6×")).toBeInTheDocument();
expect(screen.getByText("Weighted die")).toBeInTheDocument();
expect(screen.getByText(/Provably optimal/)).toBeInTheDocument();
});
it("adds dice by scrolling the wheel over the counter", async () => {
renderApp();
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
const counter = screen.getByLabelText("How many Weighted die");
const stepper = counter.parentElement;
if (!stepper) {
throw new Error("no stepper");
}
for (let i = 0; i < 6; i += 1) {
fireEvent.wheel(stepper, { deltaY: -120 });
}
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6");
expect(screen.getByText("6 dice")).toBeInTheDocument();
fireEvent.wheel(stepper, { deltaY: 120 });
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("5");
expect(screen.getByText("5 dice")).toBeInTheDocument();
});
it("accumulates a rapid burst of clicks and wheel ticks", async () => {
// Driving the real app in headless Chrome caught this: six fast clicks used
// to land as one, because each handler computed from the count captured at
// render time. React batches faster than it re-renders.
renderApp();
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
const row = screen.getAllByRole("listitem")[0];
const add = within(row).getByRole("button", { name: "Weighted die" });
act(() => {
for (let i = 0; i < 4; i += 1) {
fireEvent.click(add);
}
});
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4");
const stepper = screen.getByLabelText("How many Weighted die").parentElement;
if (!stepper) {
throw new Error("no stepper");
}
act(() => {
for (let i = 0; i < 2; i += 1) {
fireEvent.wheel(stepper, { deltaY: -120 });
}
});
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6");
expect(screen.getByText("6 dice")).toBeInTheDocument();
});
it("ranks the badges you own alongside the dice", async () => {
const saved = JSON.stringify({
diceCounts: { weighted: 6 },
badgeIds: ["gold_emperors", "gold_defence"],
});
renderApp(saved);
await waitFor(() => {
expect(screen.getByText("Best badge per tier")).toBeInTheDocument();
});
// The name also appears in the ownership checkbox, so scope to the result.
const results = document.querySelector(".result");
if (!results) {
throw new Error("no result panel");
}
expect(within(results as HTMLElement).getByText("Gold Emperor's badge")).toBeInTheDocument();
// The Defence badge is owned too, and must be reported as situational
// rather than given an invented number.
expect(within(results as HTMLElement).getByText(/situational/)).toBeInTheDocument();
});
it("restores a saved inventory on load", () => {
renderApp(JSON.stringify({ diceCounts: { weighted: 4 }, badgeIds: [] }));
expect(screen.getByText("4 dice")).toBeInTheDocument();
});
it("persists changes for next time", async () => {
const storage = memoryStorage();
render(<App createPort={createInlinePort} storage={storage} />);
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
await userEvent.click(screen.getByLabelText("Add one Weighted die"));
expect(readSaved(storage)).toEqual({ diceCounts: { weighted: 1 }, badgeIds: [] });
});
it("clears everything on request", async () => {
renderApp(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: ["tin_might"] }));
await waitFor(() => {
expect(screen.getByText("Best loadout")).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
expect(screen.getByText("0 dice")).toBeInTheDocument();
expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument();
});
});

View File

@ -1,180 +0,0 @@
/** How the App shell falls back to browser storage. */
/** The App shell's core behaviour. */
/**
* End-to-end tests for the app, driving the real flow: search for a die, click
* to add it, scroll the wheel over a counter, and get a real recommendation.
*
* The worker is replaced with a port that runs the *real* solver synchronously —
* jsdom has no module-worker support, but nothing about the solver is stubbed,
* so these exercise the actual recommendation path.
*/
import { render, screen, waitFor, } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { App } from "./App.tsx";
import { STORAGE_KEY, } from "./lib/inventoryIo.ts";
import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts";
import { solve } from "./core/solve.ts";
import type { SolverPort } from "./hooks/useSolver.ts";
import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts";
/**
* A solver port that runs the real solver in-process.
*
* @returns A port usable in place of the Web Worker.
*/
function createInlinePort(): SolverPort {
const listeners = new Set<(event: MessageEvent<WorkerReply>) => void>();
return {
postMessage: ({ id, request }: WorkerRequest) => {
let reply: WorkerReply;
try {
// Keep the simulation short: these tests are about the wiring.
reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) };
} catch (error) {
reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" };
}
for (const listener of listeners) {
listener({ data: reply } as MessageEvent<WorkerReply>);
}
},
addEventListener: (_type, listener) => listeners.add(listener),
removeEventListener: (_type, listener) => listeners.delete(listener),
};
}
/**
* An in-memory Storage stand-in, so tests do not share persisted state.
*
* @param initial - Optional starting contents.
* @returns A minimal storage object.
*/
function memoryStorage(initial?: string): Pick<Storage, "getItem" | "setItem"> {
const store = new Map<string, string>();
if (initial !== undefined) {
store.set(STORAGE_KEY, initial);
}
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
store.set(key, value);
},
};
}
/**
* Read back what the app persisted, typed so assertions are not on `any`.
*
* @param storage - The storage the app was given.
* @returns The parsed inventory.
*/
function readSaved(storage: Pick<Storage, "getItem">): SavedInventory {
return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory;
}
/**
* A URL port that reports a fragment without touching the real location.
*
* @param hash - The fragment the page was "opened with".
* @returns The port plus a record of whether the app cleared the fragment.
*/
function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } {
let clears = 0;
return {
read: () => hash,
clear: () => {
clears += 1;
},
base: () => "https://dice.example/",
cleared: () => clears,
};
}
const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) =>
render(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
describe("App storage", () => {
it("falls back to the browser's own storage when none is injected", () => {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ diceCounts: { weighted: 2 }, badgeIds: [] }),
);
render(<App createPort={createInlinePort} />);
expect(screen.getByText("2 dice")).toBeInTheDocument();
window.localStorage.clear();
});
it("disables Clear when there is nothing to clear", () => {
renderApp();
expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled();
});
it("drops a die from the inventory when its count reaches zero", async () => {
const storage = memoryStorage(
JSON.stringify({ diceCounts: { weighted: 1 }, badgeIds: [] }),
);
render(<App createPort={createInlinePort} storage={storage} />);
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
await userEvent.click(screen.getByLabelText("Remove one Weighted die"));
expect(readSaved(storage)).toEqual({ diceCounts: {}, badgeIds: [] });
});
it("toggles a badge on and off", async () => {
const storage = memoryStorage();
render(<App createPort={createInlinePort} storage={storage} />);
await userEvent.type(screen.getByLabelText("Search dice and badges"), "emperor");
const checkbox = screen.getByRole("checkbox");
await userEvent.click(checkbox);
expect(readSaved(storage).badgeIds).toEqual(["gold_emperors"]);
await userEvent.click(checkbox);
expect(readSaved(storage).badgeIds).toEqual([]);
});
it("uses the singular for a single die", async () => {
renderApp();
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
await userEvent.click(screen.getByLabelText("Add one Weighted die"));
expect(screen.getByText("1 die")).toBeInTheDocument();
});
it("surfaces a solver error rather than showing nothing", async () => {
// A port that always fails, standing in for a worker that threw.
const failingPort = (): SolverPort => {
const listeners = new Set<(event: MessageEvent<WorkerReply>) => void>();
return {
postMessage: ({ id }: WorkerRequest) => {
for (const listener of listeners) {
listener({ data: { id, ok: false, error: "solver exploded" } } as MessageEvent<
WorkerReply
>);
}
},
addEventListener: (_type, listener) => listeners.add(listener),
removeEventListener: (_type, listener) => listeners.delete(listener),
};
};
render(
<App
createPort={failingPort}
storage={memoryStorage(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: [] }))}
/>,
);
await waitFor(() => {
expect(screen.getByText("solver exploded")).toBeInTheDocument();
});
});
});

View File

@ -1,63 +0,0 @@
/**
* Badge ownership, grouped by tier.
*
* Badges are owned or not owned — there is no quantity — so these are toggles
* rather than steppers. They are grouped by tier because that is the axis the
* recommendation is reported on: a badge only takes effect when your opponent
* brings one of the same tier, and you cannot know theirs in advance.
*/
import type { JSX } from "react";
import { BADGES, TIERS } from "../data/badges.ts";
import { fuzzyFilter } from "../lib/fuzzy.ts";
export interface BadgePickerProps {
readonly query: string;
readonly owned: ReadonlySet<string>;
readonly onToggle: (id: string, owned: boolean) => void;
}
/**
* Tier-grouped badge checkboxes, filtered by the same fuzzy search.
*
* @param props - Query, owned ids, and a toggle handler.
* @returns The picker element.
*/
export function BadgePicker({ query, owned, onToggle }: BadgePickerProps): JSX.Element {
const matches = fuzzyFilter(query, BADGES, (badge) => badge.name).map((m) => m.item);
const matched = new Set(matches.map((badge) => badge.id));
return (
<div className="badge-picker">
{TIERS.map((tier) => {
const inTier = BADGES.filter(
(badge) => badge.tier === tier && matched.has(badge.id),
);
if (inTier.length === 0) {
return null;
}
return (
<section key={tier}>
<h3 className={`tier tier-${tier}`}>{tier}</h3>
<ul className="badge-list">
{inTier.map((badge) => (
<li key={badge.id}>
<label title={badge.description}>
<input
type="checkbox"
checked={owned.has(badge.id)}
onChange={(event) => {
onToggle(badge.id, event.target.checked);
}}
/>
<span>{badge.name}</span>
</label>
</li>
))}
</ul>
</section>
);
})}
</div>
);
}

View File

@ -1,47 +0,0 @@
/**
* The searchable list of every die in the game, with owned quantities.
*/
import type { JSX } from "react";
import { DICE } from "../data/dice.ts";
import { fuzzyFilter } from "../lib/fuzzy.ts";
import { DieRow } from "./DieRow.tsx";
import type { CountUpdater } from "./QuantityStepper.tsx";
export interface DiceListProps {
readonly query: string;
readonly counts: Readonly<Record<string, number>>;
readonly onChange: (id: string, update: CountUpdater) => void;
}
/**
* Fuzzy-filtered list of dice.
*
* @param props - The search query, current counts, and a change handler.
* @returns The list element, or an empty-state message.
*/
export function DiceList({ query, counts, onChange }: DiceListProps): JSX.Element {
const matches = fuzzyFilter(query, DICE, (die) => die.name);
if (matches.length === 0) {
return <p className="empty">No die matches “{query}”.</p>;
}
return (
// `role="list"` is not redundant: the list-style:none needed for the grid
// layout makes Safari/VoiceOver drop list semantics entirely.
<ul className="die-list" role="list">
{matches.map(({ item, indices }) => (
<DieRow
key={item.id}
die={item}
count={counts[item.id] ?? 0}
highlight={indices}
onChange={(update) => {
onChange(item.id, update);
}}
/>
))}
</ul>
);
}

View File

@ -1,107 +0,0 @@
/**
* One die in the inventory list: name, face distribution, and a quantity.
*/
import type { JSX } from "react";
import { MAX_PER_DIE } from "../data/dice.ts";
import type { Die } from "../data/dice.ts";
import { QuantityStepper } from "./QuantityStepper.tsx";
import type { CountUpdater } from "./QuantityStepper.tsx";
export interface DieRowProps {
readonly die: Die;
readonly count: number;
readonly highlight: readonly number[];
readonly onChange: (update: CountUpdater) => void;
}
/**
* Render a name with the fuzzy-matched characters marked.
*
* Adjacent characters are grouped into runs rather than emitted one element per
* character: it keeps the DOM small and, more usefully, leaves the unmatched
* parts of the name as whole text nodes.
*
* @param name - The die's display name.
* @param highlight - Indices in the name that the search query matched.
* @returns The name as alternating plain and marked runs.
*/
export function highlightName(name: string, highlight: readonly number[]): JSX.Element {
const marked = new Set(highlight);
const runs: { text: string; hit: boolean }[] = [];
for (let index = 0; index < name.length; index += 1) {
const hit = marked.has(index);
const last = runs.at(-1);
if (last?.hit === hit) {
last.text += name[index];
} else {
runs.push({ text: name[index], hit });
}
}
return (
<>
{runs.map((run, index) =>
run.hit ? (
// Index keys are correct here: the runs come from a fixed string.
<mark key={index}>{run.text}</mark>
) : (
<span key={index}>{run.text}</span>
),
)}
</>
);
}
/**
* A single inventory row. Clicking anywhere on the row adds one die.
*
* @param props - The die, its current count, search highlights, and a handler.
* @returns The row element.
*/
export function DieRow({ die, count, highlight, onChange }: DieRowProps): JSX.Element {
const isWild = die.wildcardFaces.length > 0;
// One tooltip for the whole strip rather than one per bar. The per-face
// numbered labels that used to sit under the bars are gone: they cost a line
// of vertical space in every one of 43 rows, and the strip is now 11-14px
// tall, where a 0.55rem label would not have been legible anyway.
const facesTitle = die.weights
.map((weight, face) => `${face + 1}: ${(weight * 100).toFixed(1)}%`)
.join(" ");
return (
<li className={count > 0 ? "die-row owned" : "die-row"}>
<button
type="button"
className="die-main"
// The name is ellipsised in the narrow cells, so it goes in the tooltip
// too — otherwise a fuzzy match landing past the cut is unrecoverable.
title={`${die.name} — ${die.description}`}
// The stepper's "+" already owns "Add one <die>"; this control needs a
// distinct accessible name so the two are not ambiguous.
aria-label={die.name}
onClick={() => {
onChange((previous) => Math.min(MAX_PER_DIE, previous + 1));
}}
>
<span className="die-name">{highlightName(die.name, highlight)}</span>
<span className="die-faces" title={facesTitle}>
{die.weights.map((weight, face) => (
<span className="die-face" key={face}>
{/*
* x3, not the old x1.6: that factor was tuned for a 26px strip.
* At 11-14px a uniform 1/6 face rendered under 4px and every die
* looked flat. At x3 a uniform face fills half the strip and a
* face weighted a third or more saturates.
*/}
<span
className="die-face-bar"
style={{ height: `${Math.min(100, weight * 100 * 3)}%` }}
/>
</span>
))}
</span>
{isWild && <span className="die-tag">wild</span>}
</button>
<QuantityStepper label={die.name} value={count} max={MAX_PER_DIE} onChange={onChange} />
</li>
);
}

View File

@ -1,161 +0,0 @@
/**
* Moving an inventory between devices: a file, the clipboard, or a link.
*
* Collapsed by default and placed below the badges, because the dice grid above
* it spends the whole of a phone screen and a summary line up there would cost
* a row of dice.
*/
import { useState } from "react";
import type { ChangeEvent, JSX } from "react";
import {
EXPORT_FILENAME,
isUsable,
parseInventoryJson,
serialiseInventory,
} from "../lib/inventoryIo.ts";
import type { ClipboardPort, SavedInventory, SaveFilePort } from "../lib/inventoryIo.ts";
export interface InventoryIoProps {
readonly inventory: SavedInventory;
readonly onImport: (inventory: SavedInventory) => void;
readonly clipboard: ClipboardPort;
readonly saveFile: SaveFilePort;
/** Absolute URL carrying this inventory in its fragment. */
readonly shareUrl: string;
}
interface Status {
readonly ok: boolean;
readonly message: string;
}
/**
* Import and export controls for the saved inventory.
*
* @param props - The current inventory, an import handler, and the injectable
* clipboard and download ports.
* @returns The collapsible panel.
*/
export function InventoryIo({
inventory,
onImport,
clipboard,
saveFile,
shareUrl,
}: InventoryIoProps): JSX.Element {
const [text, setText] = useState("");
const [status, setStatus] = useState<Status | null>(null);
const json = serialiseInventory(inventory);
const apply = (raw: string): void => {
const result = parseInventoryJson(raw);
const { inventory: parsed, dropped } = result;
if (!isUsable(result)) {
setStatus({ ok: false, message: `Nothing imported. ${dropped.join(" ")}` });
return;
}
onImport(parsed);
setStatus({
ok: true,
message: dropped.length === 0 ? "Imported." : `Imported. Ignored: ${dropped.join(" ")}`,
});
};
const copy = (value: string, what: string): void => {
void clipboard.writeText(value).then(
() => {
setStatus({ ok: true, message: `${what} copied.` });
},
() => {
// Insecure origin, or a browser that refuses without a user gesture.
// Put the payload in the box so it can still be copied by hand.
setText(value);
setStatus({ ok: false, message: "Copy failed — select the text below and copy it." });
},
);
};
const onFile = (event: ChangeEvent<HTMLInputElement>): void => {
const { files } = event.target;
if (files === null || files.length === 0) {
return;
}
void files[0].text().then(apply, () => {
setStatus({ ok: false, message: "Could not read that file." });
});
};
return (
<details className="io">
<summary>Import / export</summary>
<div className="io-row">
<button
type="button"
className="clear"
onClick={() => {
saveFile(EXPORT_FILENAME, json);
setStatus({ ok: true, message: `Saved ${EXPORT_FILENAME}.` });
}}
>
Download .json
</button>
<button
type="button"
className="clear"
onClick={() => {
copy(json, "JSON");
}}
>
Copy JSON
</button>
<button
type="button"
className="clear"
onClick={() => {
copy(shareUrl, "Link");
}}
>
Copy link
</button>
</div>
<label className="io-file">
Load a .json file
<input type="file" accept="application/json" onChange={onFile} />
</label>
<textarea
className="io-text"
rows={4}
placeholder="…or paste an exported inventory here"
aria-label="Inventory JSON"
value={text}
onChange={(event) => {
setText(event.target.value);
}}
/>
<div className="io-row">
<button
type="button"
className="clear"
disabled={text.trim() === ""}
onClick={() => {
apply(text);
}}
>
Import pasted
</button>
</div>
{status !== null && (
<p className={status.ok ? "io-status" : "io-status bad"} role="status">
{status.message}
</p>
)}
</details>
);
}

View File

@ -1,106 +0,0 @@
/**
* The quantity control for one inventory row.
*
* Two ways to change a count: the −/+ buttons and the mouse wheel. There used
* to be a third — typing into a number input — but the inventory list now packs
* 43 dice into one screen, and the input cost 28px of a ~155px cell at phone
* width. With a maximum of six, three taps of "+" covers the whole range, so
* the field bought nothing that justified the space.
*/
import type { JSX } from "react";
/**
* How a count changes: a function of the previous count, never an absolute
* value computed from a stale render. Wheel events and rapid clicks arrive
* faster than React re-renders, so `onChange(value + 1)` silently collapses a
* burst of six into one — which is exactly what happened the first time this
* was driven in a real browser.
*/
export type CountUpdater = (previous: number) => number;
export interface QuantityStepperProps {
readonly label: string;
readonly value: number;
readonly max: number;
readonly onChange: (update: CountUpdater) => void;
}
/**
* Clamp a count into the allowed range, treating anything unparsable as zero.
*
* The `Number.isFinite` guard is defensive since the number input was removed —
* every caller now passes `previous + delta`, which cannot be NaN. It is kept
* because the inventory importer also clamps values that came from a file.
*
* @param value - Proposed count.
* @param max - Largest allowed count.
* @returns The clamped whole number.
*/
export function clampCount(value: number, max: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.min(max, Math.max(0, Math.floor(value)));
}
/**
* Stepper for how many of a die or badge the player owns.
*
* @param props - Current value, bounds, and the change handler.
* @returns The stepper element.
*/
export function QuantityStepper({
label,
value,
max,
onChange,
}: QuantityStepperProps): JSX.Element {
const step = (delta: number): void => {
onChange((previous) => clampCount(previous + delta, max));
};
return (
<div
className="stepper"
onWheel={(event) => {
// Scrolling up adds one, down removes one. The row is not scrollable
// itself, so nothing is stolen from the page.
event.preventDefault();
step(event.deltaY < 0 ? 1 : -1);
}}
>
<button
type="button"
className="stepper-button stepper-remove"
aria-label={`Remove one ${label}`}
disabled={value === 0}
onClick={() => {
step(-1);
}}
>
−
</button>
{/*
* `output` rather than a span: it has an implicit `status` role and is a
* nameable element, so `aria-label` actually resolves for screen readers
* (on a bare span most of them ignore it). It stays a direct child of
* `.stepper` so the wheel handler still covers it.
*/}
<output className="stepper-value" aria-label={`How many ${label}`}>
{value}
</output>
<button
type="button"
className="stepper-button stepper-add"
aria-label={`Add one ${label}`}
disabled={value >= max}
onClick={() => {
step(1);
}}
>
+
</button>
</div>
);
}

View File

@ -1,171 +0,0 @@
/**
* The recommendation: six dice, the numbers behind them, and a badge per tier.
*/
import type { JSX } from "react";
import type { Die } from "../data/dice.ts";
import type { SolveResponse } from "../core/solve.ts";
export interface ResultPanelProps {
readonly result: SolveResponse | null;
readonly error: string | null;
readonly solving: boolean;
}
/**
* Collapse a loadout into "3x Weighted die, 2x Pie die" form.
*
* @param dice - The six dice of a loadout.
* @returns One entry per distinct die, in descending count order.
*/
export function summariseDice(dice: readonly Die[]): { die: Die; count: number }[] {
const byId = new Map<string, { die: Die; count: number }>();
for (const die of dice) {
const existing = byId.get(die.id);
if (existing) {
existing.count += 1;
} else {
byId.set(die.id, { die, count: 1 });
}
}
return [...byId.values()].sort((a, b) => b.count - a.count);
}
/**
* Format a point figure for display.
*
* @param value - The number of points.
* @returns The value rounded to whole points with thousands separators.
*/
function points(value: number): string {
return Math.round(value).toLocaleString("en-GB");
}
/**
* The recommendation panel.
*
* @param props - The latest solve, any error, and whether one is in flight.
* @returns The panel element.
*/
export function ResultPanel({ result, error, solving }: ResultPanelProps): JSX.Element {
if (error) {
return (
<div className="result">
<p className="empty">{error}</p>
</div>
);
}
if (!result) {
return (
<div className="result">
<p className="empty">
{solving ? "Solving…" : "Add at least six dice to get a recommendation."}
</p>
</div>
);
}
return (
<div className={solving ? "result stale" : "result"}>
<h2>Best loadout</h2>
<ul className="loadout">
{summariseDice(result.dice).map(({ die, count }) => (
<li key={die.id}>
<span className="loadout-count">{count}×</span>
<span className="loadout-name">{die.name}</span>
</li>
))}
</ul>
<dl className="stats">
<div>
<dt>Expected score per throw</dt>
<dd>{points(result.evaluation.ev)}</dd>
</div>
<div>
<dt>Bust chance</dt>
<dd>{(result.evaluation.bustProbability * 100).toFixed(2)}%</dd>
</div>
<div>
<dt>Good throw (90th pct)</dt>
<dd>{points(result.evaluation.p90)}</dd>
</div>
<div>
<dt>Simulated points per turn</dt>
<dd>
{points(result.simulation.meanPerTurn)}
<small> ± {result.simulation.standardError.toFixed(1)}</small>
</dd>
</div>
</dl>
<p className="provenance">
{result.optimal
? "Provably optimal: every possible loadout was evaluated."
: "Best found by local search — near-optimal, not proven."}{" "}
Chosen from {result.inventorySize} dice.
</p>
{result.alternatives.length > 0 && (
<details className="alternatives">
<summary>Runners-up</summary>
<ul>
{result.alternatives.map((alternative, index) => (
// Alternatives are a fixed ranked list; the index is their rank.
<li key={index}>
<span className="alt-ev">{points(alternative.evaluation.ev)}</span>
{summariseDice(alternative.dice)
.map(({ die, count }) => `${count}× ${die.name}`)
.join(", ")}
</li>
))}
</ul>
</details>
)}
<h2>Best badge per tier</h2>
{result.badges.length === 0 ? (
<p className="empty">Tick the badges you own to have them ranked.</p>
) : (
result.badges.map((recommendation) => {
const [best, ...rest] = recommendation.ranked;
return (
<section key={recommendation.tier} className="badge-result">
<h3 className={`tier tier-${recommendation.tier}`}>{recommendation.tier}</h3>
<p className="badge-pick">
<strong>{best.badge.name}</strong>
{best.pointsPerGame !== null && (
<span className="badge-value"> ~{points(best.pointsPerGame)} pts/game</span>
)}
</p>
<p className="badge-reason">{best.reason}</p>
{best.dice && (
<p className="badge-reason">
With this badge, bring:{" "}
{summariseDice(best.dice)
.map(({ die, count }) => `${count}× ${die.name}`)
.join(", ")}
</p>
)}
{rest.length > 0 && (
<details>
<summary>Other {recommendation.tier} badges</summary>
<ul>
{rest.map((valuation) => (
<li key={valuation.badge.id}>
{valuation.badge.name}
{valuation.pointsPerGame === null
? " — situational"
: ` — ~${points(valuation.pointsPerGame)} pts/game`}
</li>
))}
</ul>
</details>
)}
</section>
);
})
)}
</div>
);
}

View File

@ -1,164 +0,0 @@
/** Inventory IO component tests. */
/** Result panel and inventory IO component tests. */
/** Badge picker and result panel component tests. */
/** Row, list and picker component tests. */
/**
* Component tests for the three ways of entering an inventory: clicking a die,
* scrolling the wheel over its counter, and fuzzy-searching by name.
*/
import { fireEvent, render, screen, waitFor, } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { SavedInventory } from "../lib/inventoryIo.ts";
import { InventoryIo } from "./InventoryIo.tsx";
import type { InventoryIoProps } from "./InventoryIo.tsx";
/**
* Apply the updater a component emitted, to assert on the resulting count.
*
* @param mock - The mocked onChange.
* @param previous - The count it should be applied to.
* @param call - Which call to read, counting from the end by default.
* @returns The count the component asked for.
*/
describe("InventoryIo", () => {
const inventory: SavedInventory = {
diceCounts: { weighted: 2 },
badgeIds: ["tin_might"],
};
/**
* Render the panel with every port stubbed, and open it.
*
* @param overrides - Ports or handlers to replace.
* @returns The stubs the caller will want to assert on.
*/
function setup(overrides: Partial<InventoryIoProps> = {}) {
const onImport = vi.fn();
const saveFile = vi.fn();
const clipboard = { writeText: vi.fn<(text: string) => Promise<void>>().mockResolvedValue() };
render(
<InventoryIo
inventory={inventory}
onImport={onImport}
clipboard={clipboard}
saveFile={saveFile}
shareUrl="https://dice.example/#i=abc"
{...overrides}
/>,
);
return { onImport, saveFile, clipboard };
}
it("starts collapsed so it costs no vertical space", () => {
setup();
expect(screen.getByText("Import / export").closest("details")).not.toHaveAttribute("open");
});
it("downloads the inventory as parseable JSON", async () => {
const { saveFile } = setup();
await userEvent.click(screen.getByRole("button", { name: "Download .json" }));
expect(saveFile).toHaveBeenCalledTimes(1);
const [filename, body] = saveFile.mock.calls[0] as [string, string];
expect(filename).toBe("kcd2-dice.json");
expect(JSON.parse(body)).toEqual(inventory);
expect(screen.getByRole("status")).toHaveTextContent("Saved kcd2-dice.json.");
});
it("copies the JSON to the clipboard", async () => {
const { clipboard } = setup();
await userEvent.click(screen.getByRole("button", { name: "Copy JSON" }));
expect(JSON.parse(clipboard.writeText.mock.calls[0][0])).toEqual(inventory);
await screen.findByText("JSON copied.");
});
it("copies the share link", async () => {
const { clipboard } = setup();
await userEvent.click(screen.getByRole("button", { name: "Copy link" }));
expect(clipboard.writeText).toHaveBeenCalledWith("https://dice.example/#i=abc");
await screen.findByText("Link copied.");
});
it("falls back to the textarea when the clipboard refuses", async () => {
// Insecure origins reject; the payload must still be reachable by hand.
const clipboard = {
writeText: vi.fn<(text: string) => Promise<void>>().mockRejectedValue(new Error("denied")),
};
setup({ clipboard });
await userEvent.click(screen.getByRole("button", { name: "Copy JSON" }));
await screen.findByText(/Copy failed/);
const box = screen.getByLabelText<HTMLTextAreaElement>("Inventory JSON");
expect(JSON.parse(box.value)).toEqual(inventory);
});
it("imports a pasted inventory", async () => {
const { onImport } = setup();
// fireEvent rather than userEvent.type: the latter treats "{" and "[" as
// key-descriptor syntax, which mangles JSON.
fireEvent.change(screen.getByLabelText("Inventory JSON"), {
target: { value: JSON.stringify({ diceCounts: { lucky: 3 }, badgeIds: [] }) },
});
await userEvent.click(screen.getByRole("button", { name: "Import pasted" }));
expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 3 }, badgeIds: [] });
expect(screen.getByRole("status")).toHaveTextContent("Imported.");
});
it("refuses an unusable payload without calling onImport", async () => {
const { onImport } = setup();
fireEvent.change(screen.getByLabelText("Inventory JSON"), { target: { value: "{not json" } });
await userEvent.click(screen.getByRole("button", { name: "Import pasted" }));
expect(onImport).not.toHaveBeenCalled();
expect(screen.getByRole("status")).toHaveTextContent("Nothing imported. Not valid JSON.");
});
it("imports what it can and says what it ignored", async () => {
const { onImport } = setup();
fireEvent.change(screen.getByLabelText("Inventory JSON"), {
target: { value: '{"diceCounts":{"lucky":1,"nope":2},"badgeIds":[]}' },
});
await userEvent.click(screen.getByRole("button", { name: "Import pasted" }));
expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 1 }, badgeIds: [] });
expect(screen.getByRole("status")).toHaveTextContent("Ignored: Unknown die “nope”.");
});
it("keeps the import button disabled until something is pasted", () => {
setup();
expect(screen.getByRole("button", { name: "Import pasted" })).toBeDisabled();
});
it("imports an uploaded file", async () => {
const { onImport } = setup();
const file = new File([JSON.stringify({ diceCounts: { lucky: 5 }, badgeIds: [] })], "inv.json", {
type: "application/json",
});
await userEvent.upload(screen.getByLabelText(/Load a .json file/), file);
await waitFor(() => {
expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 5 }, badgeIds: [] });
});
});
it("does nothing when the file picker is dismissed", () => {
const { onImport } = setup();
const input = screen.getByLabelText(/Load a .json file/);
fireEvent.change(input, { target: { files: [] } });
expect(onImport).not.toHaveBeenCalled();
fireEvent.change(input, { target: { files: null } });
expect(onImport).not.toHaveBeenCalled();
});
it("reports a file it cannot read", async () => {
const { onImport } = setup();
const unreadable = new File(["x"], "bad.json", { type: "application/json" });
vi.spyOn(unreadable, "text").mockRejectedValue(new Error("I/O"));
await userEvent.upload(screen.getByLabelText(/Load a .json file/), unreadable);
await screen.findByText("Could not read that file.");
expect(onImport).not.toHaveBeenCalled();
});
});

View File

@ -1,71 +0,0 @@
/** Badge picker and result panel component tests. */
/** Row, list and picker component tests. */
/**
* Component tests for the three ways of entering an inventory: clicking a die,
* scrolling the wheel over its counter, and fuzzy-searching by name.
*/
import { render, screen, } from "@testing-library/react";
import { ordinary } from "../test/fixtures.ts";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { Die } from "../data/dice.ts";
import { BadgePicker } from "./BadgePicker.tsx";
import { summariseDice } from "./ResultPanel.tsx";
/**
* Apply the updater a component emitted, to assert on the resulting count.
*
* @param mock - The mocked onChange.
* @param previous - The count it should be applied to.
* @param call - Which call to read, counting from the end by default.
* @returns The count the component asked for.
*/
describe("BadgePicker", () => {
it("groups badges under their tier", () => {
render(<BadgePicker query="" owned={new Set()} onToggle={vi.fn()} />);
expect(screen.getByRole("heading", { name: "tin" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "silver" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument();
});
it("hides a tier with no matching badge", () => {
render(<BadgePicker query="emperor" owned={new Set()} onToggle={vi.fn()} />);
expect(screen.queryByRole("heading", { name: "tin" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument();
});
it("reflects which badges are owned", () => {
render(<BadgePicker query="emperor" owned={new Set(["gold_emperors"])} onToggle={vi.fn()} />);
expect(screen.getByRole("checkbox")).toBeChecked();
});
it("reports a toggle in both directions", async () => {
const onToggle = vi.fn();
const { rerender } = render(
<BadgePicker query="emperor" owned={new Set()} onToggle={onToggle} />,
);
await userEvent.click(screen.getByRole("checkbox"));
expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", true);
rerender(
<BadgePicker query="emperor" owned={new Set(["gold_emperors"])} onToggle={onToggle} />,
);
await userEvent.click(screen.getByRole("checkbox"));
expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", false);
});
});
describe("summariseDice", () => {
it("collapses duplicates and orders by count", () => {
const weighted: Die = { ...ordinary, id: "weighted", name: "Weighted die" };
expect(summariseDice([ordinary, weighted, weighted, weighted])).toEqual([
{ die: weighted, count: 3 },
{ die: ordinary, count: 1 },
]);
});
});

View File

@ -1,145 +0,0 @@
/** Result panel and inventory IO component tests. */
/** Badge picker and result panel component tests. */
/** Row, list and picker component tests. */
/**
* Component tests for the three ways of entering an inventory: clicking a die,
* scrolling the wheel over its counter, and fuzzy-searching by name.
*/
import { render, screen, } from "@testing-library/react";
import { ordinary, response } from "../test/fixtures.ts";
import { describe, expect, it, } from "vitest";
import type { Die } from "../data/dice.ts";
import type { SolveResponse } from "../core/solve.ts";
import { ResultPanel, } from "./ResultPanel.tsx";
/**
* Apply the updater a component emitted, to assert on the resulting count.
*
* @param mock - The mocked onChange.
* @param previous - The count it should be applied to.
* @param call - Which call to read, counting from the end by default.
* @returns The count the component asked for.
*/
describe("ResultPanel", () => {
it("prompts for dice before the first solve", () => {
render(<ResultPanel result={null} error={null} solving={false} />);
expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument();
});
it("says it is working while a solve is in flight", () => {
render(<ResultPanel result={null} error={null} solving />);
expect(screen.getByText("Solving…")).toBeInTheDocument();
});
it("shows an error instead of a stale answer", () => {
render(<ResultPanel result={null} error="Need at least 6 dice" solving={false} />);
expect(screen.getByText("Need at least 6 dice")).toBeInTheDocument();
});
it("shows the loadout and its numbers", () => {
render(<ResultPanel result={response} error={null} solving={false} />);
expect(screen.getByText("6×")).toBeInTheDocument();
expect(screen.getByText("Ordinary die")).toBeInTheDocument();
expect(screen.getByText("399")).toBeInTheDocument();
expect(screen.getByText("3.09%")).toBeInTheDocument();
expect(screen.getByText("900")).toBeInTheDocument();
});
it("says when the answer is proven rather than merely good", () => {
render(<ResultPanel result={response} error={null} solving={false} />);
expect(screen.getByText(/Provably optimal/)).toBeInTheDocument();
});
it("admits when the answer is only a local search result", () => {
render(
<ResultPanel result={{ ...response, optimal: false }} error={null} solving={false} />,
);
expect(screen.getByText(/not proven/)).toBeInTheDocument();
});
it("lists runners-up when there are any", () => {
const withAlternatives: SolveResponse = {
...response,
alternatives: [
{ dice: new Array<Die>(6).fill(ordinary), evaluation: response.evaluation },
],
};
render(<ResultPanel result={withAlternatives} error={null} solving={false} />);
expect(screen.getByText("Runners-up")).toBeInTheDocument();
});
it("asks for badges when none are owned", () => {
render(<ResultPanel result={response} error={null} solving={false} />);
expect(screen.getByText(/Tick the badges you own/)).toBeInTheDocument();
});
it("shows the pick, its value and its reasoning for each tier", () => {
const withBadges: SolveResponse = {
...response,
badges: [
{
tier: "gold",
ranked: [
{
badge: {
id: "gold_emperors",
name: "Gold Emperor's badge",
description: "",
tier: "gold",
effect: { kind: "scoring", rules: { emperorTriple: true } },
},
pointsPerGame: 1234,
reason: "Changes the scoring table.",
dice: new Array<Die>(6).fill(ordinary),
},
{
badge: {
id: "gold_warlord",
name: "Gold Warlord badge",
description: "",
tier: "gold",
effect: { kind: "multiplier", factor: 2, uses: 1 },
},
pointsPerGame: 567,
reason: "Doubles a good turn.",
dice: null,
},
{
badge: {
id: "gold_defence",
name: "Gold Defence badge",
description: "",
tier: "gold",
effect: { kind: "defence" },
},
pointsPerGame: null,
reason: "Situational.",
dice: null,
},
],
},
],
};
render(<ResultPanel result={withBadges} error={null} solving={false} />);
expect(screen.getByText("Gold Emperor's badge")).toBeInTheDocument();
expect(screen.getByText(/~1,234 pts\/game/)).toBeInTheDocument();
expect(screen.getByText("Changes the scoring table.")).toBeInTheDocument();
expect(screen.getByText(/With this badge, bring/)).toBeInTheDocument();
// The runners-up list renders both a valued badge and a situational one.
expect(screen.getByText(/Gold Warlord badge — ~567 pts\/game/)).toBeInTheDocument();
expect(screen.getByText(/Gold Defence badge — situational/)).toBeInTheDocument();
});
it("dims the panel while a fresh solve is running", () => {
const { container } = render(
<ResultPanel result={response} error={null} solving />,
);
expect(container.querySelector(".result")).toHaveClass("stale");
});
});

View File

@ -1,120 +0,0 @@
/** Row, list and picker component tests. */
/**
* Component tests for the three ways of entering an inventory: clicking a die,
* scrolling the wheel over its counter, and fuzzy-searching by name.
*/
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MAX_PER_DIE } from "../data/dice.ts";
import type { Die } from "../data/dice.ts";
import { DiceList } from "./DiceList.tsx";
import { DieRow, } from "./DieRow.tsx";
/**
* Apply the updater a component emitted, to assert on the resulting count.
*
* @param mock - The mocked onChange.
* @param previous - The count it should be applied to.
* @param call - Which call to read, counting from the end by default.
* @returns The count the component asked for.
*/
function applied(
mock: { mock: { calls: unknown[][] } },
previous: number,
call = mock.mock.calls.length - 1,
): number {
const update = mock.mock.calls[call][0] as (p: number) => number;
return update(previous);
}
const ordinary: Die = {
id: "ordinary",
name: "Ordinary die",
description: "An ordinary playing die.",
weights: [1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6],
wildcardFaces: [],
wildScoresAlone: false,
};
describe("DieRow", () => {
it("adds one die when the row is clicked", async () => {
const onChange = vi.fn();
render(<DieRow die={ordinary} count={0} highlight={[]} onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: "Ordinary die" }));
expect(applied(onChange, 0)).toBe(1);
});
it("stops at the maximum when clicked repeatedly", async () => {
const onChange = vi.fn();
render(
<DieRow die={ordinary} count={MAX_PER_DIE} highlight={[]} onChange={onChange} />,
);
await userEvent.click(screen.getByRole("button", { name: "Ordinary die" }));
expect(applied(onChange, MAX_PER_DIE)).toBe(MAX_PER_DIE);
});
it("marks a row you own", () => {
const { container } = render(
<DieRow die={ordinary} count={2} highlight={[]} onChange={vi.fn()} />,
);
expect(container.querySelector(".die-row")).toHaveClass("owned");
});
it("tags the wildcard dice", () => {
const balatro: Die = {
...ordinary,
id: "balatro",
wildcardFaces: [1],
wildScoresAlone: true,
};
render(<DieRow die={balatro} count={0} highlight={[]} onChange={vi.fn()} />);
expect(screen.getByText("wild")).toBeInTheDocument();
});
});
describe("DiceList", () => {
it("shows every die when the query is empty", () => {
render(<DiceList query="" counts={{}} onChange={vi.fn()} />);
expect(screen.getAllByRole("listitem")).toHaveLength(43);
});
it("narrows to the Weighted die on a three-letter query", () => {
render(<DiceList query="wei" counts={{}} onChange={vi.fn()} />);
const rows = screen.getAllByRole("listitem");
// The name is split into highlighted and plain runs, so match on the row
// button's accessible name rather than on a single text node.
expect(within(rows[0]).getByRole("button", { name: "Weighted die" })).toBeInTheDocument();
});
it("highlights the characters the query matched", () => {
render(<DiceList query="wei" counts={{}} onChange={vi.fn()} />);
const rows = screen.getAllByRole("listitem");
expect(within(rows[0]).getByText("Wei").tagName).toBe("MARK");
});
it("finds a die by a fragment from the middle of its name", () => {
render(<DiceList query="grozav" counts={{}} onChange={vi.fn()} />);
expect(screen.getAllByRole("listitem")).toHaveLength(1);
});
it("says so when nothing matches", () => {
render(<DiceList query="qqqqq" counts={{}} onChange={vi.fn()} />);
expect(screen.getByText(/No die matches/)).toBeInTheDocument();
});
it("passes the owned count through to the row", () => {
render(<DiceList query="wei" counts={{ weighted: 4 }} onChange={vi.fn()} />);
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4");
});
it("reports which die changed", async () => {
const onChange = vi.fn();
render(<DiceList query="wei" counts={{}} onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: "Weighted die" }));
expect(onChange.mock.calls[0][0]).toBe("weighted");
expect((onChange.mock.calls[0][1] as (p: number) => number)(0)).toBe(1);
});
});

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