mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
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:
parent
ec5d3aba34
commit
a0459b99a0
35
.github/workflows/reverse-survivors-ci.yml
vendored
35
.github/workflows/reverse-survivors-ci.yml
vendored
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."
|
||||
}
|
||||
56
docs/superpowers/evidence/extract-three-apps-2026-08-23.json
Normal file
56
docs/superpowers/evidence/extract-three-apps-2026-08-23.json
Normal 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."
|
||||
]
|
||||
}
|
||||
45
focus_owner/.gitignore
vendored
45
focus_owner/.gitignore
vendored
@ -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
|
||||
@ -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'
|
||||
@ -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.
|
||||
@ -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
|
||||
14
focus_owner/android/.gitignore
vendored
14
focus_owner/android/.gitignore
vendored
@ -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
|
||||
@ -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")
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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")
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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.
|
||||
@ -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');
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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 "$@"
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
@ -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.
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
6
kcd2_dice_solver/.gitignore
vendored
6
kcd2_dice_solver/.gitignore
vendored
@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
dist.staging
|
||||
.deploy.lock
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
@ -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.
|
||||
@ -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 "$@"
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
@ -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>
|
||||
@ -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
@ -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 "$@"
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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
Loading…
Reference in New Issue
Block a user