From a0459b99a08d2acfce45b97a32bd84c40fb2040a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 23 Aug 2026 17:34:02 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PJrqKERAo5QUeEHWS5Asgp --- .github/workflows/reverse-survivors-ci.yml | 35 - .prettierignore | 1 - AGENTS.md | 8 +- .../extract-three-apps-2026-08-23.json | 20 + .../extract-three-apps-2026-08-23.json | 56 + focus_owner/.gitignore | 45 - focus_owner/.metadata | 30 - focus_owner/README.md | 142 - focus_owner/analysis_options.yaml | 37 - focus_owner/android/.gitignore | 14 - focus_owner/android/app/build.gradle.kts | 82 - .../android/app/src/debug/AndroidManifest.xml | 7 - .../android/app/src/main/AndroidManifest.xml | 134 - .../com/kuhy/focus_owner/BootReceiver.kt | 52 - .../kuhy/focus_owner/DevicePolicyBridge.kt | 219 - .../kuhy/focus_owner/DevicePolicyLocation.kt | 92 - .../focus_owner/DevicePolicyUninstallGuard.kt | 79 - .../kuhy/focus_owner/DevicePolicyVpnDns.kt | 146 - .../focus_owner/EnforcementAlarmReceiver.kt | 20 - .../kuhy/focus_owner/EnforcementDecision.kt | 221 - .../com/kuhy/focus_owner/EnforcementLog.kt | 223 - .../com/kuhy/focus_owner/EnforcementRunner.kt | 237 - .../kuhy/focus_owner/EnforcementScheduler.kt | 117 - .../kuhy/focus_owner/EnforcementService.kt | 196 - .../com/kuhy/focus_owner/EnforcementTypes.kt | 63 - .../focus_owner/FocusDeviceAdminReceiver.kt | 37 - .../com/kuhy/focus_owner/FocusPolicy.kt | 238 - .../com/kuhy/focus_owner/HomeLocationStore.kt | 116 - .../kuhy/focus_owner/LocationAcquisition.kt | 198 - .../com/kuhy/focus_owner/LocationFix.kt | 68 - .../com/kuhy/focus_owner/MainActivity.kt | 117 - .../com/kuhy/focus_owner/PolicyPinning.kt | 94 - .../com/kuhy/focus_owner/PolicyTypes.kt | 48 - .../com/kuhy/focus_owner/SweepablePackages.kt | 42 - .../res/drawable-v21/launch_background.xml | 12 - .../main/res/drawable/launch_background.xml | 12 - .../app/src/main/res/values-night/styles.xml | 18 - .../app/src/main/res/values/styles.xml | 18 - .../app/src/main/res/xml/device_admin.xml | 12 - .../app/src/profile/AndroidManifest.xml | 7 - .../EnforcementDecisionCurfewTest.kt | 223 - .../focus_owner/EnforcementDecisionTest.kt | 169 - .../kuhy/focus_owner/EnforcementFixtures.kt | 92 - .../kuhy/focus_owner/EnforcementLogTest.kt | 169 - .../focus_owner/EnforcementRunnerBestTest.kt | 90 - focus_owner/android/build.gradle.kts | 24 - focus_owner/android/gradle.properties | 6 - .../gradle/wrapper/gradle-wrapper.properties | 5 - focus_owner/android/settings.gradle.kts | 26 - focus_owner/assets/policy.json | 153 - .../profile-owner-verification-2026-08-09.md | 172 - focus_owner/lib/debug_log_page.dart | 191 - focus_owner/lib/device_policy.dart | 127 - focus_owner/lib/enforcement_record.dart | 219 - focus_owner/lib/enforcement_status_card.dart | 148 - focus_owner/lib/focus_policy.dart | 197 - focus_owner/lib/home_location.dart | 60 - focus_owner/lib/main.dart | 70 - focus_owner/lib/policy.dart | 85 - focus_owner/lib/status_body.dart | 179 - focus_owner/lib/status_card_parts.dart | 128 - focus_owner/lib/status_page_dialogs.dart | 103 - focus_owner/lib/status_page_state.dart | 248 - focus_owner/lib/theme.dart | 43 - focus_owner/lib/workout_signal.dart | 117 - focus_owner/pubspec.lock | 222 - focus_owner/pubspec.yaml | 100 - focus_owner/scripts/push_home_location.sh | 144 - focus_owner/test/enforcement_record_test.dart | 193 - .../test/enforcement_status_card_test.dart | 211 - focus_owner/test/home_location_test.dart | 87 - focus_owner/test/policy_decisions_test.dart | 184 - focus_owner/test/policy_test.dart | 101 - .../test/status_page_actions_test.dart | 204 - focus_owner/test/status_page_test.dart | 174 - focus_owner/test/widget_test.dart | 88 - focus_owner/test/workout_signal_test.dart | 125 - kcd2_dice_solver/.gitignore | 6 - kcd2_dice_solver/README.md | 162 - kcd2_dice_solver/deploy_build.sh | 131 - kcd2_dice_solver/eslint.config.js | 29 - kcd2_dice_solver/index.html | 12 - kcd2_dice_solver/package.json | 44 - kcd2_dice_solver/pnpm-lock.yaml | 2755 ---------- kcd2_dice_solver/run.sh | 65 - kcd2_dice_solver/src/App.tsx | 217 - kcd2_dice_solver/src/AppLinks.test.tsx | 249 - kcd2_dice_solver/src/AppRecovery.test.tsx | 173 - kcd2_dice_solver/src/AppShell.test.tsx | 227 - kcd2_dice_solver/src/AppStorage.test.tsx | 180 - .../src/components/BadgePicker.tsx | 63 - kcd2_dice_solver/src/components/DiceList.tsx | 47 - kcd2_dice_solver/src/components/DieRow.tsx | 107 - .../src/components/InventoryIo.tsx | 161 - .../src/components/QuantityStepper.tsx | 106 - .../src/components/ResultPanel.tsx | 171 - .../components/componentInventory.test.tsx | 164 - .../src/components/componentPickers.test.tsx | 71 - .../src/components/componentResult.test.tsx | 145 - .../src/components/componentRows.test.tsx | 120 - .../src/components/components.test.tsx | 121 - kcd2_dice_solver/src/core/badgeBaseline.ts | 185 - kcd2_dice_solver/src/core/badgeValue.test.ts | 197 - kcd2_dice_solver/src/core/badgeValue.ts | 172 - kcd2_dice_solver/src/core/counts.ts | 84 - kcd2_dice_solver/src/core/distribution.ts | 114 - kcd2_dice_solver/src/core/evaluate.test.ts | 197 - kcd2_dice_solver/src/core/evaluate.ts | 137 - kcd2_dice_solver/src/core/scoring.test.ts | 231 - kcd2_dice_solver/src/core/scoring.ts | 172 - kcd2_dice_solver/src/core/scoringMoves.ts | 203 - kcd2_dice_solver/src/core/search.test.ts | 224 - kcd2_dice_solver/src/core/search.ts | 100 - kcd2_dice_solver/src/core/searchGroups.ts | 109 - kcd2_dice_solver/src/core/searchStrategies.ts | 232 - kcd2_dice_solver/src/core/simulate.test.ts | 100 - kcd2_dice_solver/src/core/simulate.ts | 211 - .../src/core/simulateTurns.test.ts | 198 - kcd2_dice_solver/src/core/simulateTurns.ts | 198 - kcd2_dice_solver/src/core/solve.demo.test.ts | 69 - kcd2_dice_solver/src/core/solve.test.ts | 91 - kcd2_dice_solver/src/core/solve.ts | 117 - kcd2_dice_solver/src/data/badgeTable.ts | 165 - kcd2_dice_solver/src/data/badgeTableGold.ts | 108 - kcd2_dice_solver/src/data/badges.ts | 112 - kcd2_dice_solver/src/data/data.test.ts | 165 - kcd2_dice_solver/src/data/dice.ts | 93 - kcd2_dice_solver/src/data/headstart.ts | 22 - kcd2_dice_solver/src/data/rawDice.ts | 21 - kcd2_dice_solver/src/data/rawDiceAM.ts | 178 - kcd2_dice_solver/src/data/rawDiceNZ.ts | 157 - .../src/hooks/useInventoryState.ts | 163 - kcd2_dice_solver/src/hooks/useSolver.test.tsx | 210 - kcd2_dice_solver/src/hooks/useSolver.ts | 141 - kcd2_dice_solver/src/index.css | 774 --- kcd2_dice_solver/src/lib/fuzzy.test.ts | 58 - kcd2_dice_solver/src/lib/fuzzy.ts | 148 - .../src/lib/inventoryHash.test.ts | 160 - kcd2_dice_solver/src/lib/inventoryHash.ts | 130 - kcd2_dice_solver/src/lib/inventoryIo.test.ts | 164 - kcd2_dice_solver/src/lib/inventoryIo.ts | 177 - kcd2_dice_solver/src/lib/lib.test.ts | 109 - kcd2_dice_solver/src/lib/rng.test.ts | 41 - kcd2_dice_solver/src/lib/rng.ts | 38 - kcd2_dice_solver/src/main.tsx | 14 - kcd2_dice_solver/src/test/fixtures.ts | 35 - kcd2_dice_solver/src/test/setup.ts | 1 - kcd2_dice_solver/src/worker/solver.worker.ts | 37 - kcd2_dice_solver/tsconfig.json | 31 - kcd2_dice_solver/vite.config.ts | 28 - .../features/lib/kcd2_autorebuild.sh | 3 +- .../features/setup_kcd2_dice_solver.sh | 5 +- reverse_survivors/.gitignore | 3 - reverse_survivors/README.md | 85 - reverse_survivors/eslint.config.js | 47 - reverse_survivors/index.html | 12 - reverse_survivors/package-lock.json | 4452 ----------------- reverse_survivors/package.json | 41 - reverse_survivors/src/App.test.tsx | 90 - reverse_survivors/src/App.tsx | 102 - reverse_survivors/src/core/director.test.ts | 172 - reverse_survivors/src/core/director.ts | 169 - .../src/core/directorPowers.test.ts | 151 - reverse_survivors/src/core/enemies.ts | 144 - reverse_survivors/src/core/rng.ts | 33 - reverse_survivors/src/core/sim.test.ts | 153 - reverse_survivors/src/core/sim.ts | 69 - reverse_survivors/src/core/simEnemies.test.ts | 234 - reverse_survivors/src/core/simFrenzy.test.ts | 129 - reverse_survivors/src/core/simSurvivor.ts | 133 - reverse_survivors/src/core/simThreats.ts | 170 - reverse_survivors/src/core/simUnit.ts | 44 - reverse_survivors/src/core/simUnits.test.ts | 241 - reverse_survivors/src/core/snapshot.ts | 161 - reverse_survivors/src/core/status.test.ts | 82 - reverse_survivors/src/core/status.ts | 35 - reverse_survivors/src/core/statusKinds.ts | 25 - reverse_survivors/src/core/types.ts | 157 - reverse_survivors/src/core/vec-rng.test.ts | 73 - reverse_survivors/src/core/vec.ts | 18 - reverse_survivors/src/main.test.tsx | 27 - reverse_survivors/src/main.tsx | 14 - reverse_survivors/src/render/draw.test.ts | 86 - reverse_survivors/src/render/draw.ts | 127 - reverse_survivors/src/styles.css | 276 - reverse_survivors/src/test/harness.ts | 56 - reverse_survivors/src/test/setup.ts | 7 - reverse_survivors/src/ui/CanvasStage.tsx | 18 - reverse_survivors/src/ui/Hud.tsx | 78 - reverse_survivors/src/ui/SpawnBar.tsx | 94 - reverse_survivors/src/ui/format.ts | 6 - reverse_survivors/src/ui/panels.test.tsx | 189 - reverse_survivors/src/ui/ui-bits.test.tsx | 71 - reverse_survivors/src/ui/useGameLoop.ts | 26 - reverse_survivors/tsconfig.json | 24 - reverse_survivors/vite.config.ts | 18 - 196 files changed, 84 insertions(+), 28803 deletions(-) delete mode 100644 .github/workflows/reverse-survivors-ci.yml create mode 100644 docs/superpowers/contracts/extract-three-apps-2026-08-23.json create mode 100644 docs/superpowers/evidence/extract-three-apps-2026-08-23.json delete mode 100644 focus_owner/.gitignore delete mode 100644 focus_owner/.metadata delete mode 100644 focus_owner/README.md delete mode 100644 focus_owner/analysis_options.yaml delete mode 100644 focus_owner/android/.gitignore delete mode 100644 focus_owner/android/app/build.gradle.kts delete mode 100644 focus_owner/android/app/src/debug/AndroidManifest.xml delete mode 100644 focus_owner/android/app/src/main/AndroidManifest.xml delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/BootReceiver.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyLocation.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyUninstallGuard.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyVpnDns.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementAlarmReceiver.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementLog.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementScheduler.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementService.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusDeviceAdminReceiver.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationFix.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt delete mode 100644 focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt delete mode 100644 focus_owner/android/app/src/main/res/drawable-v21/launch_background.xml delete mode 100644 focus_owner/android/app/src/main/res/drawable/launch_background.xml delete mode 100644 focus_owner/android/app/src/main/res/values-night/styles.xml delete mode 100644 focus_owner/android/app/src/main/res/values/styles.xml delete mode 100644 focus_owner/android/app/src/main/res/xml/device_admin.xml delete mode 100644 focus_owner/android/app/src/profile/AndroidManifest.xml delete mode 100644 focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionCurfewTest.kt delete mode 100644 focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt delete mode 100644 focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementFixtures.kt delete mode 100644 focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementLogTest.kt delete mode 100644 focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementRunnerBestTest.kt delete mode 100644 focus_owner/android/build.gradle.kts delete mode 100644 focus_owner/android/gradle.properties delete mode 100644 focus_owner/android/gradle/wrapper/gradle-wrapper.properties delete mode 100644 focus_owner/android/settings.gradle.kts delete mode 100644 focus_owner/assets/policy.json delete mode 100644 focus_owner/docs/profile-owner-verification-2026-08-09.md delete mode 100644 focus_owner/lib/debug_log_page.dart delete mode 100644 focus_owner/lib/device_policy.dart delete mode 100644 focus_owner/lib/enforcement_record.dart delete mode 100644 focus_owner/lib/enforcement_status_card.dart delete mode 100644 focus_owner/lib/focus_policy.dart delete mode 100644 focus_owner/lib/home_location.dart delete mode 100644 focus_owner/lib/main.dart delete mode 100644 focus_owner/lib/policy.dart delete mode 100644 focus_owner/lib/status_body.dart delete mode 100644 focus_owner/lib/status_card_parts.dart delete mode 100644 focus_owner/lib/status_page_dialogs.dart delete mode 100644 focus_owner/lib/status_page_state.dart delete mode 100644 focus_owner/lib/theme.dart delete mode 100644 focus_owner/lib/workout_signal.dart delete mode 100644 focus_owner/pubspec.lock delete mode 100644 focus_owner/pubspec.yaml delete mode 100755 focus_owner/scripts/push_home_location.sh delete mode 100644 focus_owner/test/enforcement_record_test.dart delete mode 100644 focus_owner/test/enforcement_status_card_test.dart delete mode 100644 focus_owner/test/home_location_test.dart delete mode 100644 focus_owner/test/policy_decisions_test.dart delete mode 100644 focus_owner/test/policy_test.dart delete mode 100644 focus_owner/test/status_page_actions_test.dart delete mode 100644 focus_owner/test/status_page_test.dart delete mode 100644 focus_owner/test/widget_test.dart delete mode 100644 focus_owner/test/workout_signal_test.dart delete mode 100644 kcd2_dice_solver/.gitignore delete mode 100644 kcd2_dice_solver/README.md delete mode 100755 kcd2_dice_solver/deploy_build.sh delete mode 100644 kcd2_dice_solver/eslint.config.js delete mode 100644 kcd2_dice_solver/index.html delete mode 100644 kcd2_dice_solver/package.json delete mode 100644 kcd2_dice_solver/pnpm-lock.yaml delete mode 100755 kcd2_dice_solver/run.sh delete mode 100644 kcd2_dice_solver/src/App.tsx delete mode 100644 kcd2_dice_solver/src/AppLinks.test.tsx delete mode 100644 kcd2_dice_solver/src/AppRecovery.test.tsx delete mode 100644 kcd2_dice_solver/src/AppShell.test.tsx delete mode 100644 kcd2_dice_solver/src/AppStorage.test.tsx delete mode 100644 kcd2_dice_solver/src/components/BadgePicker.tsx delete mode 100644 kcd2_dice_solver/src/components/DiceList.tsx delete mode 100644 kcd2_dice_solver/src/components/DieRow.tsx delete mode 100644 kcd2_dice_solver/src/components/InventoryIo.tsx delete mode 100644 kcd2_dice_solver/src/components/QuantityStepper.tsx delete mode 100644 kcd2_dice_solver/src/components/ResultPanel.tsx delete mode 100644 kcd2_dice_solver/src/components/componentInventory.test.tsx delete mode 100644 kcd2_dice_solver/src/components/componentPickers.test.tsx delete mode 100644 kcd2_dice_solver/src/components/componentResult.test.tsx delete mode 100644 kcd2_dice_solver/src/components/componentRows.test.tsx delete mode 100644 kcd2_dice_solver/src/components/components.test.tsx delete mode 100644 kcd2_dice_solver/src/core/badgeBaseline.ts delete mode 100644 kcd2_dice_solver/src/core/badgeValue.test.ts delete mode 100644 kcd2_dice_solver/src/core/badgeValue.ts delete mode 100644 kcd2_dice_solver/src/core/counts.ts delete mode 100644 kcd2_dice_solver/src/core/distribution.ts delete mode 100644 kcd2_dice_solver/src/core/evaluate.test.ts delete mode 100644 kcd2_dice_solver/src/core/evaluate.ts delete mode 100644 kcd2_dice_solver/src/core/scoring.test.ts delete mode 100644 kcd2_dice_solver/src/core/scoring.ts delete mode 100644 kcd2_dice_solver/src/core/scoringMoves.ts delete mode 100644 kcd2_dice_solver/src/core/search.test.ts delete mode 100644 kcd2_dice_solver/src/core/search.ts delete mode 100644 kcd2_dice_solver/src/core/searchGroups.ts delete mode 100644 kcd2_dice_solver/src/core/searchStrategies.ts delete mode 100644 kcd2_dice_solver/src/core/simulate.test.ts delete mode 100644 kcd2_dice_solver/src/core/simulate.ts delete mode 100644 kcd2_dice_solver/src/core/simulateTurns.test.ts delete mode 100644 kcd2_dice_solver/src/core/simulateTurns.ts delete mode 100644 kcd2_dice_solver/src/core/solve.demo.test.ts delete mode 100644 kcd2_dice_solver/src/core/solve.test.ts delete mode 100644 kcd2_dice_solver/src/core/solve.ts delete mode 100644 kcd2_dice_solver/src/data/badgeTable.ts delete mode 100644 kcd2_dice_solver/src/data/badgeTableGold.ts delete mode 100644 kcd2_dice_solver/src/data/badges.ts delete mode 100644 kcd2_dice_solver/src/data/data.test.ts delete mode 100644 kcd2_dice_solver/src/data/dice.ts delete mode 100644 kcd2_dice_solver/src/data/headstart.ts delete mode 100644 kcd2_dice_solver/src/data/rawDice.ts delete mode 100644 kcd2_dice_solver/src/data/rawDiceAM.ts delete mode 100644 kcd2_dice_solver/src/data/rawDiceNZ.ts delete mode 100644 kcd2_dice_solver/src/hooks/useInventoryState.ts delete mode 100644 kcd2_dice_solver/src/hooks/useSolver.test.tsx delete mode 100644 kcd2_dice_solver/src/hooks/useSolver.ts delete mode 100644 kcd2_dice_solver/src/index.css delete mode 100644 kcd2_dice_solver/src/lib/fuzzy.test.ts delete mode 100644 kcd2_dice_solver/src/lib/fuzzy.ts delete mode 100644 kcd2_dice_solver/src/lib/inventoryHash.test.ts delete mode 100644 kcd2_dice_solver/src/lib/inventoryHash.ts delete mode 100644 kcd2_dice_solver/src/lib/inventoryIo.test.ts delete mode 100644 kcd2_dice_solver/src/lib/inventoryIo.ts delete mode 100644 kcd2_dice_solver/src/lib/lib.test.ts delete mode 100644 kcd2_dice_solver/src/lib/rng.test.ts delete mode 100644 kcd2_dice_solver/src/lib/rng.ts delete mode 100644 kcd2_dice_solver/src/main.tsx delete mode 100644 kcd2_dice_solver/src/test/fixtures.ts delete mode 100644 kcd2_dice_solver/src/test/setup.ts delete mode 100644 kcd2_dice_solver/src/worker/solver.worker.ts delete mode 100644 kcd2_dice_solver/tsconfig.json delete mode 100644 kcd2_dice_solver/vite.config.ts delete mode 100644 reverse_survivors/.gitignore delete mode 100644 reverse_survivors/README.md delete mode 100644 reverse_survivors/eslint.config.js delete mode 100644 reverse_survivors/index.html delete mode 100644 reverse_survivors/package-lock.json delete mode 100644 reverse_survivors/package.json delete mode 100644 reverse_survivors/src/App.test.tsx delete mode 100644 reverse_survivors/src/App.tsx delete mode 100644 reverse_survivors/src/core/director.test.ts delete mode 100644 reverse_survivors/src/core/director.ts delete mode 100644 reverse_survivors/src/core/directorPowers.test.ts delete mode 100644 reverse_survivors/src/core/enemies.ts delete mode 100644 reverse_survivors/src/core/rng.ts delete mode 100644 reverse_survivors/src/core/sim.test.ts delete mode 100644 reverse_survivors/src/core/sim.ts delete mode 100644 reverse_survivors/src/core/simEnemies.test.ts delete mode 100644 reverse_survivors/src/core/simFrenzy.test.ts delete mode 100644 reverse_survivors/src/core/simSurvivor.ts delete mode 100644 reverse_survivors/src/core/simThreats.ts delete mode 100644 reverse_survivors/src/core/simUnit.ts delete mode 100644 reverse_survivors/src/core/simUnits.test.ts delete mode 100644 reverse_survivors/src/core/snapshot.ts delete mode 100644 reverse_survivors/src/core/status.test.ts delete mode 100644 reverse_survivors/src/core/status.ts delete mode 100644 reverse_survivors/src/core/statusKinds.ts delete mode 100644 reverse_survivors/src/core/types.ts delete mode 100644 reverse_survivors/src/core/vec-rng.test.ts delete mode 100644 reverse_survivors/src/core/vec.ts delete mode 100644 reverse_survivors/src/main.test.tsx delete mode 100644 reverse_survivors/src/main.tsx delete mode 100644 reverse_survivors/src/render/draw.test.ts delete mode 100644 reverse_survivors/src/render/draw.ts delete mode 100644 reverse_survivors/src/styles.css delete mode 100644 reverse_survivors/src/test/harness.ts delete mode 100644 reverse_survivors/src/test/setup.ts delete mode 100644 reverse_survivors/src/ui/CanvasStage.tsx delete mode 100644 reverse_survivors/src/ui/Hud.tsx delete mode 100644 reverse_survivors/src/ui/SpawnBar.tsx delete mode 100644 reverse_survivors/src/ui/format.ts delete mode 100644 reverse_survivors/src/ui/panels.test.tsx delete mode 100644 reverse_survivors/src/ui/ui-bits.test.tsx delete mode 100644 reverse_survivors/src/ui/useGameLoop.ts delete mode 100644 reverse_survivors/tsconfig.json delete mode 100644 reverse_survivors/vite.config.ts diff --git a/.github/workflows/reverse-survivors-ci.yml b/.github/workflows/reverse-survivors-ci.yml deleted file mode 100644 index 72bafef5..00000000 --- a/.github/workflows/reverse-survivors-ci.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: reverse-survivors CI - -# Relocated here from reverse_survivors/.github/workflows/ci.yml when the game -# moved into this monorepo — GitHub only reads workflows from the repository -# root, so the nested copy would have been inert. - -on: - push: - branches: [main] - paths: - - "reverse_survivors/**" - - ".github/workflows/reverse-survivors-ci.yml" - pull_request: - branches: [main] - paths: - - "reverse_survivors/**" - - ".github/workflows/reverse-survivors-ci.yml" - -defaults: - run: - working-directory: reverse_survivors - -jobs: - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: reverse_survivors/package-lock.json - - run: npm ci - - run: npm run check - - run: npm run build diff --git a/.prettierignore b/.prettierignore index a2bec95e..248a56d7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -19,4 +19,3 @@ billsplit/test/fixtures/ # Generated by `python3 -m python_pkg.focus_policy` from phone_focus_mode/config.sh. # json.dumps cannot reproduce prettier's single-line-short-array style, so # formatting it here would conflict with every regeneration. -focus_owner/assets/policy.json diff --git a/AGENTS.md b/AGENTS.md index 576f15e6..a27699bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,9 +11,6 @@ Mixed-language monorepo: Python packages, Bash automation, and several apps. | `linux_configuration/` | Arch setup, i3 config, system maintenance, and the tracked git hooks | | `phone_focus_mode/` | GPS-based Android focus enforcer (Bash, ADB, Magisk) | | `billsplit/` | Receipt bill-splitting Flutter app (Dart) | -| `reverse_survivors/` | React/Vite game — you play the horde (TypeScript) | -| `kcd2_dice_solver/` | KCD2 dice solver | -| `focus_owner/` | Android Device-Owner focus enforcer (Kotlin) | | `bottles/` | Small standalone project | | `docs/` | Reference docs; `docs/superpowers/` holds AI workflow artifacts | | `third_party/` | Vendored upstream skills/agents | @@ -27,7 +24,10 @@ Extracted to their own repos: [`steam-backlog-enforcer`](https://github.com/kuhy [`diet-guard`](https://github.com/kuhyx/diet-guard), [`wake-alarm`](https://github.com/kuhyx/wake-alarm), [`dufs-cloud`](https://github.com/kuhyx/dufs-cloud), -[`build-your-x`](https://github.com/kuhyx/build-your-x) (lives at `~/build_your_x`). +[`build-your-x`](https://github.com/kuhyx/build-your-x) (lives at `~/build_your_x`), +[`reverse-survivors`](https://github.com/kuhyx/reverse-survivors), +[`kcd2-dice-solver`](https://github.com/kuhyx/kcd2-dice-solver) (lives at `~/kcd2-dice-solver`), +[`focus-owner`](https://github.com/kuhyx/focus-owner). Archived work: [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive). ## Adding a Flutter app diff --git a/docs/superpowers/contracts/extract-three-apps-2026-08-23.json b/docs/superpowers/contracts/extract-three-apps-2026-08-23.json new file mode 100644 index 00000000..50d7027c --- /dev/null +++ b/docs/superpowers/contracts/extract-three-apps-2026-08-23.json @@ -0,0 +1,20 @@ +{ + "title": "Extract reverse_survivors, kcd2_dice_solver and focus_owner to their own public repos", + "objective": "Move three self-contained applications out of testsAndMisc into their own repositories with full history, give each real CI it never had in the monorepo, and repoint every consumer the monorepo keeps - including the live systemd unit and Docker bind mount that deploy the dice solver.", + "acceptance_criteria": [ + "Each unit is a public GitHub repo on branch main whose commit count equals the monorepo's count for that path (8, 14, 30).", + "Each extracted repo passes its own toolchain from a clean clone: npm check+build; pnpm lint+test+build; flutter analyze --fatal-infos + test.", + "Each extracted repo carries a CI workflow that runs those same commands with no paths filter and no working-directory.", + "kcd2-dice-build.service starts successfully and exits 0 after being repointed - proven by running it, not by reading it.", + "https://dice.kuhy.duckdns.org/ returns HTTP 200 and serves the bundle hash produced by that run.", + "setup_kcd2_dice_solver.sh builds from the extracted repo, and its generated post-commit hook no longer filters on a kcd2_dice_solver/ path that can never match again.", + "No file outside docs/ or a historical note still points at a removed directory.", + "The repo-wide pytest gate and pre-commit both still pass, without --no-verify." + ], + "out_of_scope": [ + "phone_focus_mode and python_pkg/focus_policy, which move together in a later step.", + "The four system subsystems under periodic_background.", + "Reformatting focus_owner's Dart sources: the committed tree predates the current formatter and 15 files would change, which belongs in its own commit in the new repo." + ], + "verifier": "gh api confirms three public repos; standalone clones pass npm/pnpm/flutter checks; systemctl --user start kcd2-dice-build.service exits 0; curl https://dice.kuhy.duckdns.org/ returns 200 with a bundle hash matching dist/; ./meta/scripts/report_live_state.sh shows no unit pointing at a removed path; pre-commit passes." +} diff --git a/docs/superpowers/evidence/extract-three-apps-2026-08-23.json b/docs/superpowers/evidence/extract-three-apps-2026-08-23.json new file mode 100644 index 00000000..9e76a314 --- /dev/null +++ b/docs/superpowers/evidence/extract-three-apps-2026-08-23.json @@ -0,0 +1,56 @@ +{ + "intent": "Extract reverse_survivors, kcd2_dice_solver and focus_owner into their own public GitHub repositories with full history, and repoint every reference the monorepo kept - including the live kcd2 systemd unit.", + "scope": [ + "reverse_survivors/ (44 files, 8 commits) -> github.com/kuhyx/reverse-survivors", + "kcd2_dice_solver/ (73 files, 14 commits) -> github.com/kuhyx/kcd2-dice-solver, cloned to ~/kcd2-dice-solver", + "focus_owner/ (72 files, 30 commits) -> github.com/kuhyx/focus-owner", + ".github/workflows/reverse-survivors-ci.yml removed; each new repo carries its own CI", + "setup_kcd2_dice_solver.sh + lib/kcd2_autorebuild.sh repointed; the deploy script stays here", + "Non-goal: phone_focus_mode and the four system subsystems, which are separate steps" + ], + "changes": [ + "Each unit extracted with git filter-repo --path-rename /: so it owns its repository root, preserving every commit that touched it.", + "Added a CI workflow to each: npm for reverse-survivors, pnpm for kcd2-dice-solver, flutter for focus-owner. The latter two had no CI in the monorepo at all.", + "focus_owner's two references to python_pkg.focus_policy now name the phone-focus-mode repo, where the generator is going. Regeneration of assets/policy.json stays manual and says why.", + "setup_kcd2_dice_solver.sh now builds from ${HOME}/kcd2-dice-solver instead of ${HOME}/testsAndMisc/kcd2_dice_solver, and its generated post-commit hook drops the `git diff -- kcd2_dice_solver/` path filter, which could never match again once every commit in that repo is solver code.", + "Repointed the live kcd2-dice-build.service user unit and reloaded systemd.", + "Added pnpm-workspace.yaml to the solver repo approving esbuild's build script - found by running the unit, not by reading it." + ], + "verification": [ + { + "command": "npm ci && npm run check && npm run build (reverse-survivors, standalone clone)", + "result": "pass", + "evidence": "100% statements/branches/functions/lines; vite build produced dist/ in 74ms." + }, + { + "command": "pnpm install && pnpm run lint && pnpm run test && pnpm run build (kcd2, standalone clone)", + "result": "pass", + "evidence": "tsc --noEmit and eslint clean; 288 tests in 24 files passed; vite build succeeded." + }, + { + "command": "flutter pub get && flutter analyze --fatal-infos && flutter test (focus-owner, standalone clone)", + "result": "pass", + "evidence": "No issues found; 94 tests passed. Re-run after the two comment edits, same result." + }, + { + "command": "systemctl --user start kcd2-dice-build.service", + "result": "fail then pass", + "evidence": "First run exited 1: ERR_PNPM_IGNORED_BUILDS for esbuild@0.25.12. package.json carried pnpm.onlyBuiltDependencies but pnpm 10 reads the approval from pnpm-workspace.yaml, which was never tracked. Adding it fixed the install. This is exactly the failure that reading the script could not have found." + }, + { + "command": "gh api repos/kuhyx/", + "result": "pass", + "evidence": "All three repos public on default branch main; reverse-survivors CI queued on first push." + } + ], + "risks": [ + "The kcd2 auto-rebuild git hook is installed into the solver repo's .git/hooks by setup_kcd2_dice_solver.sh; a fresh clone of that repo will not have it until the setup script is re-run.", + "focus_owner's policy.json is now a committed artifact whose generator lives in a different repo. Drift is possible if config.sh changes and nobody regenerates; the pubspec comment records the exact command.", + "The extracted repos were verified in a scratch clone, not in the working copies at ~/; only kcd2-dice-solver was cloned locally because a systemd unit needs it." + ], + "rollback": [ + "git revert the removal commit to restore all three trees and the reverse-survivors workflow.", + "Repoint kcd2-dice-build.service back to ${HOME}/testsAndMisc/kcd2_dice_solver and systemctl --user daemon-reload.", + "The new GitHub repos can be left in place; they do not affect the monorepo once the trees are restored." + ] +} diff --git a/focus_owner/.gitignore b/focus_owner/.gitignore deleted file mode 100644 index 3820a95c..00000000 --- a/focus_owner/.gitignore +++ /dev/null @@ -1,45 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/focus_owner/.metadata b/focus_owner/.metadata deleted file mode 100644 index d312f635..00000000 --- a/focus_owner/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: android - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/focus_owner/README.md b/focus_owner/README.md deleted file mode 100644 index d20ae622..00000000 --- a/focus_owner/README.md +++ /dev/null @@ -1,142 +0,0 @@ -# focus_owner - -Device Owner app that enforces focus mode on an unrooted Pixel 6a -(`23181JEGR08034`). It hides apps by GPS distance from home, plus a 23:00–05:00 -night curfew, and keeps YouTube hidden everywhere. - -**Live and enforcing.** It is provisioned as device owner, pins RethinkDNS as an -always-on VPN with lockdown, and blocks its own uninstall while enforcing. - -## How a decision is made - -One pass runs every 15 minutes (and at each curfew boundary), from -`EnforcementService` → `EnforcementRunner.decide()` → `EnforcementDecision.evaluate()`. - -There are **five** outcomes, and only the first two look different from the -outside — which is exactly what made this hard to debug before the status -screen existed: - -| Reason | What it does | -| ------------------ | ----------------------------------------------------------------------------------- | -| `AWAY` | Hides only `always_blocked_packages`; everything else is available. | -| `AT_HOME` | Full day allowlist sweep. | -| `CURFEW` | Full sweep against the shorter night allowlist. | -| `LOCATION_UNKNOWN` | **Identical sweep to `AT_HOME`** — fail-closed, so losing GPS is not an off switch. | -| `WORKOUT` | As at home, plus the workout exemptions. | - -Plus a sixth state that is not a reason: **no home configured**, which makes the -fence answer "inside" everywhere and so enforces everywhere. The status screen -renders that case explicitly rather than letting it read as `AT_HOME`. - -`always_blocked_packages` (YouTube, YouTube Music, Chrome) are exempt from every -branch including `AWAY` and `WORKOUT`, so leaving the house cannot restore them. - -## Diagnosing it - -**Use the in-app screen, not adb.** Neither adb route works here: - -- `run-as` returns `package not debuggable` — device owner requires a release - build, and making it debuggable would let anyone with adb edit this app's - state, which is the bypass the whole design exists to close. -- logcat rotates. Measured 2026-08-14: `adb logcat -d -s FocusOwner:V` came back - **empty** while `dumpsys alarm` showed 82 alarms had fired. - -So the durable record is `filesDir/enforcement_log.jsonl`, written by -`EnforcementLog` and read back over the platform channel. The main screen shows -the latest pass — reason, distance vs the fence, fix age/provider/accuracy, -curfew state, and every hidden app with the rule that hid it — and **Debug log** -shows the history. It never records coordinates: distance in metres is enough to -debug a geofence, and this is a home address. - -Useful fields when something looks wrong: - -- `fix.outcome` — `CACHED_FRESH` / `ACTIVE_OK` / `TIMEOUT` / `NO_PERMISSION` / - `NO_PROVIDER`. Distinguishes "no fix at the office" from "fuzzed fix at home". -- `fix.accuracy_m` — tens of metres means precise location is live. Hundreds or - thousands means the app is being fed fuzzed coarse location and the 150 m - fence cannot be trusted. -- `failure` — set on passes that decided nothing (not provisioned, unreadable - policy), which otherwise leave no trace at all. - -## Location - -The geofence needs `ACCESS_FINE_LOCATION`; coarse is fuzzed to a ~1–2 km grid on -Android 12+, against a 150 m radius. The app **self-grants** fine, coarse and -background location every pass via `setPermissionGrantState` (device owner needs -no prompt), and calls `setLocationEnabled(true)`. - -Each pass actively requests a fix rather than reading whatever another app -cached, bounded by `ACQUIRE_TIMEOUT_MS` (20 s) and run inside a -`location`-typed foreground service — background location delivery is throttled -too hard otherwise. A cached fix younger than `FRESH_WINDOW_MS` (30 min) is used -as-is; anything older is treated as no fix and fails closed. That window is -deliberately **longer than the 15-minute cadence**: a shorter one would mark the -newest available cache stale on every pass of an idle phone and block everything. - -## Policy - -Generated from `phone_focus_mode/config.sh`, never hand-edited: - -```bash -cd ~/testsAndMisc -python3 -m python_pkg.focus_policy \ - --config phone_focus_mode/config.sh \ - --output focus_owner/assets/policy.json --redact-home -``` - -`--redact-home` is required — the committed asset must keep `latitude`/ -`longitude` null. Real coordinates live only in app-private storage, written by -"Set home to current location" in the app. - -Allowlists are exact-match, plus `allowed_prefixes` / `night_allowed_prefixes` -for apps that ship as a package family (Tachiyomi installs every source as its -own apk). Prefixes match whole labels, so `eu.kanade.tachiyomi` covers -`eu.kanade.tachiyomi.extension.all.mangadex` but not `eu.kanade.tachiyomisomething`. - -**Sweepable is not the same as hideable.** `blockable_system_packages` decides -which `FLAG_SYSTEM` packages are _eligible for a decision_; `isAllowed` is what -then protects them. Removing an allowed system package from that list does not -protect it, it **freezes** it in whatever state it was last left in — it lands -in neither `packagesToHide` nor `packagesToShow`. Play Store is the live example. - -## Build and verify - -```bash -cd ~/testsAndMisc/focus_owner -flutter analyze && flutter test -JAVA_HOME=/usr/lib/jvm/java-21-openjdk (cd android && ./gradlew :app:testDebugUnitTest) -JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --release -adb -s 23181JEGR08034 install -r build/app/outputs/flutter-apk/app-release.apk -adb -s 23181JEGR08034 shell am start -n com.kuhy.focus_owner/.MainActivity -``` - -`JAVA_HOME` is required: the system default JDK 26 breaks the Android Gradle -plugin's `jlink` transform. - -Release build only — device owner refuses a debuggable one. Then tap **Run -enforcement now** and read the status card; `EnforcementService` is not -exported, so it cannot be started from adb. - -> **Never** `adb uninstall`, `pm clear`, or wipe this app's data. Uninstalling a -> device owner strands ownership with no holder and a factory reset becomes the -> only exit. `install -r` is safe and preserves the home location. - -## Why the escape hatch ships first - -`dpm remove-active-admin` does not work on a device owner. The only exit that -avoids a factory reset is `DevicePolicyManager.clearDeviceOwnerApp()`, callable -solely by this package on itself. So `releaseDeviceOwner()` is implemented, -unit-tested, and reachable from the main screen with no PC attached. - -Accounts now exist on the device, so `dpm set-device-owner` would refuse to run -again: releasing is a one-way door until a wipe. The confirmation dialog says so -in those terms. See -`~/.claude/projects/-home-kuhy-testsAndMisc/memory/device-owner-not-root-banking-works.md`. - -## Known gap - -The network layer does not filter `youtube.com`. `private_dns_host` is empty -(the router does not forward 853, and under VPN lockdown app DNS goes to the -VPN's own resolver anyway), and RethinkDNS's blocklists can be switched off from -inside that app. So a browser reaches YouTube on the web **when away from home**. -At home and during curfew every browser is hidden, which is what closes it there. diff --git a/focus_owner/analysis_options.yaml b/focus_owner/analysis_options.yaml deleted file mode 100644 index bf8d4218..00000000 --- a/focus_owner/analysis_options.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -analyzer: - exclude: - - build/** - - android/** - - ios/** - - web/** - - windows/** - - macos/** - - linux/** -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/focus_owner/android/.gitignore b/focus_owner/android/.gitignore deleted file mode 100644 index be3943c9..00000000 --- a/focus_owner/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/focus_owner/android/app/build.gradle.kts b/focus_owner/android/app/build.gradle.kts deleted file mode 100644 index 851e3009..00000000 --- a/focus_owner/android/app/build.gradle.kts +++ /dev/null @@ -1,82 +0,0 @@ -import java.util.Properties - -plugins { - id("com.android.application") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -// Release signing key, kept out of the repo, shared with the other apps. -// -// This matters more here than elsewhere: a device owner can only be replaced -// by an APK with the same signature, and `dpm remove-active-admin` does not -// work on one. Provisioning a debug-signed build would mean the app could -// never be updated, and the only exit from a stale device owner that cannot -// release itself is another factory reset. -val keystoreProperties = Properties().apply { - val f = rootProject.file("key.properties") - if (f.exists()) f.inputStream().use { load(it) } -} - -android { - namespace = "com.kuhy.focus_owner" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.kuhy.focus_owner" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - signingConfigs { - if (keystoreProperties.getProperty("storeFile") != null) { - create("release") { - storeFile = file(keystoreProperties.getProperty("storeFile")) - storePassword = keystoreProperties.getProperty("storePassword") - keyAlias = keystoreProperties.getProperty("keyAlias") - keyPassword = keystoreProperties.getProperty("keyPassword") - } - } - } - - buildTypes { - release { - // Falls back to the debug key only when key.properties is absent - // (a fresh clone), so `flutter run --release` still works. - signingConfig = signingConfigs.findByName("release") - ?: signingConfigs.getByName("debug") - // Deliberately no minify/shrink. An untested ProGuard pass that - // strips a DevicePolicyManager callback would produce a device - // owner that cannot release itself, and that costs a factory - // reset to undo. - } - } -} - -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } -} - -flutter { - source = "../.." -} - -dependencies { - // org.json is an Android stub on the JVM classpath, so unit tests need a - // real implementation to parse the policy asset. - testImplementation("org.json:json:20240303") - testImplementation("junit:junit:4.13.2") -} diff --git a/focus_owner/android/app/src/debug/AndroidManifest.xml b/focus_owner/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/focus_owner/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/focus_owner/android/app/src/main/AndroidManifest.xml b/focus_owner/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 5ddb6816..00000000 --- a/focus_owner/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/BootReceiver.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/BootReceiver.kt deleted file mode 100644 index 26530804..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/BootReceiver.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.kuhy.focus_owner - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.UserManager -import android.util.Log - -/** - * Re-arms enforcement after a reboot. - * - * Two actions, because one is not enough. `BOOT_COMPLETED` does not arrive - * until the user first unlocks the device — measured on this phone, where the - * user stayed `RUNNING_LOCKED` for several minutes after a restart with no - * network and no app able to start. Relying on it alone means enforcement - * state is frozen at whatever it was before the reboot until someone unlocks. - * - * That freeze is safe in one direction and not the other. Apps stay hidden, so - * nothing leaks — but apps that *should* have been restored stay hidden too, so - * a reboot during curfew could leave the dialer missing at breakfast. - * `LOCKED_BOOT_COMPLETED` fires before unlock, so the schedule is re-armed as - * early as the system allows and the first post-unlock run repairs the rest. - */ -class BootReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - // MY_PACKAGE_REPLACED for the same reason as the boot actions: - // installing over the app cancels its pending alarms, so without - // it the chain stays dead until someone opens the app. It arrives - // only for this package and only after the install completes. - Intent.ACTION_LOCKED_BOOT_COMPLETED, - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED, - -> { - val unlocked = context.getSystemService(UserManager::class.java) - ?.isUserUnlocked ?: false - Log.i( - FocusDeviceAdminReceiver.TAG, - "boot: ${intent.action} unlocked=$unlocked", - ) - // Always re-arm the alarm: it is device-encryption safe and - // cheap. Only run enforcement once storage is readable, since - // the policy asset and the coordinates live in credential- - // encrypted storage and are unreadable before unlock. - EnforcementScheduler(context).scheduleNext() - if (unlocked) EnforcementService.start(context) - } - else -> Log.w(FocusDeviceAdminReceiver.TAG, "unexpected ${intent.action}") - } - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt deleted file mode 100644 index 1fd374b0..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.kuhy.focus_owner - -import android.app.admin.DevicePolicyManager -import android.content.ComponentName -import android.content.Context -import android.os.Build -import android.util.Log - -/** - * Thin wrapper over [DevicePolicyManager] exposing only what this build needs: - * reporting provisioning state, and giving up device ownership. - * - * No restriction is applied anywhere in this class. Adding enforcement is a - * later, separate step; see [FocusDeviceAdminReceiver] for why the release path - * ships and is verified first. - */ -class DevicePolicyBridge(private val context: Context) { - - private val dpm: DevicePolicyManager = - context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager - - private val admin: ComponentName = - ComponentName(context, FocusDeviceAdminReceiver::class.java) - - /** VPN-pinning and Private-DNS-pinning calls; see [DevicePolicyVpnDns]. */ - private val vpnDns: DevicePolicyVpnDns = - DevicePolicyVpnDns(dpm, admin, isDeviceOwner = ::isDeviceOwner) - - /** Location-permission and location-services calls; see [DevicePolicyLocation]. */ - private val location: DevicePolicyLocation = DevicePolicyLocation( - context, - dpm, - admin, - isDeviceOwnerOrProfileOwner = { isDeviceOwner() || isProfileOwner() }, - isDeviceOwner = ::isDeviceOwner, - ) - - /** Uninstall-blocking calls; see [DevicePolicyUninstallGuard]. */ - private val uninstallGuard: DevicePolicyUninstallGuard = - DevicePolicyUninstallGuard(context, dpm, admin, isDeviceOwner = ::isDeviceOwner) - - /** Whether this package currently holds device owner. */ - fun isDeviceOwner(): Boolean = dpm.isDeviceOwnerApp(context.packageName) - - /** Whether this package is profile owner (a work profile, not the device). */ - fun isProfileOwner(): Boolean = dpm.isProfileOwnerApp(context.packageName) - - /** - * Hides or unhides a package, the DPM equivalent of `pm disable-user`. - * - * This is the mechanism the whole Device Owner design rests on, and unlike - * `pm suspend` and `pm disable-user` — both measured to be cleared by a - * reboot on this device — it is expected to persist. That expectation is - * the thing worth testing before a factory reset is spent on it. - * - * Requires device owner or profile owner; returns false when held by - * neither, rather than throwing. - */ - fun setApplicationHidden(packageName: String, hidden: Boolean): Boolean { - if (!isDeviceOwner() && !isProfileOwner()) { - Log.w(FocusDeviceAdminReceiver.TAG, "not DO/PO; cannot hide $packageName") - return false - } - return runCatching { - val applied = dpm.setApplicationHidden(admin, packageName, hidden) - Log.i(FocusDeviceAdminReceiver.TAG, "setApplicationHidden($packageName,$hidden)=$applied") - applied - }.getOrElse { error -> - Log.e(FocusDeviceAdminReceiver.TAG, "setApplicationHidden failed", error) - false - } - } - - /** Self-grants the location permissions the geofence needs. See [DevicePolicyLocation.grantLocationPermissions]. */ - fun grantLocationPermissions(): Set = location.grantLocationPermissions() - - /** Turns location services on, so a fix is obtainable at all. */ - fun enableLocation(): Boolean = location.enableLocation() - - /** Whether a package is currently hidden by this admin. */ - fun isApplicationHidden(packageName: String): Boolean { - if (!isDeviceOwner() && !isProfileOwner()) return false - return runCatching { dpm.isApplicationHidden(admin, packageName) } - .getOrDefault(false) - } - - /** Whether the admin component is active (a weaker state than ownership). */ - fun isAdminActive(): Boolean = dpm.isAdminActive(admin) - - /** - * Whether any account exists, which decides if releasing is reversible. - * - * `dpm set-device-owner` refuses to run while the device has accounts, so - * once one is added, releasing ownership can only be undone by a factory - * reset. The release dialog says so, and it must say so truthfully rather - * than assume: during provisioning the count is zero and releasing is - * cheap, which is exactly when the escape hatch should be exercised. - */ - fun hasAccounts(): Boolean = runCatching { - // Measured on device rather than assumed: with GET_ACCOUNTS undeclared - // this returned 5 accounts while dumpsys reported 6, so the call works - // without the runtime permission but does not necessarily see every - // account. Only "any account at all" is needed here, so the visibility - // gap does not matter -- and a permission prompt would be a heavy - // price for one warning string. - android.accounts.AccountManager.get(context) - .getAccountsByType(null) - .isNotEmpty() - }.getOrElse { error -> - // Fail toward the scarier message: claiming a release is cheap when it - // is not is the more damaging of the two possible wrong answers. - Log.w(FocusDeviceAdminReceiver.TAG, "could not read accounts", error) - true - } - - /** Blocks or unblocks uninstalling this app. See [DevicePolicyUninstallGuard.setSelfUninstallBlocked]. */ - fun setSelfUninstallBlocked(blocked: Boolean): Boolean = - uninstallGuard.setSelfUninstallBlocked(blocked) - - /** Whether uninstalling this app is currently blocked. */ - fun isSelfUninstallBlocked(): Boolean = uninstallGuard.isSelfUninstallBlocked() - - /** Blocks uninstalling an arbitrary package. See [DevicePolicyUninstallGuard.setPackageUninstallBlocked]. */ - fun setPackageUninstallBlocked(packageName: String, blocked: Boolean): Boolean = - uninstallGuard.setPackageUninstallBlocked(packageName, blocked) - - /** Pins [vpnPackage] as the always-on VPN. See [DevicePolicyVpnDns.setAlwaysOnVpn]. */ - fun setAlwaysOnVpn(vpnPackage: String, lockdown: Boolean = false): String? = - vpnDns.setAlwaysOnVpn(vpnPackage, lockdown) - - /** Pins Private DNS to [host]. See [DevicePolicyVpnDns.setPrivateDns]. */ - fun setPrivateDns(host: String): String? = vpnDns.setPrivateDns(host) - - /** The Private DNS host currently pinned, or null. */ - fun privateDnsHost(): String? = vpnDns.privateDnsHost() - - /** Blocks or unblocks user changes to Private DNS. */ - fun setPrivateDnsConfigBlocked(blocked: Boolean): Boolean = - vpnDns.setPrivateDnsConfigBlocked(blocked) - - /** The package currently pinned as always-on VPN, or null. */ - fun alwaysOnVpnPackage(): String? = vpnDns.alwaysOnVpnPackage() - - /** Prevents the user from changing VPN configuration. See [DevicePolicyVpnDns.setVpnConfigBlocked]. */ - fun setVpnConfigBlocked(blocked: Boolean): Boolean = vpnDns.setVpnConfigBlocked(blocked) - - /** - * Describes provisioning state for the UI, so the escape hatch can be found - * without a PC attached. - */ - fun status(): Map = mapOf( - "packageName" to context.packageName, - "isDeviceOwner" to isDeviceOwner(), - "isAdminActive" to isAdminActive(), - "sdkInt" to Build.VERSION.SDK_INT, - "restrictionsApplied" to false, - "uninstallBlocked" to isSelfUninstallBlocked(), - "alwaysOnVpn" to (alwaysOnVpnPackage() ?: ""), - "hasAccounts" to hasAccounts(), - ) - - /** - * Relinquishes device ownership without wiping the device. - * - * This is the only exit that preserves user data: `dpm remove-active-admin` - * refuses to act on a device owner, and a factory reset is the sole - * alternative — one that `DISALLOW_FACTORY_RESET` could itself remove. - * - * [DevicePolicyManager.clearDeviceOwnerApp] is deprecated (API 26) but - * still functional, and it is callable only by the owning package on - * itself, which is exactly why this method has to live inside the app. - * - * @return true when ownership was released or was never held. - */ - @Suppress("DEPRECATION") - fun releaseDeviceOwner(): Boolean { - if (!isDeviceOwner()) { - Log.i(FocusDeviceAdminReceiver.TAG, "Not device owner; nothing to release") - return true - } - return runCatching { - // Order matters and is not interchangeable: every one of these is a - // device-owner power, so clearing ownership first would leave them - // in place with nothing privileged left to lift them — an app that - // owns nothing and cannot be removed, and a pinned VPN nobody can - // reconfigure. Undo the restrictions, then release. - setSelfUninstallBlocked(false) - setVpnConfigBlocked(false) - // Leaving Private DNS pinned to a resolver the user cannot change - // would outlive the ownership required to unpin it, and if that - // host ever stops answering the device has no DNS at all. - setPrivateDnsConfigBlocked(false) - runCatching { dpm.setGlobalPrivateDnsModeOpportunistic(admin) } - .onFailure { - Log.w(FocusDeviceAdminReceiver.TAG, "could not reset Private DNS", it) - } - // Read the pinned package BEFORE unpinning: afterwards the getter - // returns null and the provider would stay permanently - // unremovable, which is the exact trap this block exists to avoid. - val vpn = vpnDns.alwaysOnVpnBlockedPackage - // Unpinning clears lockdown with it, which is what restores - // connectivity if the VPN app is the thing that broke. - runCatching { dpm.setAlwaysOnVpnPackage(admin, null, false) } - .onFailure { - Log.w(FocusDeviceAdminReceiver.TAG, "could not unpin VPN", it) - } - vpn?.let { setPackageUninstallBlocked(it, false) } - dpm.clearDeviceOwnerApp(context.packageName) - val released = !isDeviceOwner() - Log.i(FocusDeviceAdminReceiver.TAG, "clearDeviceOwnerApp -> released=$released") - released - }.getOrElse { error -> - // Never rethrow: the UI button that calls this is the last resort - // before a factory reset, so it must always render an outcome. - Log.e(FocusDeviceAdminReceiver.TAG, "clearDeviceOwnerApp failed", error) - false - } - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyLocation.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyLocation.kt deleted file mode 100644 index 407d513d..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyLocation.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.kuhy.focus_owner - -import android.app.admin.DevicePolicyManager -import android.content.ComponentName -import android.content.Context -import android.util.Log - -/** - * The location-permission and location-services half of [DevicePolicyBridge]. - * - * Split out to keep [DevicePolicyBridge] under the 250-line cap. Owns the - * self-grant-permissions and enable-location DPM calls the geofence pass - * depends on, while [DevicePolicyBridge] keeps ownership/uninstall/hide/VPN - * concerns and delegates these calls to an instance of this class. - */ -internal class DevicePolicyLocation( - private val context: Context, - private val dpm: DevicePolicyManager, - private val admin: ComponentName, - private val isDeviceOwnerOrProfileOwner: () -> Boolean, - private val isDeviceOwner: () -> Boolean, -) { - - /** - * Self-grants the location permissions the geofence needs. - * - * A device owner can grant its own runtime permissions with no user - * prompt, which matters here for two reasons. First, FINE is required: - * COARSE is fuzzed to a ~1-2 km grid on Android 12+, against a 150 m - * fence, so a coarse-only build misclassifies home as AWAY and restores - * everything the geofence exists to hide. Second, background location was - * previously granted by hand over adb, which is invisible state that a - * reinstall silently loses -- and losing it fails closed, blocking - * everything with no on-screen explanation. - * - * Re-asserted on every pass rather than once at provisioning, matching the - * VPN and private-DNS pins: the protection then self-heals instead of - * depending on anyone remembering. - * - * @return the permissions that are granted after this call. - */ - fun grantLocationPermissions(): Set { - if (!isDeviceOwnerOrProfileOwner()) return emptySet() - return LOCATION_PERMISSIONS.filterTo(mutableSetOf()) { permission -> - runCatching { - dpm.setPermissionGrantState( - admin, - context.packageName, - permission, - DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED, - ) - // Read back rather than trusting the setter: setPermissionGrantState - // returns false for a permission the platform refuses to - // auto-grant, and ACCESS_BACKGROUND_LOCATION is exactly the - // kind of permission that can be refused. - dpm.getPermissionGrantState(admin, context.packageName, permission) == - DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED - }.getOrElse { error -> - Log.w(FocusDeviceAdminReceiver.TAG, "could not grant $permission", error) - false - } - } - } - - /** Turns location services on, so a fix is obtainable at all. */ - fun enableLocation(): Boolean { - if (!isDeviceOwner()) return false - return runCatching { - dpm.setLocationEnabled(admin, true) - true - }.getOrElse { error -> - // Not fatal: the pass still runs and fails closed without a fix. - Log.w(FocusDeviceAdminReceiver.TAG, "could not enable location", error) - false - } - } - - private companion object { - /** - * Location permissions self-granted every pass. - * - * Ordered coarse-then-fine-then-background because that is the order - * the platform expects them to be escalated in; granting background - * without a foreground grant in place is refused. - */ - val LOCATION_PERMISSIONS = listOf( - android.Manifest.permission.ACCESS_COARSE_LOCATION, - android.Manifest.permission.ACCESS_FINE_LOCATION, - android.Manifest.permission.ACCESS_BACKGROUND_LOCATION, - ) - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyUninstallGuard.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyUninstallGuard.kt deleted file mode 100644 index 171ddf87..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyUninstallGuard.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.kuhy.focus_owner - -import android.app.admin.DevicePolicyManager -import android.content.ComponentName -import android.content.Context -import android.util.Log - -/** - * The uninstall-blocking half of [DevicePolicyBridge]. - * - * Split out to keep [DevicePolicyBridge] under the 250-line cap. Owns the - * uninstall-block DPM calls for both this app (the soft-brick guard) and an - * arbitrary package (used for the VPN provider), while [DevicePolicyBridge] - * keeps ownership/hide/VPN/location concerns and delegates these calls to an - * instance of this class. - */ -internal class DevicePolicyUninstallGuard( - private val context: Context, - private val dpm: DevicePolicyManager, - private val admin: ComponentName, - private val isDeviceOwner: () -> Boolean, -) { - - /** - * Blocks or unblocks uninstalling this app. - * - * Device owner already hides Settings' uninstall button, but `adb uninstall` - * still works without this, and that path is the soft-brick: removing the - * app without releasing first strands ownership with no holder, and a - * factory reset becomes the only exit. - * - * Self-targeted deliberately. Blocking uninstall of *other* packages is a - * separate concern and is not what this protects. - */ - fun setSelfUninstallBlocked(blocked: Boolean): Boolean { - if (!isDeviceOwner()) { - Log.w(FocusDeviceAdminReceiver.TAG, "not DO; cannot change uninstall block") - return false - } - return runCatching { - dpm.setUninstallBlocked(admin, context.packageName, blocked) - val applied = dpm.isUninstallBlocked(admin, context.packageName) - Log.i(FocusDeviceAdminReceiver.TAG, "setUninstallBlocked($blocked) -> $applied") - applied == blocked - }.getOrElse { error -> - Log.e(FocusDeviceAdminReceiver.TAG, "setUninstallBlocked failed", error) - false - } - } - - /** Whether uninstalling this app is currently blocked. */ - fun isSelfUninstallBlocked(): Boolean { - if (!isDeviceOwner()) return false - return runCatching { dpm.isUninstallBlocked(admin, context.packageName) } - .getOrDefault(false) - } - - /** - * Blocks uninstalling an arbitrary package, used for the VPN provider. - * - * Measured hole, not a hypothetical: `pm uninstall --user 0 - * com.celzero.bravedns` succeeded while the VPN was pinned and - * DISALLOW_CONFIG_VPN was set, and the network filter went down with it. - * The always-on pin survives in device policy, but it points at a package - * that is no longer installed. - */ - fun setPackageUninstallBlocked(packageName: String, blocked: Boolean): Boolean { - if (!isDeviceOwner()) return false - return runCatching { - dpm.setUninstallBlocked(admin, packageName, blocked) - val applied = dpm.isUninstallBlocked(admin, packageName) - Log.i(FocusDeviceAdminReceiver.TAG, "uninstallBlocked($packageName)=$applied") - applied == blocked - }.getOrElse { error -> - Log.e(FocusDeviceAdminReceiver.TAG, "setUninstallBlocked($packageName) failed", error) - false - } - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyVpnDns.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyVpnDns.kt deleted file mode 100644 index a4363073..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyVpnDns.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.kuhy.focus_owner - -import android.app.admin.DevicePolicyManager -import android.content.ComponentName -import android.util.Log - -/** - * The always-on-VPN and Private DNS pinning half of [DevicePolicyBridge]. - * - * Split out to keep [DevicePolicyBridge] under the 250-line cap. Owns the - * network-lockdown DPM calls specifically -- pinning the VPN provider, - * pinning Private DNS, and blocking user changes to either -- while - * [DevicePolicyBridge] keeps ownership/uninstall/hide concerns and delegates - * these calls to an instance of this class. - */ -internal class DevicePolicyVpnDns( - private val dpm: DevicePolicyManager, - private val admin: ComponentName, - private val isDeviceOwner: () -> Boolean, -) { - - /** - * The VPN provider whose uninstall this admin has blocked, if any. - * - * Read back from the system rather than remembered in the app, so the - * release path still finds it after a process restart -- an unremovable - * app left behind by a half-completed release is the failure worth - * avoiding here. - */ - val alwaysOnVpnBlockedPackage: String? - get() = runCatching { dpm.getAlwaysOnVpnPackage(admin) }.getOrNull() - - /** - * Pins [vpnPackage] as the always-on VPN, the network-level block. - * - * This is the DPM call, not `settings put secure always_on_vpn_app` -- - * that one writes successfully, survives a reboot, and does nothing, - * because VpnManager ignores adb-written values. Measured on this device. - * - * [lockdown] drops every packet that is not going through the tunnel, - * which is what closes "stop the VPN and browse freely". It is a real - * hazard and not the default: if the VPN app breaks, the device has no - * connectivity until device ownership is released. That is survivable - * only because the release path is in the app itself and does not need - * the network -- verified on this device. - * - * @return null on success, or a message describing why it failed. - */ - fun setAlwaysOnVpn(vpnPackage: String, lockdown: Boolean = false): String? { - if (!isDeviceOwner()) return "not device owner" - return runCatching { - dpm.setAlwaysOnVpnPackage(admin, vpnPackage, lockdown) - val active = dpm.getAlwaysOnVpnPackage(admin) - Log.i(FocusDeviceAdminReceiver.TAG, "alwaysOnVpn -> $active") - if (active == vpnPackage) null else "pinned $active, expected $vpnPackage" - }.getOrElse { error -> - // UnsupportedOperationException is the documented signal that the - // package does not declare a VPN service, or does not opt in to - // always-on. Report it rather than leaving a silent no-op. - Log.e(FocusDeviceAdminReceiver.TAG, "setAlwaysOnVpnPackage failed", error) - error.message ?: error::class.java.simpleName - } - } - - /** - * Pins Private DNS to [host] and forbids the user changing it. - * - * This is the layer that survives the bypass measured on 2026-08-11: - * RethinkDNS's blocklists can be switched off from inside that app in a - * few taps, and no device owner API can stop it. Private DNS is a system - * setting, so the rules move to a resolver the phone cannot edit at all. - * - * Fails loudly rather than silently: an unresolvable or unreachable host - * here means no DNS at all, so the caller must verify before locking. - * - * @return null on success, or a message describing why it failed. - */ - fun setPrivateDns(host: String): String? { - if (!isDeviceOwner()) return "not device owner" - return runCatching { - val result = dpm.setGlobalPrivateDnsModeSpecifiedHost(admin, host) - val active = dpm.getGlobalPrivateDnsHost(admin) - Log.i(FocusDeviceAdminReceiver.TAG, "privateDns($host) -> code=$result now=$active") - if (result == DevicePolicyManager.PRIVATE_DNS_SET_NO_ERROR && active == host) { - null - } else { - "setGlobalPrivateDnsModeSpecifiedHost returned $result (host=$active)" - } - }.getOrElse { error -> - Log.e(FocusDeviceAdminReceiver.TAG, "setPrivateDns failed", error) - error.message ?: error::class.java.simpleName - } - } - - /** The Private DNS host currently pinned, or null. */ - fun privateDnsHost(): String? { - if (!isDeviceOwner()) return null - return runCatching { dpm.getGlobalPrivateDnsHost(admin) }.getOrNull() - } - - /** Blocks or unblocks user changes to Private DNS. */ - fun setPrivateDnsConfigBlocked(blocked: Boolean): Boolean { - if (!isDeviceOwner()) return false - return runCatching { - if (blocked) { - dpm.addUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_PRIVATE_DNS) - } else { - dpm.clearUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_PRIVATE_DNS) - } - Log.i(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_PRIVATE_DNS=$blocked") - true - }.getOrElse { error -> - Log.e(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_PRIVATE_DNS failed", error) - false - } - } - - /** The package currently pinned as always-on VPN, or null. */ - fun alwaysOnVpnPackage(): String? { - if (!isDeviceOwner()) return null - return runCatching { dpm.getAlwaysOnVpnPackage(admin) }.getOrNull() - } - - /** - * Prevents the user from changing VPN configuration. - * - * Applied separately from [setAlwaysOnVpn] and only after it is confirmed - * working: pinning the restriction first would make a failed VPN setup - * harder to repair from the device. - */ - fun setVpnConfigBlocked(blocked: Boolean): Boolean { - if (!isDeviceOwner()) return false - return runCatching { - if (blocked) { - dpm.addUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_VPN) - } else { - dpm.clearUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_VPN) - } - Log.i(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_VPN=$blocked") - true - }.getOrElse { error -> - Log.e(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_VPN failed", error) - false - } - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementAlarmReceiver.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementAlarmReceiver.kt deleted file mode 100644 index ea6f04cf..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementAlarmReceiver.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.kuhy.focus_owner - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent - -/** - * Fires when a scheduled evaluation is due and hands off to the service. - * - * A receiver cannot do the work itself: broadcast receivers get roughly ten - * seconds and no foreground guarantee, while an enforcement pass queries - * location and iterates every installed package. It exists only to start the - * service, which then schedules the following run. - */ -class EnforcementAlarmReceiver : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { - EnforcementService.start(context) - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt deleted file mode 100644 index 0dcb3f78..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementDecision.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.kuhy.focus_owner - -import kotlin.math.asin -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.sqrt - -/** - * The outcome of evaluating the policy against current conditions. - * - * A pure function of its inputs, so it can be tested without a device, a clock - * or a location provider. The inputs and the two result enums live in - * `EnforcementTypes.kt`. - * - * This is the ONLY implementation that enforces anything. `lib/enforcement.dart` - * used to be described here as a mirror pinned by a `PolicyParityTest`; no such - * test ever existed, and the two had silently drifted -- the Dart copy has no - * always-blocked concept at all, so it would report YouTube as visible while it - * is in fact hidden. The UI therefore renders the record this class produces - * rather than re-deciding anything, which removes the drift instead of policing - * it. - */ -data class EnforcementDecision( - val reason: EnforcementReason, - val packagesToHide: Set, - val packagesToShow: Set, - /** - * Metres from home, or null when there was no fix or no coordinates. - * - * Reported so the phone can explain itself. Coordinates are deliberately - * NOT carried: a distance is enough to debug the geofence, and logcat is - * readable by anyone with adb. - */ - val distanceM: Double? = null, - /** The value [distanceM] was compared against, including hysteresis. */ - val thresholdM: Double? = null, - /** - * Whether the fence returned a verdict, or null when it could not. - * - * Null is not the same as false: it means the question was unanswerable, - * so the caller leaves the previous verdict standing instead of recording - * "outside" and losing hysteresis at the worst moment. - */ - val insideFence: Boolean? = null, - /** Why each hidden package is hidden, for the UI and the log. */ - val hideReasons: Map = emptyMap(), - /** - * Whether the clock is inside the curfew window. - * - * Carried separately from [reason] because the two disagree: at 23:30 away - * from home the reason is AWAY, and inferring curfew from the enum would - * report "not in curfew" in exactly the branches where it is least obvious. - */ - val curfewActive: Boolean = false, -) { - /** - * Whether this pass leaves anything hidden. - * - * Derived rather than stored, which matters for the always-blocked set: - * AWAY still hides those, so it still counts as enforcing, and the - * uninstall block keyed off this stays on away from home. Were this tied - * to the reason instead, leaving the house would lift the block while - * YouTube stayed hidden -- making `adb uninstall` the off switch for the - * one thing that is meant not to have one. - */ - val isEnforcing: Boolean get() = packagesToHide.isNotEmpty() - - companion object { - private const val EARTH_RADIUS_M = 6_371_000.0 - - /** - * Decides which packages should be hidden right now. - * - * Fail-closed on unknown location, matching `focus_daemon.sh`, which - * logs "Location unavailable - defaulting to focus mode". Losing GPS - * must not be a way to switch enforcement off. - * - * [packagesToShow] is always the complete allowed set, never a delta, - * so a missed run repairs itself on the next one. Failing to hide is a - * mild disappointment; failing to *unhide* strands the user without a - * dialer or a banking app after a reboot during curfew. - */ - fun evaluate(policy: FocusPolicy, inputs: EnforcementInputs): EnforcementDecision { - val lat = inputs.latitude - val lon = inputs.longitude - val hasFix = lat != null && lon != null - val fence = if (hasFix) { - geofence(policy, lat, lon, inputs.currentlyEnforcing) - } else { - null - } - val atHome = fence?.inside ?: false - // Computed before the AWAY return so every branch can report it. - // The window is a property of the clock, not of where the phone is. - val duringCurfew = policy.isCurfewActive(inputs.minutesSinceMidnight) - - // Exempt from every branch below, including AWAY and WORKOUT. - // Restricted to packages actually present, so the sets stay a - // description of this device rather than of the policy. - val alwaysBlocked = inputs.installedPackages - .intersect(policy.alwaysBlockedPackages) - .filterNot { policy.isProtected(it) } - .toSet() - - if (hasFix && !atHome) { - return EnforcementDecision( - reason = EnforcementReason.AWAY, - packagesToHide = alwaysBlocked, - packagesToShow = inputs.installedPackages - alwaysBlocked, - distanceM = fence?.metres, - thresholdM = fence?.threshold, - insideFence = fence?.inside, - hideReasons = alwaysBlocked.associateWith { HideReason.ALWAYS_BLOCKED }, - curfewActive = duringCurfew, - ) - } - - val reason = when { - !hasFix -> EnforcementReason.LOCATION_UNKNOWN - duringCurfew -> EnforcementReason.CURFEW - else -> EnforcementReason.AT_HOME - } - - val hide = mutableSetOf() - val show = mutableSetOf() - val why = mutableMapOf() - for (pkg in inputs.installedPackages) { - if (pkg in alwaysBlocked) { - hide.add(pkg) - why[pkg] = HideReason.ALWAYS_BLOCKED - } else if (policy.isAllowed(pkg, duringCurfew)) { - show.add(pkg) - } else { - hide.add(pkg) - why[pkg] = if (duringCurfew) { - HideReason.NOT_IN_NIGHT_ALLOWLIST - } else { - HideReason.NOT_IN_ALLOWLIST - } - } - } - - if (inputs.workoutActive) { - for (pkg in policy.workoutExemptPackages) { - // A workout exemption must not become a way to reach the - // always-blocked set, or "go for a run" is the off switch. - if (pkg in alwaysBlocked) continue - if (hide.remove(pkg)) { - show.add(pkg) - why.remove(pkg) - } - } - return EnforcementDecision( - reason = EnforcementReason.WORKOUT, - packagesToHide = hide, - packagesToShow = show, - distanceM = fence?.metres, - thresholdM = fence?.threshold, - insideFence = fence?.inside, - hideReasons = why, - curfewActive = duringCurfew, - ) - } - return EnforcementDecision( - reason = reason, - packagesToHide = hide, - packagesToShow = show, - distanceM = fence?.metres, - thresholdM = fence?.threshold, - insideFence = fence?.inside, - hideReasons = why, - curfewActive = duringCurfew, - ) - } - - /** - * Evaluates the fence, returning the geometry as well as the verdict. - * - * The distance and the threshold are returned rather than discarded so - * the app can say *why* it decided what it decided. Without them the - * three states are indistinguishable from outside, which is what made - * the original misbehaviour impossible to diagnose from the phone. - */ - private fun geofence( - policy: FocusPolicy, - latitude: Double, - longitude: Double, - currentlyEnforcing: Boolean, - ): Geofence { - val home = policy.home - val threshold = home.radiusM + if (currentlyEnforcing) home.hysteresisM else 0.0 - // No coordinates configured: refuse to claim the user is away. - // Reported as inside with an unknown distance, so the UI can say - // "no home set" rather than presenting a fabricated 0 m. - if (!home.hasCoordinates) { - return Geofence(metres = null, threshold = threshold, inside = true) - } - val metres = haversineMetres( - home.latitude!!, - home.longitude!!, - latitude, - longitude, - ) - return Geofence(metres, threshold, inside = metres <= threshold) - } - - private fun haversineMetres( - lat1: Double, - lon1: Double, - lat2: Double, - lon2: Double, - ): Double { - val dLat = Math.toRadians(lat2 - lat1) - val dLon = Math.toRadians(lon2 - lon1) - val a = sin(dLat / 2) * sin(dLat / 2) + - cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * - sin(dLon / 2) * sin(dLon / 2) - return 2 * EARTH_RADIUS_M * asin(sqrt(a)) - } - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementLog.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementLog.kt deleted file mode 100644 index 33d53607..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementLog.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.kuhy.focus_owner - -import android.content.Context -import android.util.Log -import org.json.JSONArray -import org.json.JSONObject -import java.io.File - -/** - * Append-only, size-capped record of enforcement passes. - * - * Read back through the platform channel rather than adb, because neither adb - * route works on this build: `run-as` returns "package not debuggable" on the - * release build device owner requires (see [EnforcementRunner.setHomeToCurrentLocation]), - * and logcat rotates -- measured 2026-08-14, `adb logcat -d -s FocusOwner:V` - * came back empty while `dumpsys alarm` showed 82 alarms had fired. An in-app - * reader is the only durable option, so this file plus a UI is the deliverable. - * - * Never records coordinates. Distance in metres, fix age, accuracy and - * provider carry everything needed to debug the geofence without writing down - * where the user lives. - */ -class EnforcementLog(private val directory: File) { - - constructor(context: Context) : this(context.filesDir) - - private val file: File get() = File(directory, FILE_NAME) - - /** - * Appends one record. - * - * Every failure is swallowed. This is instrumentation: a logging problem - * must never be able to fail an enforcement pass, because the pass running - * is the app's actual promise. - */ - fun append(record: JSONObject) { - runCatching { - if (!directory.exists()) directory.mkdirs() - file.appendText(record.toString() + "\n") - rotateIfNeeded() - }.onFailure { - Log.w(FocusDeviceAdminReceiver.TAG, "could not append enforcement record", it) - } - } - - /** - * The most recent records, newest first. - * - * A corrupt line is skipped rather than failing the read: a half-written - * record from a process killed mid-append must not cost the whole history, - * which is exactly when the history is most wanted. - */ - fun readRecent(limit: Int = DEFAULT_LIMIT): List = runCatching { - if (!file.exists()) return emptyList() - file.readLines() - .asReversed() - .asSequence() - .filter { it.isNotBlank() } - .filter { line -> runCatching { JSONObject(line) }.isSuccess } - .take(limit) - .toList() - }.getOrElse { - Log.w(FocusDeviceAdminReceiver.TAG, "could not read enforcement log", it) - emptyList() - } - - /** - * Trims the file once it grows past [MAX_BYTES]. - * - * Rotates by whole lines rather than truncating bytes, so the file can - * never be left starting mid-record, which would make the oldest surviving - * entry unparsable. - */ - private fun rotateIfNeeded() { - if (file.length() <= MAX_BYTES) return - val kept = file.readLines().filter { it.isNotBlank() }.takeLast(KEEP_LINES) - file.writeText(kept.joinToString("\n", postfix = "\n")) - } - - companion object { - private const val FILE_NAME = "enforcement_log.jsonl" - - /** - * Byte size that triggers a trim. - * - * NOT the resulting file size, and not a ceiling. A trim drops the - * file to [KEEP_LINES] whole records and appends resume until this is - * crossed again, so the steady state is 400 records plus one trim - * interval -- measured at 492 lines after 1200 appends. Measured - * against this device (~32 sweepable packages): ~730 B/record at home - * and ~1.7 KB during curfew when 17 apps are hidden, so the file sits - * somewhere around 300-700 KB. Bounded, just not by this number. - */ - private const val MAX_BYTES = 256L * 1024L - - /** The real bound: ~400 records is a little over four days at 15 min. */ - private const val KEEP_LINES = 400 - private const val DEFAULT_LIMIT = 200 - - /** Schema version of the records written by this build. */ - const val SCHEMA_VERSION = 1 - - /** - * Renders one pass as a record. - * - * Deliberately a pure function of its arguments so it can be tested - * without a device, and so it is obvious by inspection that no - * coordinate reaches it: it takes a [LocationFix] but reads only the - * age, accuracy, provider and outcome. - */ - fun record( - timestampMs: Long, - decision: EnforcementDecision, - fix: LocationFix?, - policy: FocusPolicy?, - homeConfigured: Boolean, - permissions: Set, - hidDelta: List, - restoredDelta: List, - failure: String? = null, - ): JSONObject = JSONObject().apply { - put("v", SCHEMA_VERSION) - put("ts", timestampMs) - put("reason", decision.reason.name) - put("distance_m", decision.distanceM ?: JSONObject.NULL) - put("threshold_m", decision.thresholdM ?: JSONObject.NULL) - put("inside_fence", decision.insideFence ?: JSONObject.NULL) - put("home_configured", homeConfigured) - put( - "fix", - JSONObject().apply { - put("age_ms", fix?.takeIf { it.hasCoordinates }?.ageMs ?: JSONObject.NULL) - put("provider", fix?.provider ?: JSONObject.NULL) - put("accuracy_m", fix?.accuracyM ?: JSONObject.NULL) - put("outcome", fix?.outcome?.name ?: JSONObject.NULL) - }, - ) - put("permissions", JSONArray(permissions.sorted())) - // From the clock, not inferred from the reason: at 23:30 away from - // home the reason is AWAY while the curfew is genuinely in force. - put("curfew_active", decision.curfewActive) - put( - "curfew_window", - policy?.curfew?.let { "${hhmm(it.startMinutes)}-${hhmm(it.endMinutes)}" } - ?: JSONObject.NULL, - ) - put( - "counts", - JSONObject().apply { - put("to_hide", decision.packagesToHide.size) - put("to_show", decision.packagesToShow.size) - put("hid_delta", hidDelta.size) - put("restored_delta", restoredDelta.size) - }, - ) - put( - "hidden", - JSONArray().apply { - decision.packagesToHide.sorted().forEach { pkg -> - put( - JSONObject() - .put("pkg", pkg) - .put( - "why", - decision.hideReasons[pkg]?.name ?: "UNKNOWN", - ), - ) - } - }, - ) - put("hid", JSONArray(hidDelta.sorted())) - put("restored", JSONArray(restoredDelta.sorted())) - put("failure", failure ?: JSONObject.NULL) - } - - /** - * A record for a pass that produced no decision at all. - * - * Written from the early-return paths -- not provisioned, unreadable - * policy -- because those are precisely the states behind "it just - * stopped working and nothing says why", and without this they leave - * no trace anywhere the phone can show. - */ - fun failureRecord(timestampMs: Long, failure: String): JSONObject = - JSONObject().apply { - put("v", SCHEMA_VERSION) - put("ts", timestampMs) - put("reason", "NO_DECISION") - put("distance_m", JSONObject.NULL) - put("threshold_m", JSONObject.NULL) - put("inside_fence", JSONObject.NULL) - put("home_configured", JSONObject.NULL) - put( - "fix", - JSONObject().apply { - put("age_ms", JSONObject.NULL) - put("provider", JSONObject.NULL) - put("accuracy_m", JSONObject.NULL) - put("outcome", JSONObject.NULL) - }, - ) - put("permissions", JSONArray()) - put("curfew_active", JSONObject.NULL) - put("curfew_window", JSONObject.NULL) - put( - "counts", - JSONObject().apply { - put("to_hide", 0) - put("to_show", 0) - put("hid_delta", 0) - put("restored_delta", 0) - }, - ) - put("hidden", JSONArray()) - put("hid", JSONArray()) - put("restored", JSONArray()) - put("failure", failure) - } - - private fun hhmm(minutes: Int): String = - "%02d:%02d".format(minutes / 60, minutes % 60) - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt deleted file mode 100644 index 62799ee4..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt +++ /dev/null @@ -1,237 +0,0 @@ -package com.kuhy.focus_owner - -import android.content.Context -import android.util.Log -import java.util.Calendar - -/** - * Gathers the inputs for one enforcement pass and applies the result. - * - * Split out of [EnforcementService] so the service stays a thin lifecycle - * wrapper and this part can be reasoned about — and eventually instrumented — - * on its own. Location-fix acquisition itself lives in [LocationAcquisition]; - * this class keeps the decide/apply orchestration and delegates to it. - */ -class EnforcementRunner(private val context: Context) { - - private val locationAcquisition = LocationAcquisition(context) - private val homeLocationStore = HomeLocationStore(context) { freshWindowMs, maxAccuracyM -> - acquireLocation(freshWindowMs = freshWindowMs, maxAccuracyM = maxAccuracyM) - } - - /** - * Inputs from the current pass, carried from [decide] to [apply]. - * - * The record needs both halves: the fix and whether home is configured are - * only visible while deciding, the hide/show deltas only while applying. - * This is why [EnforcementService] uses ONE runner for both calls -- a - * second instance would log a pass with no fix metadata at all. - */ - private var pass: PassContext? = null - - private data class PassContext( - val fix: LocationFix, - val homeConfigured: Boolean, - val policy: FocusPolicy?, - ) - - /** Builds the decision, or null when the policy cannot be read. */ - fun decide(): EnforcementDecision? { - val policy = try { - FocusPolicy.load(context) - } catch (e: Exception) { - // A policy that cannot be parsed must never be treated as an empty - // allowlist: that would hide every app on the device. - Log.e(FocusDeviceAdminReceiver.TAG, "policy unreadable", e) - return null - } - - val home = homeLocationStore.readHomeLocation() - // Acquired even when no home is configured, so the log can tell - // "never provisioned" apart from "could not get a fix" -- previously - // both produced an identical LOCATION_UNKNOWN with nothing to - // distinguish them. - val fix = acquireLocation() - val fresh = fix.isFresh(FRESH_WINDOW_MS) - pass = PassContext(fix = fix, homeConfigured = home != null, policy = policy) - val calendar = Calendar.getInstance() - val minutes = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE) - - // Coordinates come from app-private storage, not the committed asset, - // which ships redacted. Substituted in here so the decision layer sees - // one coherent policy. - val effective = if (home == null) { - policy - } else { - policy.copy( - home = policy.home.copy(latitude = home.first, longitude = home.second), - ) - } - - return EnforcementDecision.evaluate( - effective, - EnforcementInputs( - installedPackages = sweepablePackages(context, effective), - minutesSinceMidnight = minutes, - // A stale fix is withheld entirely rather than passed with its - // age attached: it must not be able to classify the phone as - // AWAY. Fail-closed here is only safe because acquireLocation - // now actively requests a fix, so "stale" is the exception - // rather than the steady state. - latitude = if (fresh) fix.latitude else null, - longitude = if (fresh) fix.longitude else null, - // Keyed off whether the last pass placed the phone inside the - // fence, NOT off whether it hid anything. AWAY still hides the - // always-blocked set, so the old `wasEnforcing()` was true in - // every state and the threshold was permanently radius + - // hysteresis -- biasing every borderline call toward AT_HOME. - currentlyEnforcing = wasInsideFence(), - workoutActive = false, - fix = fix, - ), - ) - } - - /** - * Applies a decision. - * - * Show is applied before hide. If a pass is interrupted part-way — the - * process is killed, the device sleeps — the half that has run is the half - * that restores access rather than the half that removes it. - */ - fun apply(decision: EnforcementDecision, bridge: DevicePolicyBridge) { - // Re-read rather than threaded through from decide(): an unreadable - // policy must not stop a pass that is otherwise ready to apply, and - // the VPN pin is the one step that should survive a policy problem. - val policy = runCatching { FocusPolicy.load(context) }.getOrNull() - - // Re-asserted every pass for the same self-healing reason as the pins - // below. The geofence is only as good as its input, and a lost FINE - // grant degrades it silently: coarse location is fuzzed to a ~1-2 km - // grid, so the phone starts reading home as AWAY with nothing on - // screen to say why. Background location was previously granted by - // hand over adb, which a reinstall would have quietly dropped. - val granted = bridge.grantLocationPermissions() - bridge.enableLocation() - - // VPN/Private DNS pins + self-uninstall-block re-assertion; see - // [pinPolicyToDevice]. - pinPolicyToDevice(policy, decision, bridge) - - // Show/hide deltas; see [applyVisibilitySweep]. Show is applied before - // hide inside it for the same interrupted-pass reason documented there. - val (hiddenNow, restoredNow) = - applyVisibilitySweep(decision, bridge, selfPackage = context.packageName) - rememberEnforcing(decision.isEnforcing) - // Only recorded when the fence actually returned an answer. A pass - // with no usable fix must leave the previous verdict standing rather - // than resetting it to "outside", which would drop hysteresis for the - // next pass at exactly the moment the input is least reliable. - decision.insideFence?.let { rememberInsideFence(it) } - // Durable, in-app-readable record. Logcat is kept for live tailing but - // cannot be relied on: it rotates, and `run-as` is refused on this - // build, so logcat alone left the phone unable to explain itself. - EnforcementLog(context).append( - EnforcementLog.record( - timestampMs = System.currentTimeMillis(), - decision = decision, - fix = pass?.fix, - policy = pass?.policy ?: policy, - homeConfigured = pass?.homeConfigured ?: homeLocationStore.hasHomeLocation(), - permissions = granted, - hidDelta = hiddenNow, - restoredDelta = restoredNow, - ), - ) - Log.i( - FocusDeviceAdminReceiver.TAG, - "applied ${decision.reason}: hid ${hiddenNow.size}, " + - "restored ${restoredNow.size}", - ) - } - - /** Writes the current location as home. See [HomeLocationStore.setHomeToCurrentLocation]. */ - fun setHomeToCurrentLocation(): String? = homeLocationStore.setHomeToCurrentLocation() - - /** Whether home coordinates are provisioned, without revealing them. */ - fun hasHomeLocation(): Boolean = homeLocationStore.hasHomeLocation() - - /** Acquires a fix. See [LocationAcquisition.acquireLocation]. */ - fun acquireLocation( - timeoutMs: Long = LocationAcquisition.ACQUIRE_TIMEOUT_MS, - freshWindowMs: Long = LocationAcquisition.FRESH_WINDOW_MS, - maxAccuracyM: Double? = null, - ): LocationFix = locationAcquisition.acquireLocation(timeoutMs, freshWindowMs, maxAccuracyM) - - private fun prefs() = - context.getSharedPreferences("enforcement", Context.MODE_PRIVATE) - - /** Whether the previous pass was enforcing. */ - private fun wasEnforcing(): Boolean = prefs().getBoolean(KEY_ENFORCING, false) - - private fun rememberEnforcing(value: Boolean) = - prefs().edit().putBoolean(KEY_ENFORCING, value).apply() - - /** - * Whether the previous pass placed the phone inside the fence. - * - * This, not [wasEnforcing], is what hysteresis must key off. Hysteresis - * exists to stop a phone sitting on the boundary from flapping, so it has - * to widen the fence only for someone already judged to be inside it. - */ - private fun wasInsideFence(): Boolean = prefs().getBoolean(KEY_WAS_INSIDE, false) - - private fun rememberInsideFence(value: Boolean) = - prefs().edit().putBoolean(KEY_WAS_INSIDE, value).apply() - - companion object { - private const val KEY_ENFORCING = "was_enforcing" - - /** Whether the previous pass placed the phone inside the fence. */ - private const val KEY_WAS_INSIDE = "was_inside_fence" - - /** Longest a pass will wait for a fresh fix before falling back. */ - const val ACQUIRE_TIMEOUT_MS = LocationAcquisition.ACQUIRE_TIMEOUT_MS - - /** - * A fix at most this old is trusted as describing "now". - * - * Deliberately longer than the 15-minute enforcement cadence. A window - * below the cadence would mark the newest available cache stale on - * every pass of a phone sitting still, sending every pass to - * LOCATION_UNKNOWN and blocking everything -- turning the fail-closed - * default from a safety net into the normal case. - */ - const val FRESH_WINDOW_MS = LocationAcquisition.FRESH_WINDOW_MS - - /** - * Picks the fix best able to answer "is the phone inside the fence". - * - * Freshness is a HARD GATE and accuracy is a preference *within* it, - * in that order. Getting this backwards is not a style choice: sorting - * by accuracy first can surface a very precise fix from this morning, - * which the age check then rejects, sending the pass to - * LOCATION_UNKNOWN and blocking everything -- the exact office - * symptom. A recent coarse fix beats an old precise one because the - * old one is describing somewhere the phone no longer is. - * - * Within the window, accuracy decides: a ±2 km network fix cannot - * answer a 150 m question, so it loses to a ±10 m GPS fix even if it - * is a minute newer. Unknown accuracy sorts last rather than first -- - * it might be anything, and this comparison exists to avoid guessing. - * - * When nothing is fresh, returns the newest stale fix so the log can - * report what was actually available; the caller classifies it - * unusable either way. - */ - fun best(candidates: List, freshWindowMs: Long): LocationFix? { - if (candidates.isEmpty()) return null - val fresh = candidates.filter { it.ageMs <= freshWindowMs } - if (fresh.isEmpty()) return candidates.minByOrNull { it.ageMs } - return fresh.sortedWith( - compareBy { it.accuracyM ?: Double.MAX_VALUE } - .thenBy { it.ageMs }, - ).first() - } - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementScheduler.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementScheduler.kt deleted file mode 100644 index e90f441f..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementScheduler.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.kuhy.focus_owner - -import android.app.AlarmManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import android.util.Log - -/** - * Wakes the enforcement service at the moments the decision can change. - * - * A polling loop is the obvious design and the wrong one: Doze will skip it, - * and skipping the 05:00 boundary means the curfew allowlist stays applied into - * the morning — the user finds their banking app missing and no obvious cause. - * The transitions are known ahead of time, so they get exact alarms. - * - * A periodic fallback runs alongside them. Alarms can be lost to a reboot, a - * force-stop, or a system that decides otherwise; the fallback bounds how long - * a lost alarm can leave stale policy applied. It is deliberately coarse, since - * its job is repair rather than precision. - */ -class EnforcementScheduler(private val context: Context) { - - private val alarms = context.getSystemService(AlarmManager::class.java) - - /** - * Schedules the next evaluation. - * - * Uses `setExactAndAllowWhileIdle` where permitted so Doze cannot defer a - * boundary. When exact alarms are not permitted the inexact variant still - * fires, just late — degraded rather than broken, which is the right - * failure for a wellbeing tool. - */ - fun scheduleNext(atMillis: Long = defaultNextRun()) { - val pending = PendingIntent.getBroadcast( - context, - REQUEST_CODE, - Intent(context, EnforcementAlarmReceiver::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || - alarms.canScheduleExactAlarms() - if (exact) { - alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMillis, pending) - } else { - // Not fatal: the alarm still arrives, only later. Logged because a - // silently-inexact schedule would look like a bug at the boundary. - Log.w(FocusDeviceAdminReceiver.TAG, "exact alarms not permitted; using inexact") - alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMillis, pending) - } - } - - /** Cancels any pending evaluation. */ - fun cancel() { - PendingIntent.getBroadcast( - context, - REQUEST_CODE, - Intent(context, EnforcementAlarmReceiver::class.java), - PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE, - )?.let(alarms::cancel) - } - - /** - * The sooner of the periodic fallback and the next curfew boundary. - * - * The class comment has always described exact alarms at the boundaries, - * but nothing ever computed one: both callers invoked `scheduleNext()` - * bare, so the default was only ever `now + 15 min` and 05:00 could be - * served up to fifteen minutes late -- exactly the "banking app still - * missing in the morning" failure the comment warns about. - */ - private fun defaultNextRun(): Long { - val now = System.currentTimeMillis() - val fallback = now + FALLBACK_INTERVAL_MS - val boundary = nextCurfewBoundary(now) ?: return fallback - return minOf(fallback, boundary) - } - - /** When the curfew next opens or closes, or null when it is disabled. */ - private fun nextCurfewBoundary(now: Long): Long? { - val curfew = runCatching { FocusPolicy.load(context) }.getOrNull()?.curfew - ?: return null - return listOf(curfew.startMinutes, curfew.endMinutes) - .map { nextOccurrenceOf(it, now) } - .min() - } - - /** The next wall-clock instant at [minutesSinceMidnight], strictly after [now]. */ - private fun nextOccurrenceOf(minutesSinceMidnight: Int, now: Long): Long { - val calendar = java.util.Calendar.getInstance().apply { - timeInMillis = now - set(java.util.Calendar.HOUR_OF_DAY, minutesSinceMidnight / 60) - set(java.util.Calendar.MINUTE, minutesSinceMidnight % 60) - set(java.util.Calendar.SECOND, 0) - set(java.util.Calendar.MILLISECOND, 0) - } - // Rolled forward rather than clamped, so a boundary already passed - // today schedules for tomorrow instead of firing immediately. - if (calendar.timeInMillis <= now) { - calendar.add(java.util.Calendar.DAY_OF_YEAR, 1) - } - return calendar.timeInMillis - } - - companion object { - private const val REQUEST_CODE = 100 - - /** - * Upper bound on how long a lost alarm can leave stale policy applied. - * - * Fifteen minutes trades a little battery for a much smaller window in - * which the user is locked out of an app they should have back. - */ - const val FALLBACK_INTERVAL_MS: Long = 15 * 60 * 1000 - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementService.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementService.kt deleted file mode 100644 index edc17387..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementService.kt +++ /dev/null @@ -1,196 +0,0 @@ -package com.kuhy.focus_owner - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import android.util.Log - -/** - * Applies the current enforcement decision, then schedules the next evaluation. - * - * Deliberately not a polling loop. Doze can skip a poll indefinitely, and the - * transitions that matter are known in advance — the curfew boundaries — so - * [EnforcementScheduler] sets exact alarms for them instead. The service wakes, - * applies, schedules, and stops; it does not sit resident burning battery to - * re-decide something that cannot change until a known moment. - * - * The unhide path is the one that has to be reliable. Failing to hide an app is - * a mild disappointment; failing to *unhide* one strands the user without a - * dialer or a banking app because the phone happened to reboot during curfew. - * So every run applies the complete allowed set rather than a delta, and a - * missed run repairs itself on the next one without needing to know what the - * previous one did. - */ -class EnforcementService : Service() { - - /** Single-threaded so overlapping starts queue rather than race. */ - private val worker = java.util.concurrent.Executors.newSingleThreadExecutor() - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - // The LOCATION type is what lifts background location throttling for - // the fix this pass is about to request. It is requested rather than - // assumed: the platform refuses that type unless a location permission - // is already held, and this app grants its own permissions *during* a - // pass -- so the very first pass after a fresh install has none yet. - // Measured 2026-08-14: passing it unconditionally threw - // SecurityException and crashed the service on every alarm, which is - // strictly worse than a throttled fix. Fall back to SPECIAL_USE alone. - val foreground = startForegroundTyped(FGS_TYPE) || - startForegroundTyped(ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) - if (!foreground) { - // Neither type was accepted. Continuing would leave a started - // service that never called startForeground, which the platform - // kills with ForegroundServiceDidNotStartInTimeException -- a - // crash on every alarm, i.e. enforcement silently stops. Bailing - // out costs one tick instead; the alarm chain is re-armed below. - Log.e(FocusDeviceAdminReceiver.TAG, "could not enter foreground; skipping pass") - EnforcementScheduler(applicationContext).scheduleNext() - stopSelf(startId) - return START_NOT_STICKY - } - // Run off the main thread. The pass now waits up to - // EnforcementRunner.ACQUIRE_TIMEOUT_MS for a location fix, and - // blocking the main thread for that long is an ANR. The foreground - // notification is already showing, so the service is entitled to stay - // alive for the few seconds this takes. - worker.execute { - runCatching { applyAndReschedule() } - .onFailure { - Log.e(FocusDeviceAdminReceiver.TAG, "enforcement run failed", it) - } - // Stopped from the completion path, not from onStartCommand: doing - // it there would tear the service down while the work is in flight. - stopSelf(startId) - } - // The alarm brings us back. START_NOT_STICKY because a restart by the - // system would be a duplicate of the next alarm. - return START_NOT_STICKY - } - - override fun onDestroy() { - worker.shutdown() - super.onDestroy() - } - - /** Enters the foreground with [type], or returns false if refused. */ - private fun startForegroundTyped(type: Int): Boolean = runCatching { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, buildNotification(), type) - } else { - startForeground(NOTIFICATION_ID, buildNotification()) - } - true - }.getOrElse { error -> - Log.w(FocusDeviceAdminReceiver.TAG, "FGS type $type refused", error) - false - } - - private fun applyAndReschedule() { - val context = applicationContext - // Genuinely scheduled FIRST, not merely in a finally. A finally covers - // exceptions but not process death, and the pass now blocks up to - // ACQUIRE_TIMEOUT_MS waiting for a fix -- a kill inside that window - // (low memory, force-stop) would lose the alarm chain entirely, with - // no way back until the app is opened or the phone reboots. Arming up - // front costs nothing: the next pass re-arms again regardless. - EnforcementScheduler(context).scheduleNext() - try { - val bridge = DevicePolicyBridge(context) - if (!bridge.isDeviceOwner() && !bridge.isProfileOwner()) { - // Not provisioned: there is nothing this build may enforce, and - // saying so is more useful than failing silently every tick. - Log.i(FocusDeviceAdminReceiver.TAG, "not DO/PO - no enforcement applied") - recordFailure(context, "not device owner or profile owner") - return - } - // Granted before deciding, not only in apply(). decide() is what - // reads the location, so granting afterwards left the very first - // pass after an install with no permission at all -- outcome - // NO_PERMISSION, reason LOCATION_UNKNOWN, everything hidden for a - // full cadence. Cheap and idempotent, so it also stays in apply() - // where it self-heals every pass. - bridge.grantLocationPermissions() - bridge.enableLocation() - - // One runner for both calls: it carries the acquired fix and the - // pass timings from decide() through to the record apply() writes. - // Constructing a second one silently discarded all of that. - val runner = EnforcementRunner(context) - val decision = runner.decide() - if (decision == null) { - Log.w(FocusDeviceAdminReceiver.TAG, "no decision - policy unreadable") - recordFailure(context, "policy unreadable - no decision made") - return - } - runner.apply(decision, bridge) - } finally { - // Re-armed, not redundant with the pre-arm above: scheduleNext - // picks the sooner of the fallback and the next curfew boundary, - // and the pass itself can take 20 s, so re-running it here lands - // on the correct boundary rather than one computed before the - // pass. Re-arming an already-armed alarm just replaces it. - EnforcementScheduler(context).scheduleNext() - } - } - - /** Leaves a trace for a pass that produced no decision. */ - private fun recordFailure(context: Context, failure: String) { - EnforcementLog(context).append( - EnforcementLog.failureRecord(System.currentTimeMillis(), failure), - ) - } - - private fun buildNotification(): Notification { - val manager = getSystemService(NotificationManager::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - "Focus enforcement", - // Low: this notification exists because a foreground - // service requires one, not because it needs attention. - NotificationManager.IMPORTANCE_LOW, - ), - ) - } - return Notification.Builder(this, CHANNEL_ID) - .setContentTitle("Focus Owner") - .setContentText("Applying focus policy") - .setSmallIcon(android.R.drawable.ic_lock_idle_lock) - .setOngoing(true) - .build() - } - - companion object { - private const val CHANNEL_ID = "focus_enforcement" - private const val NOTIFICATION_ID = 1 - - /** Starts one enforcement run. */ - fun start(context: Context) { - val intent = Intent(context, EnforcementService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } - } - - /** - * Foreground service type, mirroring the manifest declaration. - * - * LOCATION is part of it because the pass requests a fix; without that - * type, background location delivery is throttled hard enough that the - * request usually times out and the pass fails closed. - */ - const val FGS_TYPE: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE or - ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt deleted file mode 100644 index c9172844..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementTypes.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.kuhy.focus_owner - -/** - * The vocabulary one enforcement pass is expressed in: what goes in, and the - * two enums that explain what came out. - * - * Same package as [EnforcementDecision], so nothing imports these explicitly - * and no consumer changes when they move here. - */ - -/** Why a particular package is hidden. */ -enum class HideReason { - /** Hidden in every state, exempt from the geofence. */ - ALWAYS_BLOCKED, - - /** Not on the day allowlist. */ - NOT_IN_ALLOWLIST, - - /** On the day allowlist but not the shorter curfew one. */ - NOT_IN_NIGHT_ALLOWLIST, -} - -/** The fence geometry behind one decision. */ -data class Geofence( - /** Metres from home, or null when no coordinates are configured. */ - val metres: Double?, - val threshold: Double, - val inside: Boolean, -) - -/** Why enforcement is currently on or off. */ -enum class EnforcementReason { - AWAY, - AT_HOME, - CURFEW, - WORKOUT, - LOCATION_UNKNOWN, -} - -/** Inputs to one enforcement decision. */ -data class EnforcementInputs( - val installedPackages: Set, - val minutesSinceMidnight: Int, - val latitude: Double? = null, - val longitude: Double? = null, - /** - * Whether the previous pass placed the phone inside the fence. - * - * Widens the threshold by the hysteresis margin, so a phone parked on the - * boundary does not flap between states. - */ - val currentlyEnforcing: Boolean = false, - val workoutActive: Boolean = false, - /** - * The fix these coordinates came from, carried for the log only. - * - * Never consulted by [EnforcementDecision.evaluate]: staleness is applied - * by the caller, which withholds the coordinates outright. Keeping the - * decision a function of latitude/longitude alone is what lets it stay - * testable without a location provider. - */ - val fix: LocationFix? = null, -) diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusDeviceAdminReceiver.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusDeviceAdminReceiver.kt deleted file mode 100644 index 28c5627a..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusDeviceAdminReceiver.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.kuhy.focus_owner - -import android.app.admin.DeviceAdminReceiver -import android.content.Context -import android.content.Intent -import android.util.Log - -/** - * Device admin component for focus mode. - * - * Deliberately inert: it declares the component so the app *can* be provisioned - * as device owner, but it applies no user restrictions and hides no packages. - * Enforcement is added only after the removal path in [DevicePolicyBridge] has - * been exercised on a real device. - * - * The ordering matters and is not stylistic. `dpm remove-active-admin` does not - * work on a device owner, so the only exit that avoids a factory reset is - * `clearDeviceOwnerApp()`, callable solely by this package on itself. An app - * that locks the device down before proving it can let go leaves a soft-brick - * recoverable only through fastboot — painful on a locked bootloader. - */ -class FocusDeviceAdminReceiver : DeviceAdminReceiver() { - - override fun onEnabled(context: Context, intent: Intent) { - super.onEnabled(context, intent) - Log.i(TAG, "Device admin enabled") - } - - override fun onDisabled(context: Context, intent: Intent) { - super.onDisabled(context, intent) - Log.i(TAG, "Device admin disabled") - } - - companion object { - const val TAG = "FocusOwner" - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt deleted file mode 100644 index 8ff4dbc7..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt +++ /dev/null @@ -1,238 +0,0 @@ -package com.kuhy.focus_owner - -import android.content.Context -import org.json.JSONObject -import java.io.File - -/** - * The device-independent focus policy, generated from `config.sh`. - * - * Deliberately a second implementation of the Dart model in `lib/policy.dart` - * rather than a call into it. A background [android.app.Service] has no Flutter - * engine, and starting one per alarm to answer a pure question would be both - * slow and a new failure mode at exactly the moment enforcement must be - * reliable. - * - * This side is the one that enforces. The Dart copy is used only to display - * the policy (allowlist sizes, the curfew window), never to decide anything -- - * a `PolicyParityTest` was cited here for months and did not exist, and the - * two had drifted apart unnoticed. Keeping the Dart side out of the decision - * path means a drift can no longer produce a wrong answer, only a stale label. - */ -data class FocusPolicy( - val home: HomeLocation, - val allowedPackages: Set, - val nightAllowedPackages: Set, - val neverDisablePrefixes: Set, - /** - * Prefix-matched day allowlist, for apps shipping as a family of packages. - * - * Tachiyomi installs every source as its own apk, so an exact list goes - * stale the moment a new extension is installed -- which from the phone is - * indistinguishable from the enforcer being broken. Matched on whole - * labels like [neverDisablePrefixes], not as a bare string prefix. - * - * Weaker than the exact list by construction: it allows packages that do - * not exist yet, so entries must stay narrow and vendor-specific. - */ - val allowedPrefixes: Set = emptySet(), - /** Prefixes that survive the curfew. Subset of [allowedPrefixes]. */ - val nightAllowedPrefixes: Set = emptySet(), - val workoutUnblockDomains: Set, - val curfew: CurfewWindow?, - val launcherPackage: String?, - /** - * System apps the sweep may hide, named individually. - * - * The sweep is default-deny for `FLAG_SYSTEM` packages, because most of - * them are platform components: on this device 243 of 320 match no - * allowlist entry and no prefix, including the emergency-alert receiver. - * Only packages named here are eligible, and an asset that predates this - * field parses to the empty set, preserving the old never-touch-system - * behaviour. - */ - val blockableSystemPackages: Set = emptySet(), - /** - * Packages hidden everywhere, exempt from the geofence. - * - * The geofence makes leaving home an off switch, which is what device - * owner exists to remove. These stay hidden under AWAY, CURFEW, AT_HOME - * and WORKOUT alike; [EnforcementDecision] never places them in - * `packagesToShow`. - */ - val alwaysBlockedPackages: Set = emptySet(), - /** - * Package pinned as the always-on VPN, or null when none is configured. - * - * The exporter emits this only when the package is also protected from - * the sweep, so the enforcer can never hide the app it has pinned. - */ - val alwaysOnVpnPackage: String? = null, - /** - * Whether the pinned VPN runs in lockdown mode. - * - * Lockdown drops every packet not going through the tunnel, which is what - * stops "turn the VPN off and browse freely". Defaults false so an asset - * predating the field cannot silently cut the device off. - */ - val vpnLockdown: Boolean = false, - /** - * Private DNS host to pin, or null to leave the setting alone. - * - * The domain rules live on this resolver rather than in the VPN app, - * because the VPN app's own blocklists can be switched off from inside it - * and no device owner API can stop that. - */ - val privateDnsHost: String? = null, -) { - /** - * Whether a package must never be hidden. - * - * Prefix-matched on whole labels, so `com.android.providers` covers - * `com.android.providers.telephony` but not `com.android.providersomething`. - */ - fun isProtected(packageName: String): Boolean = - matchesPrefix(packageName, neverDisablePrefixes) - - /** Whether a package may run under the given conditions. */ - fun isAllowed(packageName: String, duringCurfew: Boolean): Boolean { - if (isProtected(packageName)) return true - if (packageName == launcherPackage) return true - val allowed = if (duringCurfew) nightAllowedPackages else allowedPackages - if (packageName in allowed) return true - val prefixes = if (duringCurfew) nightAllowedPrefixes else allowedPrefixes - return matchesPrefix(packageName, prefixes) - } - - /** Whether the curfew is in force at [minutesSinceMidnight]. */ - fun isCurfewActive(minutesSinceMidnight: Int): Boolean = - curfew?.contains(minutesSinceMidnight) ?: false - - /** - * Packages released while a workout is in progress. - * - * The rooted system expressed this as *domains*, because it enforced with - * a hosts file. Hiding is coarser: there is no way to unblock youtube.com - * without unhiding the YouTube app, so the exception becomes packages. - */ - val workoutExemptPackages: Set - get() { - val mapping = mapOf( - "youtube.com" to listOf( - "com.google.android.youtube", - "com.google.android.apps.youtube.music", - ), - ) - val result = mutableSetOf() - for ((domain, packages) in mapping) { - val covered = workoutUnblockDomains.any { - it == domain || it.endsWith(".$domain") - } - if (covered) result.addAll(packages) - } - return result - } - - companion object { - /** - * Whether [packageName] is covered by any entry of [prefixes]. - * - * Matched on whole labels, so `com.android.providers` covers - * `com.android.providers.telephony` but not - * `com.android.providersomething`. Shared by every prefix list so the - * boundary rule cannot drift between them. - */ - private fun matchesPrefix(packageName: String, prefixes: Set): Boolean = - prefixes.any { packageName == it || packageName.startsWith("$it.") } - - /** Reads and parses the policy bundled as an asset. */ - fun load(context: Context, assetName: String = "flutter_assets/assets/policy.json"): FocusPolicy = - context.assets.open(assetName).bufferedReader().use { parse(it.readText()) } - - /** Reads a policy from a file, for tests and for overrides. */ - fun loadFile(file: File): FocusPolicy = parse(file.readText()) - - /** Parses a rendered policy document. */ - fun parse(text: String): FocusPolicy { - val json = JSONObject(text) - val version = json.optInt("schema_version", -1) - if (version != SUPPORTED_SCHEMA_VERSION) { - // Accepting a newer schema would mean enforcing a policy this - // code has misread, and a misread allowlist blocks the dialer. - throw PolicyFormatException( - "unsupported schema_version $version " + - "(this build understands $SUPPORTED_SCHEMA_VERSION)", - ) - } - val home = json.optJSONObject("home") - ?: throw PolicyFormatException("missing \"home\" object") - val curfewJson = json.optJSONObject("curfew") - return FocusPolicy( - home = HomeLocation( - latitude = if (home.isNull("latitude")) null else home.getDouble("latitude"), - longitude = if (home.isNull("longitude")) null else home.getDouble("longitude"), - radiusM = home.getDouble("radius_m"), - hysteresisM = home.getDouble("hysteresis_m"), - ), - allowedPackages = json.stringSet("allowed_packages"), - nightAllowedPackages = json.stringSet("night_allowed_packages"), - neverDisablePrefixes = json.stringSet("never_disable_prefixes"), - allowedPrefixes = json.optionalStringSet("allowed_prefixes"), - nightAllowedPrefixes = json.optionalStringSet("night_allowed_prefixes"), - workoutUnblockDomains = json.stringSet("workout_unblock_domains"), - blockableSystemPackages = json.optionalStringSet("blockable_system_packages"), - alwaysBlockedPackages = json.optionalStringSet("always_blocked_packages"), - // Empty string means "not configured", which is what the - // exporter writes when the provider is not sweep-protected. - alwaysOnVpnPackage = json.optString("always_on_vpn_package") - .takeIf { it.isNotEmpty() }, - vpnLockdown = json.optBoolean("vpn_lockdown", false), - privateDnsHost = json.optString("private_dns_host") - .takeIf { it.isNotEmpty() }, - curfew = curfewJson?.let { - CurfewWindow( - startMinutes = parseHhMm(it.getString("start"), "curfew.start"), - endMinutes = parseHhMm(it.getString("end"), "curfew.end"), - ) - }, - launcherPackage = if (json.isNull("launcher_package")) { - null - } else { - json.getString("launcher_package") - }, - ) - } - - private fun JSONObject.stringSet(field: String): Set { - val array = optJSONArray(field) - ?: throw PolicyFormatException("\"$field\" must be a list") - return (0 until array.length()).mapTo(mutableSetOf()) { array.getString(it) } - } - - /** - * A list that may be absent, read as empty. - * - * Used for fields added after assets were already shipped. The strict - * [stringSet] would throw, and an unparsable policy makes the runner - * skip the pass entirely — safe, but it silently disables enforcement - * rather than degrading to the previous behaviour. A present-but-wrong - * type is still an error, since that is a real mistake. - */ - private fun JSONObject.optionalStringSet(field: String): Set { - if (!has(field)) return emptySet() - return stringSet(field) - } - - private fun parseHhMm(value: String, field: String): Int { - val parts = value.split(":") - val hour = parts.getOrNull(0)?.toIntOrNull() - val minute = parts.getOrNull(1)?.toIntOrNull() - if (parts.size != 2 || hour == null || minute == null || - hour > 23 || minute > 59 || hour < 0 || minute < 0 - ) { - throw PolicyFormatException("\"$field\" is not a valid time: \"$value\"") - } - return hour * 60 + minute - } - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt deleted file mode 100644 index b74f0176..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.kuhy.focus_owner - -import android.content.Context -import android.util.Log -import org.json.JSONObject -import java.io.File - -/** - * The home-location read/write half of [EnforcementRunner]. - * - * Split out to keep [EnforcementRunner] under the 250-line cap. Owns writing - * the current fix as home and reading it back, while [EnforcementRunner] - * keeps the decide/apply orchestration and delegates to an instance of this - * class. - */ -internal class HomeLocationStore( - private val context: Context, - private val acquireLocation: (freshWindowMs: Long, maxAccuracyM: Double?) -> LocationFix, -) { - - /** - * Writes the current location as home. - * - * The app has to do this itself. `push_home_location.sh` stages the file - * through `run-as`, which returns "package not debuggable" on the release - * build device owner requires -- and making the build debuggable to fix - * that would let anyone with adb edit this app's state, which is the - * bypass the whole design exists to close. - * - * Deliberately not an exported intent or receiver: anything that can set - * home from outside the app can set it to somewhere else, and the geofence - * would then report AWAY forever with enforcement off. - * - * @return null on success, or a message describing why it failed. - */ - fun setHomeToCurrentLocation(): String? { - // Its own thresholds, so a retry actually requests a new fix rather - // than being handed the same cached one it just rejected. - val fix = acquireLocation(SET_HOME_MAX_AGE_MS, SET_HOME_MAX_ACCURACY_M) - if (!fix.hasCoordinates) { - return when (fix.outcome) { - FixOutcome.NO_PERMISSION -> "location permission is not granted" - FixOutcome.NO_PROVIDER -> "location services are off - turn them on" - else -> "no location fix available - go outside or open a maps app, then retry" - } - } - // A wrong home is worse than no home: it silently inverts the geofence - // everywhere, forever, and nothing on screen would say so. So this - // refuses anything but a fix taken now -- unlike an enforcement pass, - // there is a user standing here who can simply retry. - if (!fix.isFresh(SET_HOME_MAX_AGE_MS)) { - return "only a stale fix (${fix.ageMs / 1000}s old) - wait a moment and retry" - } - // Unknown accuracy reads as unacceptable, matching `best` and the - // cached-fix gate. The opposite reading would let a fix of unknown - // precision anchor home, which silently inverts the geofence - // everywhere and forever -- and unlike an enforcement pass, there is a - // user present who can simply step outside and retry. - val accuracy = fix.accuracyM ?: Double.MAX_VALUE - if (accuracy > SET_HOME_MAX_ACCURACY_M) { - val reported = fix.accuracyM?.toInt()?.let { "$it m" } ?: "unknown" - return "fix accuracy is $reported (need under " + - "${SET_HOME_MAX_ACCURACY_M.toInt()} m) - " + - "go outside for a better signal, then retry" - } - return try { - val json = JSONObject() - .put("latitude", fix.latitude) - .put("longitude", fix.longitude) - File(context.filesDir, "home_location.json").writeText(json.toString()) - // Never log the coordinates: this is the user's home address and - // logcat is world-readable to anyone with adb. - Log.i(FocusDeviceAdminReceiver.TAG, "home location written") - null - } catch (e: Exception) { - Log.e(FocusDeviceAdminReceiver.TAG, "could not write home location", e) - e.message ?: e::class.java.simpleName - } - } - - /** Whether home coordinates are provisioned, without revealing them. */ - fun hasHomeLocation(): Boolean = readHomeLocation() != null - - /** Coordinates provisioned by `push_home_location.sh`, or null. */ - fun readHomeLocation(): Pair? { - val file = File(context.filesDir, "home_location.json") - if (!file.exists()) return null - return try { - val json = JSONObject(file.readText()) - val lat = json.getDouble("latitude") - val lon = json.getDouble("longitude") - if (lat !in -90.0..90.0 || lon !in -180.0..180.0) null else lat to lon - } catch (e: Exception) { - // Unreadable coordinates read as "no home", which the decision - // layer treats as location-unknown and therefore fails closed. - Log.w(FocusDeviceAdminReceiver.TAG, "home_location.json unreadable", e) - null - } - } - - private companion object { - /** - * Much tighter than [LocationAcquisition.FRESH_WINDOW_MS], for - * anchoring home. - * - * An enforcement pass tolerates an older fix because it re-runs every - * 15 minutes and a wrong call self-corrects. Home is written once and - * silently inverts the geofence everywhere if it is wrong, and there - * is a user present who can just retry -- so it demands a fresh fix. - */ - const val SET_HOME_MAX_AGE_MS = 2L * 60L * 1000L - - /** Refuse to anchor home to a fix vaguer than the fence is wide. */ - const val SET_HOME_MAX_ACCURACY_M = 100.0 - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt deleted file mode 100644 index 7710cebd..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.kuhy.focus_owner - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationManager -import android.util.Log -import androidx.core.content.ContextCompat - -/** - * The location-fix acquisition half of [EnforcementRunner]. - * - * Split out to keep [EnforcementRunner] under the 250-line cap. Owns getting - * a [LocationFix] out of [LocationManager] -- preferring a fresh cache, - * falling back to an active request, then to a stale cache -- while - * [EnforcementRunner] keeps the decide/apply orchestration and delegates - * acquisition to an instance of this class. - */ -internal class LocationAcquisition(private val context: Context) { - - /** - * Acquires a fix, preferring a fresh one over whatever happens to be cached. - * - * The old implementation only read [LocationManager.getLastKnownLocation] - * and took the newest across providers, with no age check and no request - * of its own. That produced both reported failures: at the office with no - * navigation app running the cache was empty, so the pass fell through to - * LOCATION_UNKNOWN and blocked everything; and a cached fix from hours ago - * would have classified the office as home just as readily. - * - * Bounded by [ACQUIRE_TIMEOUT_MS] so a pass can never hang waiting for a - * fix that is not coming. On expiry it falls back to the cache, tagged so - * the decision layer and the log can both say the acquisition timed out - * rather than silently presenting a stale fix as current. - */ - fun acquireLocation( - timeoutMs: Long = ACQUIRE_TIMEOUT_MS, - freshWindowMs: Long = FRESH_WINDOW_MS, - maxAccuracyM: Double? = null, - ): LocationFix { - val fine = ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION, - ) == PackageManager.PERMISSION_GRANTED - val coarse = ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_COARSE_LOCATION, - ) == PackageManager.PERMISSION_GRANTED - if (!fine && !coarse) return LocationFix.unavailable(FixOutcome.NO_PERMISSION) - val manager = context.getSystemService(LocationManager::class.java) - ?: return LocationFix.unavailable(FixOutcome.NO_PROVIDER) - - // A recent cached fix is both cheaper and no less accurate than one we - // would request right now, so it short-circuits acquisition entirely. - // This is the common case whenever any app has used location lately. - // - // The caller's own thresholds are applied HERE, not only after the - // fact: "Set home" demands a fix under 2 minutes old and accurate to - // 100 m, and if this returned a 5-minute-old cache regardless, every - // retry would hand back the identical rejected fix and the button - // could never succeed until the cache aged out on its own. - cachedFix(manager, freshWindowMs) - ?.takeIf { it.ageMs <= freshWindowMs } - // Unknown accuracy is treated as unacceptable, matching `best`. - // The alternative reading (0.0, i.e. perfect) would let a fix of - // unknown precision anchor home, which silently inverts the - // geofence everywhere and forever. - ?.takeIf { - maxAccuracyM == null || - (it.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM - } - ?.let { return it.copy(outcome = FixOutcome.CACHED_FRESH) } - - val active = runCatching { requestCurrentFix(manager, timeoutMs) } - .getOrElse { error -> - Log.w(FocusDeviceAdminReceiver.TAG, "location request failed", error) - null - } - // The caller's accuracy bound applies to a freshly requested fix too, - // not just the cache: a brand-new fix that cannot say where it is to - // within maxAccuracyM answers the question no better than an old one. - if (active != null && - (maxAccuracyM == null || (active.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM) - ) { - return active.copy(outcome = FixOutcome.ACTIVE_OK) - } - - // Nothing fresh. Return the stale cache anyway, labelled: the decision - // layer decides what to do with it, and the log needs to say what was - // actually available rather than reporting a bare "no fix". - // Window passed through so `best` takes its no-fresh-candidates branch - // and reports the NEWEST stale fix. Without it every candidate counts - // as fresh and the most accurate one wins, which does not change the - // decision (the caller rejects anything stale either way) but makes - // the log and the set-home error quote a six-hour-old fix when a - // forty-minute-old one was available. - val stale = cachedFix(manager, freshWindowMs) - return stale?.copy(outcome = FixOutcome.TIMEOUT) - ?: LocationFix.unavailable(FixOutcome.TIMEOUT) - } - - /** The newest cached fix across enabled providers, or null. */ - private fun cachedFix( - manager: LocationManager, - freshWindowMs: Long = Long.MAX_VALUE, - ): LocationFix? = try { - manager.getProviders(true) - .asSequence() - .mapNotNull { provider -> - manager.getLastKnownLocation(provider)?.let { provider to it } - } - .map { (provider, location) -> location.toFix(provider) } - .toList() - .let { EnforcementRunner.best(it, freshWindowMs) } - } catch (e: SecurityException) { - Log.w(FocusDeviceAdminReceiver.TAG, "location denied", e) - null - } - - /** - * Asks the platform for a fix now, blocking up to [timeoutMs]. - * - * Uses [LocationManager] rather than the fused Play Services client on - * purpose: a device owner should not depend on Play Services, which is - * itself inside this app's blast radius. - * - * The caller must not be on the main thread -- [EnforcementService] runs - * the pass on a background executor for exactly this reason. - */ - private fun requestCurrentFix(manager: LocationManager, timeoutMs: Long): LocationFix? { - val provider = PROVIDER_PREFERENCE.firstOrNull { manager.isProviderEnabled(it) } - ?: return null - val latch = java.util.concurrent.CountDownLatch(1) - val holder = java.util.concurrent.atomic.AtomicReference() - val signal = android.os.CancellationSignal() - // The main executor rather than a fresh one per call: the consumer - // only stores a reference and counts down, so it does no work worth a - // thread, and creating one per pass leaked ~96 threads a day. - manager.getCurrentLocation( - provider, - signal, - androidx.core.content.ContextCompat.getMainExecutor(context), - ) { location -> - holder.set(location) - latch.countDown() - } - val answered = latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS) - if (!answered) { - // Stop the provider working on a request nobody is waiting for. - runCatching { signal.cancel() } - return null - } - return holder.get()?.toFix(provider) - } - - /** Converts a platform [Location] into the fix the decision layer sees. */ - private fun Location.toFix(provider: String): LocationFix = LocationFix( - latitude = latitude, - longitude = longitude, - // elapsedRealtimeNanos is monotonic, so a clock change cannot make a - // fix look fresher than it is. - ageMs = (android.os.SystemClock.elapsedRealtimeNanos() - elapsedRealtimeNanos) / - 1_000_000L, - accuracyM = if (hasAccuracy()) accuracy.toDouble() else null, - provider = provider, - outcome = FixOutcome.CACHED_FRESH, - ) - - companion object { - /** Longest a pass will wait for a fresh fix before falling back. */ - const val ACQUIRE_TIMEOUT_MS = 20_000L - - /** - * A fix at most this old is trusted as describing "now". - * - * Deliberately longer than the 15-minute enforcement cadence. A window - * below the cadence would mark the newest available cache stale on - * every pass of a phone sitting still, sending every pass to - * LOCATION_UNKNOWN and blocking everything -- turning the fail-closed - * default from a safety net into the normal case. - */ - const val FRESH_WINDOW_MS = 30L * 60L * 1000L - - /** - * Providers tried in order when requesting a fix. - * - * GPS first because it is the only one accurate enough for a 150 m - * fence; fused and network are fallbacks that at least distinguish - * "same city" from "10 km away". - */ - private val PROVIDER_PREFERENCE = listOf( - LocationManager.GPS_PROVIDER, - LocationManager.FUSED_PROVIDER, - LocationManager.NETWORK_PROVIDER, - ) - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationFix.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationFix.kt deleted file mode 100644 index 0ed5c4f5..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationFix.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.kuhy.focus_owner - -/** How the current fix was obtained, or why there is none. */ -enum class FixOutcome { - /** A cached fix younger than the freshness window; no request was made. */ - CACHED_FRESH, - - /** The platform answered a request made during this pass. */ - ACTIVE_OK, - - /** The request did not answer in time; any fix here is the stale cache. */ - TIMEOUT, - - /** Neither fine nor coarse location is granted. */ - NO_PERMISSION, - - /** No location provider is enabled, or the service is missing. */ - NO_PROVIDER, -} - -/** - * A location fix plus everything needed to judge and explain it. - * - * Age and accuracy are carried rather than discarded because they are what - * separates a trustworthy classification from a guess: the geofence is 150 m - * wide, so a fix that is an hour old or accurate to 2 km cannot answer the - * question being asked of it. - * - * Coordinates never leave this object -- they are used to compute a distance - * and are deliberately absent from the log record and the UI, since logcat is - * readable by anyone with adb and this is the user's home address. - */ -data class LocationFix( - val latitude: Double?, - val longitude: Double?, - /** Milliseconds since the fix was taken, from the monotonic clock. */ - val ageMs: Long, - /** Reported accuracy radius in metres, or null when unknown. */ - val accuracyM: Double?, - /** Provider name (`gps`, `fused`, `network`), or null when there is no fix. */ - val provider: String?, - val outcome: FixOutcome, -) { - /** Whether there are usable coordinates at all. */ - val hasCoordinates: Boolean get() = latitude != null && longitude != null - - /** - * Whether this fix is recent enough to describe where the phone is now. - * - * The window must exceed the enforcement cadence. A shorter one would mark - * the newest available cache stale on every pass of an idle phone, sending - * every pass to LOCATION_UNKNOWN -- which blocks everything, the exact - * failure this rewrite exists to remove. - */ - fun isFresh(windowMs: Long): Boolean = hasCoordinates && ageMs <= windowMs - - companion object { - /** A fix that could not be obtained. */ - fun unavailable(outcome: FixOutcome): LocationFix = LocationFix( - latitude = null, - longitude = null, - ageMs = Long.MAX_VALUE, - accuracyM = null, - provider = null, - outcome = outcome, - ) - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt deleted file mode 100644 index bf167b97..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.kuhy.focus_owner - -import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodChannel - -class MainActivity : FlutterActivity() { - - /** For channel calls that block; the platform thread must stay free. */ - private val worker = java.util.concurrent.Executors.newSingleThreadExecutor() - - override fun onDestroy() { - worker.shutdown() - super.onDestroy() - } - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - val bridge = DevicePolicyBridge(applicationContext) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) - .setMethodCallHandler { call, result -> - when (call.method) { - "status" -> result.success(bridge.status()) - "releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner()) - // Off the platform thread: setting home now waits for a - // genuinely fresh fix (up to ACQUIRE_TIMEOUT_MS), and - // blocking the main thread for that long is an ANR on the - // one button that provisions the geofence. - "setHomeToCurrentLocation" -> worker.execute { - val outcome = runCatching { - EnforcementRunner(applicationContext).setHomeToCurrentLocation() - }.getOrElse { it.message ?: it::class.java.simpleName } - // The work outlives a shutdown() (it only stops new - // submissions), so the activity can be gone by now. - // Replying to a dead activity's channel throws, and - // the home write has already happened either way. - if (!isDestroyed && !isFinishing) { - runOnUiThread { result.success(outcome) } - } - } - "hasHomeLocation" -> - result.success( - EnforcementRunner(applicationContext).hasHomeLocation(), - ) - "setAlwaysOnVpn" -> { - val pkg = call.argument("package") - if (pkg == null) { - result.error("bad_args", "package required", null) - } else { - // Returns the failure message, or null on success, - // so the UI can say why rather than just "failed". - result.success(bridge.setAlwaysOnVpn(pkg)) - } - } - "setVpnConfigBlocked" -> { - val blocked = call.argument("blocked") - if (blocked == null) { - result.error("bad_args", "blocked required", null) - } else { - result.success(bridge.setVpnConfigBlocked(blocked)) - } - } - "setSelfUninstallBlocked" -> { - val blocked = call.argument("blocked") - if (blocked == null) { - result.error("bad_args", "blocked required", null) - } else { - result.success(bridge.setSelfUninstallBlocked(blocked)) - } - } - "setApplicationHidden" -> { - val pkg = call.argument("package") - val hidden = call.argument("hidden") - if (pkg == null || hidden == null) { - result.error("bad_args", "package and hidden required", null) - } else { - result.success(bridge.setApplicationHidden(pkg, hidden)) - } - } - "isApplicationHidden" -> { - val pkg = call.argument("package") - if (pkg == null) { - result.error("bad_args", "package required", null) - } else { - result.success(bridge.isApplicationHidden(pkg)) - } - } - "runEnforcementNow" -> { - // Also the only way to arm the schedule on a device - // that has not rebooted since install: the service - // schedules the next run at the end of each pass. - EnforcementService.start(applicationContext) - result.success(true) - } - "cancelEnforcement" -> { - EnforcementScheduler(applicationContext).cancel() - result.success(true) - } - // Returned as raw JSON strings rather than platform maps: - // the channel codec stays trivial and the Dart side owns - // one well-defined parse, so the record format and the UI - // can be versioned together. - "readEnforcementLog" -> { - val limit = call.argument("limit") ?: 200 - result.success( - EnforcementLog(applicationContext).readRecent(limit), - ) - } - else -> result.notImplemented() - } - } - } - - companion object { - private const val CHANNEL = "com.kuhy.focus_owner/device_policy" - } -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt deleted file mode 100644 index 3186f47c..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.kuhy.focus_owner - -import android.util.Log - -/** - * Re-pins the network-lockdown and self-uninstall-block policy onto the - * device every pass. - * - * Split out of [EnforcementRunner.apply] to keep that file under the 250-line - * cap. Called with the freshly-loaded [policy] (which may be null if it - * failed to load -- the VPN/DNS pins are skipped in that case, but the - * self-uninstall-block re-assertion still runs since it depends only on - * [decision], not on the policy). - */ -internal fun pinPolicyToDevice( - policy: FocusPolicy?, - decision: EnforcementDecision, - bridge: DevicePolicyBridge, -) { - // Re-pinned every pass, unconditionally rather than tracking the - // decision: the network filter is the layer that reaches youtube.com - // in Firefox, in a webview, and in clients nobody has enumerated, so - // it must not lift when the geofence says AWAY. DISALLOW_CONFIG_VPN is - // deliberately NOT applied here -- it is a separate, later step, so a - // misconfigured VPN stays repairable from the device. - policy?.alwaysOnVpnPackage?.let { vpn -> - bridge.setAlwaysOnVpn(vpn, lockdown = policy.vpnLockdown)?.let { failure -> - Log.w(FocusDeviceAdminReceiver.TAG, "always-on VPN not pinned: $failure") - } - // Measured hole: `pm uninstall --user 0` removed the provider - // while it was pinned, taking the network filter with it and - // leaving the pin aimed at a package that no longer existed. - bridge.setPackageUninstallBlocked(vpn, true) - } - - // Pinned every pass for the same reason as the VPN: this is where the - // domain rules actually live now, so it must not depend on anyone - // remembering to set it. DISALLOW_CONFIG_PRIVATE_DNS is applied - // separately, only once the host is confirmed answering. - policy?.privateDnsHost?.let { host -> - bridge.setPrivateDns(host)?.let { failure -> - Log.w(FocusDeviceAdminReceiver.TAG, "private DNS not pinned: $failure") - } - } - - // Re-asserted every pass rather than set once at provisioning time, so - // the protection self-heals: `adb uninstall` while enforcing is the one - // route that strands device ownership with no holder, and a factory - // reset is then the only exit. Tracking the decision means going away - // from home lifts it, which keeps the app removable in exactly the - // state where enforcement is already off. - bridge.setSelfUninstallBlocked(decision.isEnforcing) -} - -/** The packages actually changed by [applyVisibilitySweep], named not counted. */ -internal data class VisibilitySweepResult( - val hiddenNow: List, - val restoredNow: List, -) - -/** - * Applies the decision's show/hide deltas to the device. - * - * Split out of [EnforcementRunner.apply] to keep that file under the 250-line - * cap. Show is applied before hide by the caller's ordering of these two - * loops -- if a pass is interrupted part-way, the half that has run is the - * half that restores access rather than the half that removes it. - */ -internal fun applyVisibilitySweep( - decision: EnforcementDecision, - bridge: DevicePolicyBridge, - selfPackage: String, -): VisibilitySweepResult { - val restoredNow = mutableListOf() - val hiddenNow = mutableListOf() - for (pkg in decision.packagesToShow) { - if (bridge.isApplicationHidden(pkg)) { - if (bridge.setApplicationHidden(pkg, false)) restoredNow.add(pkg) - } - } - for (pkg in decision.packagesToHide) { - // The exporter injects this package into both allowlists, so the - // decision layer should never propose it. Kept as a belt-and-braces - // check because it guards the unrecoverable case: hiding the - // enforcer takes the escape hatch and the trigger with it, and a - // policy asset rendered before that fix -- or hand-edited -- would - // otherwise hide the app on the first enforcing pass. - if (pkg == selfPackage) continue - if (!bridge.isApplicationHidden(pkg)) { - if (bridge.setApplicationHidden(pkg, true)) hiddenNow.add(pkg) - } - } - return VisibilitySweepResult(hiddenNow = hiddenNow, restoredNow = restoredNow) -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt deleted file mode 100644 index dd15577b..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyTypes.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.kuhy.focus_owner - -/** - * The policy's value types: the schema version this build understands, the - * failure it raises, and the two records [FocusPolicy] is built from. - * - * Same package as FocusPolicy, so nothing imports these explicitly and no call - * site changes by their moving here. - */ - -/** Schema version this build understands. */ -const val SUPPORTED_SCHEMA_VERSION = 1 - -/** Thrown when the bundled policy cannot be trusted. */ -class PolicyFormatException(message: String) : Exception(message) - -/** A nightly window that may wrap midnight (23:00 -> 05:00). */ -data class CurfewWindow(val startMinutes: Int, val endMinutes: Int) { - /** - * Whether [minutesSinceMidnight] falls inside the window. - * - * Wrapping windows are the normal case, so "inside" means at or after the - * start *or* before the end, not both. - */ - fun contains(minutesSinceMidnight: Int): Boolean = - if (startMinutes <= endMinutes) { - minutesSinceMidnight in startMinutes until endMinutes - } else { - minutesSinceMidnight >= startMinutes || minutesSinceMidnight < endMinutes - } -} - -/** The home anchor. Coordinates are null in a redacted policy. */ -data class HomeLocation( - val latitude: Double?, - val longitude: Double?, - val radiusM: Double, - val hysteresisM: Double, -) { - /** - * Whether this policy carries real coordinates. - * - * The committed asset is redacted, so enforcement must check this rather - * than treating a missing coordinate as (0, 0) — that would put "home" in - * the Atlantic and silently disable the gate. - */ - val hasCoordinates: Boolean get() = latitude != null && longitude != null -} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt deleted file mode 100644 index 7ccf2966..00000000 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.kuhy.focus_owner - -import android.content.Context -import android.content.pm.PackageManager - -/** - * Packages the allowlist sweep considers: every third-party app, plus the - * system apps the policy names in `blockable_system_packages`. - * - * Split out of [EnforcementRunner.decide] to keep that file under the - * 250-line cap. - * - * System apps are default-deny rather than filtered out entirely, because - * the apps most worth blocking are preinstalled — YouTube ships at - * `/product/app/YouTube` with `FLAG_SYSTEM`, and so does Chrome — while - * most of the rest are platform components. Measured on this device: 320 - * system packages, of which 243 match no allowlist entry and no - * `never_disable_prefix`, including `com.android.cellbroadcastreceiver` - * (emergency alerts) and `com.android.devicelockcontroller`. Sweeping - * those under Device Owner risks an unrecoverable phone, so eligibility is - * opt-in per package. - * - * [PackageManager.MATCH_UNINSTALLED_PACKAGES] is required, not optional: a - * package hidden by `setApplicationHidden` is dropped from the default - * enumeration entirely, even though it remains `installed=true`. Querying - * with flags `0` therefore returns a set that can never contain anything - * this app has already hidden — so [EnforcementDecision.packagesToShow] - * would come back empty and nothing could ever be unhidden again. Measured - * on device: after a pass hid three apps, the following pass reported - * `hid 0, restored 0` and left them hidden permanently. - */ -internal fun sweepablePackages(context: Context, policy: FocusPolicy): Set { - val pm = context.packageManager - return pm.getInstalledApplications(PackageManager.MATCH_UNINSTALLED_PACKAGES) - .filter { info -> - val isSystem = - (info.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 - !isSystem || info.packageName in policy.blockableSystemPackages - } - .map { it.packageName } - .toSet() -} diff --git a/focus_owner/android/app/src/main/res/drawable-v21/launch_background.xml b/focus_owner/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f3..00000000 --- a/focus_owner/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/focus_owner/android/app/src/main/res/drawable/launch_background.xml b/focus_owner/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f8..00000000 --- a/focus_owner/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/focus_owner/android/app/src/main/res/values-night/styles.xml b/focus_owner/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be7..00000000 --- a/focus_owner/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/focus_owner/android/app/src/main/res/values/styles.xml b/focus_owner/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef880..00000000 --- a/focus_owner/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/focus_owner/android/app/src/main/res/xml/device_admin.xml b/focus_owner/android/app/src/main/res/xml/device_admin.xml deleted file mode 100644 index d28751e8..00000000 --- a/focus_owner/android/app/src/main/res/xml/device_admin.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/focus_owner/android/app/src/profile/AndroidManifest.xml b/focus_owner/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f6981..00000000 --- a/focus_owner/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionCurfewTest.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionCurfewTest.kt deleted file mode 100644 index 4b3069bc..00000000 --- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionCurfewTest.kt +++ /dev/null @@ -1,223 +0,0 @@ -// Curfew and always-blocked decision cases. -// -// Split out of EnforcementDecisionTest.kt to keep each file under the cap. - -package com.kuhy.focus_owner -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class EnforcementDecisionCurfewTest { - - private val homeLat = EnforcementFixtures.homeLat - private val homeLon = EnforcementFixtures.homeLon - private val metresPerDegreeLat = EnforcementFixtures.metresPerDegreeLat - - private fun policy(withCoordinates: Boolean = true, curfew: String? = """{"start":"23:00","end":"05:00"}""") = - EnforcementFixtures.policy(withCoordinates, curfew) - - private fun policyWithAlwaysBlocked() = EnforcementFixtures.policyWithAlwaysBlocked() - - private val installed = EnforcementFixtures.installed - - private fun latOffset(metres: Double) = EnforcementFixtures.latOffset(metres) - - - @Test - fun `workout releases youtube and nothing else`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs( - installed, - 23 * 60 + 30, - homeLat, - homeLon, - workoutActive = true, - ), - ) - assertEquals(EnforcementReason.WORKOUT, decision.reason) - assertTrue("com.google.android.youtube" in decision.packagesToShow) - assertTrue("com.discord" in decision.packagesToHide) - assertTrue("com.evil" in decision.packagesToHide) - } - - @Test - fun `hide and show partition the installed set`() { - // packagesToShow is the complete allowed set, never a delta, so a - // missed run self-repairs on the next one. - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 12 * 60, homeLat, homeLon), - ) - assertEquals(installed, decision.packagesToHide + decision.packagesToShow) - assertTrue(decision.packagesToHide.intersect(decision.packagesToShow).isEmpty()) - } - - @Test - fun `an unknown schema version is rejected`() { - val thrown = try { - FocusPolicy.parse("""{"schema_version": 999, "home": {}}""") - false - } catch (_: PolicyFormatException) { - true - } - assertTrue("expected PolicyFormatException", thrown) - } - - @Test - fun `prefix protection matches whole labels only`() { - val p = policy() - assertTrue(p.isProtected("com.android.settings")) - assertTrue(p.isProtected("com.android.settings.sub")) - assertFalse(p.isProtected("com.android.settingsomething")) - } - - @Test - fun `distance and threshold are reported so the phone can explain itself`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon), - ) - // ~5 km away, and the value it was actually compared against. - assertEquals(5000.0, decision.distanceM!!, 50.0) - assertEquals(150.0, decision.thresholdM!!, 0.001) - assertEquals(false, decision.insideFence) - } - - @Test - fun `hysteresis widens the threshold only for a phone already inside`() { - // 160 m out: outside the bare 150 m radius, inside 150 + 30. This is - // what stops a phone parked on the boundary from flapping. - val outside = EnforcementDecision.evaluate( - policy(), - EnforcementInputs( - installed, - 12 * 60, - latOffset(160.0), - homeLon, - currentlyEnforcing = false, - ), - ) - assertEquals(150.0, outside.thresholdM!!, 0.001) - assertEquals(EnforcementReason.AWAY, outside.reason) - - val inside = EnforcementDecision.evaluate( - policy(), - EnforcementInputs( - installed, - 12 * 60, - latOffset(160.0), - homeLon, - currentlyEnforcing = true, - ), - ) - assertEquals(180.0, inside.thresholdM!!, 0.001) - assertEquals(EnforcementReason.AT_HOME, inside.reason) - } - - @Test - fun `an unanswerable fence reports null rather than outside`() { - // No fix at all. Recording "outside" here would drop hysteresis on the - // next pass at exactly the moment the input is least reliable. - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 12 * 60, latitude = null, longitude = null), - ) - assertEquals(EnforcementReason.LOCATION_UNKNOWN, decision.reason) - assertEquals(null, decision.insideFence) - assertEquals(null, decision.distanceM) - } - - @Test - fun `no home coordinates reports an unknown distance, not zero`() { - // Fabricating 0 m would read on screen as "you are exactly at home". - val decision = EnforcementDecision.evaluate( - policy(withCoordinates = false), - EnforcementInputs(installed, 12 * 60, homeLat, homeLon), - ) - assertEquals(null, decision.distanceM) - assertEquals(true, decision.insideFence) - } - - @Test - fun `every hidden package carries a reason`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 12 * 60, homeLat, homeLon), - ) - // The UI lists these per package, so a gap would render as a blank - // explanation next to a hidden app. - assertEquals(decision.packagesToHide, decision.hideReasons.keys) - assertTrue( - decision.hideReasons.values.all { it == HideReason.NOT_IN_ALLOWLIST }, - ) - } - - @Test - fun `curfew hiding is attributed to the night list`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 23 * 60 + 30, homeLat, homeLon), - ) - assertEquals(EnforcementReason.CURFEW, decision.reason) - // com.discord is day-allowed but not night-allowed, so the distinction - // matters: "not in allowlist" would be misleading. - assertEquals( - HideReason.NOT_IN_NIGHT_ALLOWLIST, - decision.hideReasons["com.discord"], - ) - } - - @Test - fun `always-blocked is attributed as such even when away`() { - val decision = EnforcementDecision.evaluate( - policyWithAlwaysBlocked(), - EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon), - ) - assertEquals(EnforcementReason.AWAY, decision.reason) - assertEquals( - HideReason.ALWAYS_BLOCKED, - decision.hideReasons["com.google.android.youtube"], - ) - } - - @Test - fun `allowed prefixes cover a family of packages`() { - val p = FocusPolicy.parse( - """ - { - "schema_version": 1, - "home": { - "latitude": $homeLat, "longitude": $homeLon, - "radius_m": 150.0, "hysteresis_m": 30.0 - }, - "curfew": {"start":"23:00","end":"05:00"}, - "launcher_package": "com.launcher", - "allowed_packages": ["com.launcher"], - "night_allowed_packages": ["com.launcher"], - "never_disable_prefixes": [], - "workout_unblock_domains": [], - "allowed_prefixes": ["eu.kanade.tachiyomi"], - "night_allowed_prefixes": ["eu.kanade.tachiyomi"], - "browser_packages": [] - } - """.trimIndent(), - ) - assertTrue(p.isAllowed("eu.kanade.tachiyomi.sy", false)) - assertTrue(p.isAllowed("eu.kanade.tachiyomi.extension.all.mangadex", false)) - // Chosen 2026-08-14: manga stays available during the curfew. - assertTrue(p.isAllowed("eu.kanade.tachiyomi.sy", true)) - // Whole labels only. - assertFalse(p.isAllowed("eu.kanade.tachiyomisomething", false)) - } - - @Test - fun `an asset without prefix keys still parses`() { - // Back-compat: a policy rendered before prefixes existed must degrade - // to exact matching rather than failing to parse at all, since an - // unparsable policy skips the pass entirely. - assertTrue(policy().allowedPrefixes.isEmpty()) - assertTrue(policy().nightAllowedPrefixes.isEmpty()) - } -} diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt deleted file mode 100644 index 06b972bb..00000000 --- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementDecisionTest.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.kuhy.focus_owner - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Mirrors `test/enforcement_test.dart` case for case. - * - * The Kotlin decision layer exists because a background Service has no Flutter - * engine. That duplication is only safe if the two implementations are pinned - * to the same expectations, so these tests deliberately restate the Dart suite - * rather than testing something adjacent. A behaviour that changes on one side - * and not the other fails here. - */ -class EnforcementDecisionTest { - - private val homeLat = EnforcementFixtures.homeLat - private val homeLon = EnforcementFixtures.homeLon - private val metresPerDegreeLat = EnforcementFixtures.metresPerDegreeLat - - private fun policy(withCoordinates: Boolean = true, curfew: String? = """{"start":"23:00","end":"05:00"}""") = - EnforcementFixtures.policy(withCoordinates, curfew) - - private fun policyWithAlwaysBlocked() = EnforcementFixtures.policyWithAlwaysBlocked() - - private val installed = EnforcementFixtures.installed - - private fun latOffset(metres: Double) = EnforcementFixtures.latOffset(metres) - - @Test - fun `away from home restores everything except the always-blocked set`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon), - ) - assertEquals(EnforcementReason.AWAY, decision.reason) - // The base policy names nothing as always-blocked, so this is the - // original behaviour: leaving home restores the whole device. - assertTrue(decision.packagesToHide.isEmpty()) - assertEquals(installed, decision.packagesToShow) - } - - @Test - fun `always-blocked packages stay hidden away from home`() { - val decision = EnforcementDecision.evaluate( - policyWithAlwaysBlocked(), - EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon), - ) - assertEquals(EnforcementReason.AWAY, decision.reason) - assertEquals(setOf("com.google.android.youtube"), decision.packagesToHide) - assertFalse(decision.packagesToShow.contains("com.google.android.youtube")) - // Everything else still comes back, which is the point of the geofence. - assertTrue(decision.packagesToShow.contains("com.discord")) - } - - @Test - fun `always-blocked still counts as enforcing away from home`() { - val decision = EnforcementDecision.evaluate( - policyWithAlwaysBlocked(), - EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon), - ) - // The uninstall block is keyed off isEnforcing. If AWAY reported false - // here, leaving the house would make `adb uninstall` an off switch for - // the one set that is meant not to have one. - assertTrue(decision.isEnforcing) - } - - @Test - fun `a workout does not release an always-blocked package`() { - val decision = EnforcementDecision.evaluate( - policyWithAlwaysBlocked(), - EnforcementInputs( - installed, - 12 * 60, - homeLat, - homeLon, - workoutActive = true, - ), - ) - assertEquals(EnforcementReason.WORKOUT, decision.reason) - assertTrue(decision.packagesToHide.contains("com.google.android.youtube")) - } - - @Test - fun `at home only non-allowlisted apps are hidden`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 12 * 60, homeLat, homeLon), - ) - assertEquals(EnforcementReason.AT_HOME, decision.reason) - assertEquals(setOf("com.evil"), decision.packagesToHide) - } - - @Test - fun `protected packages and the launcher are never hidden`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 23 * 60 + 30, homeLat, homeLon), - ) - assertTrue("com.android.settings" in decision.packagesToShow) - assertTrue("com.launcher" in decision.packagesToShow) - } - - @Test - fun `curfew narrows the allowlist`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 23 * 60 + 30, homeLat, homeLon), - ) - assertEquals(EnforcementReason.CURFEW, decision.reason) - assertTrue("com.discord" in decision.packagesToHide) - assertTrue("pl.mbank" in decision.packagesToShow) - } - - @Test - fun `curfew wraps past midnight`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 3 * 60, homeLat, homeLon), - ) - assertEquals(EnforcementReason.CURFEW, decision.reason) - } - - @Test - fun `after curfew the day list applies again`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 6 * 60, homeLat, homeLon), - ) - assertEquals(EnforcementReason.AT_HOME, decision.reason) - assertTrue("com.discord" in decision.packagesToShow) - } - - @Test - fun `hysteresis keeps the boundary from flapping`() { - // 160m: outside the 150m radius, inside radius+hysteresis (180m). - val arriving = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 12 * 60, latOffset(160.0), homeLon, currentlyEnforcing = false), - ) - val leaving = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 12 * 60, latOffset(160.0), homeLon, currentlyEnforcing = true), - ) - assertEquals(EnforcementReason.AWAY, arriving.reason) - assertEquals(EnforcementReason.AT_HOME, leaving.reason) - } - - @Test - fun `unknown location enforces rather than releasing`() { - val decision = EnforcementDecision.evaluate( - policy(), - EnforcementInputs(installed, 12 * 60), - ) - assertEquals(EnforcementReason.LOCATION_UNKNOWN, decision.reason) - assertTrue("com.evil" in decision.packagesToHide) - } - - @Test - fun `redacted coordinates still enforce`() { - val decision = EnforcementDecision.evaluate( - policy(withCoordinates = false), - EnforcementInputs(installed, 12 * 60, latOffset(5000.0), homeLon), - ) - assertTrue("com.evil" in decision.packagesToHide) - } -} diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementFixtures.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementFixtures.kt deleted file mode 100644 index 4bba3ee5..00000000 --- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementFixtures.kt +++ /dev/null @@ -1,92 +0,0 @@ -// Shared fixtures for the enforcement decision tests. -// -// Split out of EnforcementDecisionTest.kt to keep each file under the -// 250-line cap; both test classes build policies from these. - -package com.kuhy.focus_owner -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -internal object EnforcementFixtures { - val homeLat = 52.2297 - val homeLon = 21.0122 - val metresPerDegreeLat = 111_320.0 - - fun policy(withCoordinates: Boolean = true, curfew: String? = """{"start":"23:00","end":"05:00"}""") = - FocusPolicy.parse( - """ - { - "schema_version": 1, - "home": { - "latitude": ${if (withCoordinates) homeLat else "null"}, - "longitude": ${if (withCoordinates) homeLon else "null"}, - "radius_m": 150.0, - "hysteresis_m": 30.0 - }, - "curfew": ${curfew ?: "null"}, - "launcher_package": "com.launcher", - "allowed_packages": ["com.launcher","pl.mbank","com.discord","com.google.android.youtube"], - "night_allowed_packages": ["com.launcher","pl.mbank"], - "never_disable_prefixes": ["com.android.settings"], - "workout_unblock_domains": ["youtube.com","googlevideo.com"], - "browser_packages": [] - } - """.trimIndent(), - ) - - /** - * A policy that names YouTube as always-blocked and drops it from the - * allowlist, since the exporter guarantees those two never overlap. - */ - fun policyWithAlwaysBlocked() = - FocusPolicy.parse( - """ - { - "schema_version": 1, - "home": { - "latitude": $homeLat, - "longitude": $homeLon, - "radius_m": 150.0, - "hysteresis_m": 30.0 - }, - "curfew": {"start":"23:00","end":"05:00"}, - "launcher_package": "com.launcher", - "allowed_packages": ["com.launcher","pl.mbank","com.discord"], - "night_allowed_packages": ["com.launcher","pl.mbank"], - "never_disable_prefixes": ["com.android.settings"], - "workout_unblock_domains": ["youtube.com"], - "browser_packages": [], - "blockable_system_packages": ["com.google.android.youtube"], - "always_blocked_packages": ["com.google.android.youtube"] - } - """.trimIndent(), - ) - - val installed = setOf( - "com.launcher", - "pl.mbank", - "com.discord", - "com.google.android.youtube", - "com.evil", - "com.android.settings", - ) - - fun latOffset(metres: Double) = homeLat + metres / metresPerDegreeLat - - /** - * A candidate location fix at home, varying only in the fields that fix - * selection actually orders on. Shared so the home coordinates stay in - * one place: `EnforcementLogTest` asserts they never reach a log record, - * and that assertion means less if the literals are copied around. - */ - fun candidate(ageMs: Long, accuracyM: Double?, provider: String) = LocationFix( - latitude = homeLat, - longitude = homeLon, - ageMs = ageMs, - accuracyM = accuracyM, - provider = provider, - outcome = FixOutcome.CACHED_FRESH, - ) -} diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementLogTest.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementLogTest.kt deleted file mode 100644 index 6ecb5827..00000000 --- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementLogTest.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.kuhy.focus_owner - -import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -/** - * The log is the only way this app can explain itself after the fact. - * - * `run-as` is refused on the release build device owner requires, and logcat - * rotates -- measured empty while 82 alarms had fired. So these tests cover - * the two properties that matter: a record survives to be read back, and no - * coordinate ever reaches it. - */ -class EnforcementLogTest { - - @get:Rule - val folder = TemporaryFolder() - - private val homeLat = EnforcementFixtures.homeLat - private val homeLon = EnforcementFixtures.homeLon - - private fun log() = EnforcementLog(folder.root) - - private fun decision( - reason: EnforcementReason = EnforcementReason.AT_HOME, - distanceM: Double? = 42.0, - ) = EnforcementDecision( - reason = reason, - packagesToHide = setOf("com.google.android.youtube"), - packagesToShow = setOf("com.launcher"), - distanceM = distanceM, - thresholdM = 180.0, - insideFence = true, - hideReasons = mapOf("com.google.android.youtube" to HideReason.ALWAYS_BLOCKED), - ) - - private fun fix() = LocationFix( - latitude = homeLat, - longitude = homeLon, - ageMs = 45_000L, - accuracyM = 12.0, - provider = "gps", - outcome = FixOutcome.ACTIVE_OK, - ) - - private fun record( - decision: EnforcementDecision = decision(), - fix: LocationFix? = fix(), - ) = EnforcementLog.record( - timestampMs = 1_786_000_000_000L, - decision = decision, - fix = fix, - policy = null, - homeConfigured = true, - permissions = setOf("android.permission.ACCESS_FINE_LOCATION"), - hidDelta = listOf("com.google.android.youtube"), - restoredDelta = emptyList(), - ) - - @Test - fun `a record round-trips through the file`() { - log().append(record()) - val lines = log().readRecent() - assertEquals(1, lines.size) - val parsed = JSONObject(lines.first()) - assertEquals("AT_HOME", parsed.getString("reason")) - assertEquals(42.0, parsed.getDouble("distance_m"), 0.001) - assertEquals(180.0, parsed.getDouble("threshold_m"), 0.001) - assertEquals("gps", parsed.getJSONObject("fix").getString("provider")) - assertEquals("ACTIVE_OK", parsed.getJSONObject("fix").getString("outcome")) - } - - @Test - fun `the record never contains coordinates`() { - // The single most important property here: logcat and this file are - // both reachable by anyone with the phone, and this is a home address. - val serialised = record().toString() - assertFalse(serialised.contains("52.2")) - assertFalse(serialised.contains("21.0")) - assertFalse(serialised.contains("latitude")) - assertFalse(serialised.contains("longitude")) - } - - @Test - fun `every hidden package carries why it is hidden`() { - val parsed = JSONObject(record().toString()) - val hidden = parsed.getJSONArray("hidden") - assertEquals(1, hidden.length()) - assertEquals("com.google.android.youtube", hidden.getJSONObject(0).getString("pkg")) - assertEquals("ALWAYS_BLOCKED", hidden.getJSONObject(0).getString("why")) - } - - @Test - fun `records read back newest first`() { - val subject = log() - subject.append(record(decision(reason = EnforcementReason.AWAY))) - subject.append(record(decision(reason = EnforcementReason.CURFEW))) - val lines = subject.readRecent() - assertEquals("CURFEW", JSONObject(lines[0]).getString("reason")) - assertEquals("AWAY", JSONObject(lines[1]).getString("reason")) - } - - @Test - fun `readRecent respects the limit`() { - val subject = log() - repeat(5) { subject.append(record()) } - assertEquals(2, subject.readRecent(limit = 2).size) - } - - @Test - fun `a corrupt line is skipped rather than losing the history`() { - // A process killed mid-append must not cost the whole log, which is - // exactly when the log is most wanted. - val subject = log() - subject.append(record()) - java.io.File(folder.root, "enforcement_log.jsonl").appendText("{half-written\n") - subject.append(record(decision(reason = EnforcementReason.AWAY))) - val lines = subject.readRecent() - assertEquals(2, lines.size) - assertEquals("AWAY", JSONObject(lines[0]).getString("reason")) - } - - @Test - fun `an empty log reads as empty rather than failing`() { - assertTrue(log().readRecent().isEmpty()) - } - - @Test - fun `the file is capped and rotates on whole lines`() { - val subject = log() - // Enough records to trigger several rotations. - repeat(1200) { subject.append(record()) } - val file = java.io.File(folder.root, "enforcement_log.jsonl") - val lines = file.readLines().filter { it.isNotBlank() } - // Growth is bounded, but by neither constant alone: a trim drops the - // file to KEEP_LINES (400) and appends then resume until MAX_BYTES is - // next crossed, so the steady state is 400 plus one trim interval. - // What matters is that 1200 appends do not accumulate. - assertTrue("expected rotation, got ${lines.size} lines", lines.size < 1200) - assertTrue("expected a bounded file, got ${lines.size}", lines.size <= 800) - assertTrue(lines.isNotEmpty()) - // Rotating mid-record would make the oldest survivor unparsable. - lines.forEach { JSONObject(it) } - } - - @Test - fun `a pass with no fix records the outcome rather than a bare null`() { - val parsed = JSONObject( - record( - decision = decision( - reason = EnforcementReason.LOCATION_UNKNOWN, - distanceM = null, - ), - fix = LocationFix.unavailable(FixOutcome.TIMEOUT), - ).toString(), - ) - assertEquals("LOCATION_UNKNOWN", parsed.getString("reason")) - assertTrue(parsed.isNull("distance_m")) - // This is the field that separates "at the office, cache empty" from - // "fuzzed fix at home" -- the two symptoms that looked identical. - assertEquals("TIMEOUT", parsed.getJSONObject("fix").getString("outcome")) - assertTrue(parsed.getJSONObject("fix").isNull("age_ms")) - } -} diff --git a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementRunnerBestTest.kt b/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementRunnerBestTest.kt deleted file mode 100644 index b133a063..00000000 --- a/focus_owner/android/app/src/test/kotlin/com/kuhy/focus_owner/EnforcementRunnerBestTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.kuhy.focus_owner - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Fix selection: which of several candidate locations a pass actually uses. - * - * Split out of `EnforcementLogTest`, which had grown to cover two unrelated - * subjects. These cases never touch the log -- they pin the ordering rule that - * decides whether a pass sees a usable fix at all, which is upstream of - * anything being written down. - */ -class EnforcementRunnerBestTest { - - private fun candidate(ageMs: Long, accuracyM: Double?, provider: String) = - EnforcementFixtures.candidate(ageMs, accuracyM, provider) - - private val window = 30L * 60L * 1000L - - @Test - fun `a recent coarse fix beats an old precise one`() { - // The ordering that matters. Sorting by accuracy first would surface - // the morning GPS fix, the age gate would then reject it, the active - // request times out indoors, and the pass falls to LOCATION_UNKNOWN - // and blocks everything -- the office symptom, reintroduced. - val chosen = EnforcementRunner.best( - listOf( - candidate(ageMs = 6L * 60 * 60 * 1000, accuracyM = 10.0, provider = "gps"), - candidate(ageMs = 2L * 60 * 1000, accuracyM = 500.0, provider = "network"), - ), - window, - ) - assertEquals("network", chosen?.provider) - } - - @Test - fun `within the window the more accurate fix wins`() { - // A 2 km fix cannot answer a 150 m question, so a slightly older but - // far more precise fix is the better input. - val chosen = EnforcementRunner.best( - listOf( - candidate(ageMs = 10_000, accuracyM = 2000.0, provider = "network"), - candidate(ageMs = 60_000, accuracyM = 10.0, provider = "gps"), - ), - window, - ) - assertEquals("gps", chosen?.provider) - } - - @Test - fun `unknown accuracy loses to a known one`() { - val chosen = EnforcementRunner.best( - listOf( - candidate(ageMs = 10_000, accuracyM = null, provider = "passive"), - candidate(ageMs = 60_000, accuracyM = 80.0, provider = "gps"), - ), - window, - ) - assertEquals("gps", chosen?.provider) - } - - @Test - fun `with nothing fresh the newest stale fix is reported`() { - // The caller classifies it unusable regardless; returning the newest - // makes the log record describe what was actually available. - val chosen = EnforcementRunner.best( - listOf( - candidate(ageMs = 6L * 60 * 60 * 1000, accuracyM = 10.0, provider = "gps"), - candidate(ageMs = 40L * 60 * 1000, accuracyM = 500.0, provider = "network"), - ), - window, - ) - assertEquals("network", chosen?.provider) - } - - @Test - fun `no candidates yields no fix`() { - assertEquals(null, EnforcementRunner.best(emptyList(), window)) - } - - @Test - fun `a single fix is returned even with unknown accuracy`() { - val chosen = EnforcementRunner.best( - listOf(candidate(ageMs = 10_000, accuracyM = null, provider = "passive")), - window, - ) - assertEquals("passive", chosen?.provider) - } -} diff --git a/focus_owner/android/build.gradle.kts b/focus_owner/android/build.gradle.kts deleted file mode 100644 index dbee657b..00000000 --- a/focus_owner/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/focus_owner/android/gradle.properties b/focus_owner/android/gradle.properties deleted file mode 100644 index e96108cf..00000000 --- a/focus_owner/android/gradle.properties +++ /dev/null @@ -1,6 +0,0 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -# This newDsl flag was added by the Flutter template -android.newDsl=false -# This builtInKotlin flag was added by the Flutter template -android.builtInKotlin=false diff --git a/focus_owner/android/gradle/wrapper/gradle-wrapper.properties b/focus_owner/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2d428bfb..00000000 --- a/focus_owner/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/focus_owner/android/settings.gradle.kts b/focus_owner/android/settings.gradle.kts deleted file mode 100644 index c21f0c5b..00000000 --- a/focus_owner/android/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "9.0.1" apply false - id("org.jetbrains.kotlin.android") version "2.3.20" apply false -} - -include(":app") diff --git a/focus_owner/assets/policy.json b/focus_owner/assets/policy.json deleted file mode 100644 index 563b2d96..00000000 --- a/focus_owner/assets/policy.json +++ /dev/null @@ -1,153 +0,0 @@ -{ - "allowed_packages": [ - "com.android.vending", - "com.celzero.bravedns", - "com.facebook.orca", - "com.google.android.apps.maps", - "com.google.android.apps.nexuslauncher", - "com.google.android.calendar", - "com.google.android.deskclock", - "com.ichi2.anki", - "com.kuhy.diet_guard_app", - "com.kuhy.focus_owner", - "com.kuhy.home_inventory", - "com.kuhy.lyricanki", - "com.kuhy.untools", - "com.kuhy.wake_alarm_sync", - "com.kuhy.workout_app", - "com.kunzisoft.keepass.libre", - "com.metrolist.music", - "com.revolut.revolut", - "com.sosauce.cutecalc", - "com.zaneschepke.wireguardautotunnel", - "dev.kuhy.todo", - "eu.kanade.tachiyomi.sy", - "org.fossify.contacts", - "org.fossify.messages", - "org.fossify.phone", - "org.runnerup", - "org.runnerup.free", - "pl.infakt.infakt", - "pl.mbank", - "pl.nask.mobywatel" - ], - "allowed_prefixes": [ - "eu.kanade.tachiyomi" - ], - "always_blocked_packages": [ - "com.android.chrome", - "com.google.android.apps.youtube.music", - "com.google.android.youtube" - ], - "always_on_vpn_package": "com.celzero.bravedns", - "blockable_system_packages": [ - "com.android.chrome", - "com.android.vending", - "com.google.android.apps.youtube.music", - "com.google.android.videos", - "com.google.android.youtube" - ], - "browser_packages": [ - "com.android.chrome", - "org.mozilla.fenix" - ], - "curfew": { - "end": "05:00", - "start": "23:00" - }, - "home": { - "hysteresis_m": 30.0, - "latitude": null, - "longitude": null, - "radius_m": 150.0 - }, - "launcher_package": "com.google.android.apps.nexuslauncher", - "never_disable_prefixes": [ - "android", - "com.android.bluetooth", - "com.android.camera", - "com.android.captiveportallogin", - "com.android.certinstaller", - "com.android.contacts", - "com.android.dialer", - "com.android.documentsui", - "com.android.emergency", - "com.android.externalstorage", - "com.android.inputmethod", - "com.android.keychain", - "com.android.launcher", - "com.android.location", - "com.android.messaging", - "com.android.mms", - "com.android.networkstack", - "com.android.nfc", - "com.android.permissioncontroller", - "com.android.phone", - "com.android.providers", - "com.android.se", - "com.android.settings", - "com.android.shell", - "com.android.systemui", - "com.android.vpndialogs", - "com.android.wifi", - "com.celzero.bravedns", - "com.google.android.apps.messaging", - "com.google.android.contacts", - "com.google.android.deskclock", - "com.google.android.dialer", - "com.google.android.ext.services", - "com.google.android.ext.shared", - "com.google.android.gms", - "com.google.android.gsf", - "com.google.android.inputmethod.latin", - "com.google.android.permissioncontroller", - "com.google.android.setupwizard", - "com.google.android.trichromelibrary", - "com.google.android.webview", - "com.mediatek", - "com.qualcomm", - "pl.infakt.infakt" - ], - "night_allowed_packages": [ - "com.celzero.bravedns", - "com.google.android.apps.nexuslauncher", - "com.google.android.deskclock", - "com.kuhy.focus_owner", - "com.kuhy.wake_alarm_sync", - "com.kunzisoft.keepass.libre", - "com.metrolist.music", - "com.revolut.revolut", - "dev.kuhy.todo", - "org.fossify.contacts", - "org.fossify.messages", - "org.fossify.phone", - "org.runnerup", - "org.runnerup.free", - "pl.infakt.infakt", - "pl.mbank", - "pl.nask.mobywatel" - ], - "night_allowed_prefixes": [ - "eu.kanade.tachiyomi" - ], - "private_dns_host": "", - "schema_version": 1, - "vpn_lockdown": true, - "workout_unblock_domains": [ - "googlevideo.com", - "i.ytimg.com", - "i9.ytimg.com", - "m.youtube.com", - "s.ytimg.com", - "www.youtube-nocookie.com", - "www.youtube.com", - "youtu.be", - "youtube-nocookie.com", - "youtube.com", - "youtube.googleapis.com", - "youtubei.googleapis.com", - "yt3.ggpht.com", - "yt3.googleusercontent.com", - "ytimg.com" - ] -} diff --git a/focus_owner/docs/profile-owner-verification-2026-08-09.md b/focus_owner/docs/profile-owner-verification-2026-08-09.md deleted file mode 100644 index 22910cce..00000000 --- a/focus_owner/docs/profile-owner-verification-2026-08-09.md +++ /dev/null @@ -1,172 +0,0 @@ -# EnforcementRunner on-device verification (profile-owner harness) - -Date: 2026-08-09. Device: Pixel 6a `23181JEGR08034`, Android 16 (SDK 36). - -The Device Owner path is still blocked on a factory reset, so this run used a -removable managed profile as a stand-in: `pm create-user --profileOf 0 ---managed` (user 12) → `dpm set-profile-owner` → `pm install-existing --user 12`. -Profile owner and device owner reach the same `DevicePolicyManager` calls, so -the wiring under test is identical; what differs is scope, recorded below. - -## Verified - -| Property | Method | Result | -| ---------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | -| Policy loads on device | app status screen | 84 allowed (39 night), curfew 23:00-05:00, radius 150 m, 15 workout domains | -| Enforcement pass runs end-to-end | tap "Run enforcement now" | `applied LOCATION_UNKNOWN: hid 3, restored 0` | -| Hide is real, not self-reported | `dumpsys package ` per-user `hidden=` | `com.termux`, `org.breezyweather`, `de.duenndns.gmdice` all `hidden=true` | -| Hidden app is unreachable | `am start --user 12 -n com.termux/...` | `Error type 3 ... does not exist` | -| Self-hide guard holds | same dumpsys sweep | `com.kuhy.focus_owner` stayed `hidden=false` | -| **Hide survives reboot** | `adb reboot` → re-check | all three still `hidden=true` | -| Next pass is armed | `dumpsys alarm` | `*walarm*:com.kuhy.focus_owner/.EnforcementAlarmReceiver` present | -| **Unhide restores access** | after the fix below | `restored 3`; all `hidden=false`, launchable again | -| Non-exported service rejects shell | `am start-foreground-service` | `Requires permission not exported from uid 1210324` — as designed; the UI button is the only trigger | - -The reboot result is the one that matters: it is the property the whole Device -Owner design rests on, and it is the one `pm suspend` and `pm disable-user` -both fail. - -## Not covered by this harness - -- **The geofence.** Retried after real coordinates were provisioned (see - below), and still not reachable: a freshly created work profile has its own - location cache, which starts empty and stays empty because nothing in the - profile requests a fix. `dumpsys location` showed - `user 10: last location=null` against a valid `52.228948,20.951184` fix in - user 0. `EnforcementRunner.lastKnownLocation()` therefore returned null and - every pass resolved to `LOCATION_UNKNOWN` (fail-closed) regardless. Shell - mock injection is blocked (`SecurityException: not allowed to perform -MOCK_LOCATION`) and `cmd location force-location-update` does not exist on - this build. **The AWAY branch remains unexercised on hardware** and will - only be testable under a real Device Owner in user 0. - - Note the earlier `run-as` failure was mine, not a platform limit: `run-as ---user N` works once the app has been launched in user N. Coordinates were - successfully written into the profile's `filesDir` on the second attempt. - -- **The full 373-package sweep.** The profile saw only the packages - deliberately installed into it. -- **Persisted hysteresis.** `wasEnforcing()` starts fresh in a new profile. -- **System packages.** `com.google.android.dialer` is a system app, so - `installedThirdPartyPackages()` filters it out; it did not exercise the - allowlist branch as intended. - -## The bug this run found: hidden apps could never be restored - -Testing the restore path meant widening the allowlist to cover the three test -apps and re-running a pass. Expected `restored 3`. Observed: - -``` -applied LOCATION_UNKNOWN: hid 0, restored 0 -``` - -with all three still `hidden=true`. The cause is that -`pm list packages --user 12 -3` returned _only_ `com.kuhy.focus_owner` — a -package hidden via `setApplicationHidden` is dropped from the default package -enumeration entirely, while remaining `installed=true hidden=true` and visible -under `pm list packages -u`. - -`EnforcementRunner.installedThirdPartyPackages()` called -`getInstalledApplications(0)`. So once an app was hidden it left -`installedPackages`, which meant it could never appear in -`EnforcementDecision.packagesToShow`, which meant **nothing this app hid could -ever be unhidden again** — the exact failure the show-before-hide ordering in -`apply()` exists to prevent. Leaving home would not have restored anything. - -Fix: query with `PackageManager.MATCH_UNINSTALLED_PACKAGES`. Re-verified on -device after the fix: - -``` -setApplicationHidden(de.duenndns.gmdice,false)=true -setApplicationHidden(org.breezyweather,false)=true -setApplicationHidden(com.termux,false)=true -applied LOCATION_UNKNOWN: hid 0, restored 3 -``` - -All three then read `hidden=false`, reappeared in the default enumeration, and -`cmd package resolve-activity com.termux` resolved to `TermuxActivity` again. - -The flag also admits packages that are uninstalled with data retained, which -would let `packagesToHide` contain ghosts. Measured on this device: -`pm list packages -3` and `pm list packages -u -3` both return 49, so there are -none. `apply()` wraps both bridge calls in `runCatching` regardless, so a ghost -would be a no-op rather than a crash. - -## Second finding: the self-hide guard is not redundant - -`EnforcementRunner.kt:79-80` comments the self-hide check as defence in depth — -_"the policy already refuses to hide these"_. Measured against the committed -policy, it does not: `com.kuhy.focus_owner` is absent from `allowed_packages`, -absent from `night_allowed_packages`, and matches none of the 44 -`never_disable_prefixes`. The decision layer therefore places the app in -`packagesToHide` on **every** enforcing pass, and the `pkg == -context.packageName` check at line 81 is the _only_ thing stopping the app from -hiding itself — which would remove the escape hatch and the "Run enforcement -now" trigger together. - -Fix belongs in the exporter (`python_pkg/focus_policy/export.py`), not a -hand-edit of the asset, so the guarantee is generated rather than remembered. -The line-81 check should stay either way; only the comment is wrong. - -## Wiring gap found - -`EnforcementRunner.decide()` hardcodes `workoutActive = false` -(`EnforcementRunner.kt:57`). The decision layer honours the flag -(`EnforcementDecision.kt:83`), but nothing feeds it in the Kotlin path — and -the Kotlin path is the one that runs at alarm time, since a background service -has no Flutter engine. **The workout unblock is currently dead in the deployed -path.** Not fixed in this session. - -The Dart side is thinner than it looks. `lib/workout_signal.dart` defines -`ManualWorkoutSignal`, `GuardedWorkoutSignal` and `CombinedWorkoutSignal`, all -unit-tested, but none is constructed anywhere in `lib/` — and there is **no -Health Connect integration at all**: no `androidx.health` dependency, no -`android.permission.health.*` in the manifest, no query. `'health_connect'` -appears only as a fixture string in `test/workout_signal_test.dart`. Wiring -Health Connect is a from-scratch build, not a port. - -It is, however, a viable one. Measured on device: `com.stronglifts.app` holds -`android.permission.health.WRITE_EXERCISE`, `WRITE_ACTIVE_CALORIES_BURNED`, -`WRITE_WEIGHT`, `WRITE_HEIGHT` and the matching `READ_*` grants, all -`granted=true`, and `com.google.android.apps.healthdata` is installed. So the -workout data the rooted system used to read out of StrongLifts' SQLite file is -reachable through a supported API, which is the natural replacement for that -lost signal. - -## Home coordinates: placeholder found and replaced - -`config_secrets.sh` still held `REDACTED_LAT`/`REDACTED_LON`, so the policy -asset could not be regenerated. Real coordinates were taken from the Pixel 6a's -own fix rather than a map lookup — `52.228948, 20.951184`, network provider, -`hAcc=21.7 m`, consistent across the fused and network providers. - -That surfaced a second staleness problem: the coordinates already provisioned -into the app's private storage were `52.2297, 21.0122` — Warsaw city centre, a -placeholder. **4156 m from the real location, against a 150 m radius**, so the -geofence would have read as permanently away from home. Re-provisioned via -`scripts/push_home_location.sh` and verified on device. - -Regenerating `assets/policy.json` then confirmed the exporter fix end-to-end: -`com.kuhy.focus_owner` now appears in both allowlists. The regeneration also -picked up `com.kuhy.untools`, which was already in `config.sh:365` — the -committed asset was simply stale. - -The committed asset stays redacted: `latitude: null`, `longitude: null`, and -zero occurrences of the real values. The unredacted export (stdout only, used -by the provisioning script) carries them. - -## Teardown (done) - -`pm remove-user 12` → `Success: removed user`; a clean build (committed policy -asset, no widened allowlist) reinstalled into user 0; staged -`/data/local/tmp/home_location.json` removed. Verified afterwards: - -- `pm list users` → user 0 only -- `Device Owner Type: -1`, no profile owner -- `com.termux`, `org.breezyweather`, `de.duenndns.gmdice`, - `com.stronglifts.app` all `hidden=false` in user 0 - -Three `EnforcementAlarmReceiver` alarms remain registered by the user-0 -instance. They are inert: `DevicePolicyBridge.setApplicationHidden` returns -early when the caller is neither device owner nor profile owner, which user 0 -is not. diff --git a/focus_owner/lib/debug_log_page.dart b/focus_owner/lib/debug_log_page.dart deleted file mode 100644 index ca4d8f13..00000000 --- a/focus_owner/lib/debug_log_page.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:focus_owner/enforcement_record.dart'; -import 'package:focus_owner/theme.dart'; - -/// The enforcement history, newest first. -/// -/// This screen is the deliverable for "extensive debug log to see when it does -/// not work". It has to live in the app: logcat rotates away within minutes -/// (measured empty while `dumpsys alarm` reported 82 alarms had fired) and -/// `run-as` is refused on the release build device owner requires, so neither -/// adb route can read the history. -class DebugLogPage extends StatelessWidget { - const DebugLogPage({required this.records, super.key}); - - final List records; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: kField, - appBar: AppBar( - backgroundColor: kField, - foregroundColor: kText, - title: const Text('Enforcement log'), - ), - body: records.isEmpty - ? const Center( - child: Padding( - padding: EdgeInsets.all(kGap * 2), - child: Text( - 'No passes recorded yet.\n\n' - 'Each enforcement pass appends one entry here, so this fills ' - 'up on its own every 15 minutes.', - textAlign: TextAlign.center, - style: TextStyle(color: kMuted, height: 1.4), - ), - ), - ) - : ListView.separated( - padding: const EdgeInsets.all(kGap), - itemCount: records.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) => _Entry(record: records[index]), - ), - ); - } -} - -class _Entry extends StatelessWidget { - const _Entry({required this.record}); - - final EnforcementRecord record; - - @override - Widget build(BuildContext context) { - // Material, not a decorated box: ExpansionTile paints its ink on the - // nearest Material ancestor and a DecoratedBox in between hides it. - return Material( - color: kSurface, - borderRadius: BorderRadius.circular(6), - child: Theme( - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - title: Row( - children: [ - Text( - _clock(record.timestamp), - style: const TextStyle(color: kMuted, fontSize: 12), - ), - const SizedBox(width: kGap / 2), - Expanded( - child: Text( - record.failure != null - ? 'NO DECISION' - : record.reason.replaceAll('_', ' '), - style: TextStyle( - color: record.failure != null || record.locationUnknown - ? kDanger - : kText, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - Text( - record.distanceLabel, - style: const TextStyle(color: kMuted, fontSize: 12), - ), - ], - ), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(kGap, 0, kGap, kGap), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _line('When', record.timestamp.toString()), - _line('Fix', record.fixLabel), - _line( - 'Curfew', - switch (record.curfewActive) { - true => 'active', - false => 'inactive', - null => 'unknown', - }, - ), - _line('Home set', '${record.homeConfigured ?? "unknown"}'), - _line('Inside fence', '${record.insideFence ?? "unanswerable"}'), - _line( - 'Counts', - '${record.hideCount} hidden / ${record.showCount} shown · ' - 'hid ${record.hidNow}, restored ${record.restoredNow}', - ), - if (record.failure != null) - _line('Failure', record.failure!, danger: true), - if (record.hidden.isNotEmpty) ...[ - const SizedBox(height: 6), - const Text( - 'Hidden', - style: TextStyle(color: kMuted, fontSize: 12), - ), - for (final h in record.hidden) - Text( - ' ${h.package} — ${h.reason.explanation}', - style: const TextStyle(color: kText, fontSize: 11), - ), - ], - const SizedBox(height: 6), - // Lets a record leave the phone without adb, which is the - // only other way it could be shared. - TextButton( - onPressed: () => Clipboard.setData( - ClipboardData(text: _asText(record)), - ), - child: const Text('Copy'), - ), - ], - ), - ), - ], - ), - ), - ); - } - - static Widget _line(String label, String value, {bool danger = false}) => - Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 110, - child: Text( - label, - style: const TextStyle(color: kMuted, fontSize: 12), - ), - ), - Expanded( - child: Text( - value, - style: TextStyle( - color: danger ? kDanger : kText, - fontSize: 12, - ), - ), - ), - ], - ), - ); - - static String _clock(DateTime at) => - '${at.hour.toString().padLeft(2, '0')}:' - '${at.minute.toString().padLeft(2, '0')}'; - - static String _asText(EnforcementRecord r) => [ - 'when: ${r.timestamp}', - 'reason: ${r.reason}', - 'distance: ${r.distanceLabel}', - 'fix: ${r.fixLabel}', - 'curfew active: ${r.curfewActive}', - 'home configured: ${r.homeConfigured}', - 'inside fence: ${r.insideFence}', - 'hidden: ${r.hideCount}, shown: ${r.showCount}', - 'hid now: ${r.hidNow}, restored now: ${r.restoredNow}', - if (r.failure != null) 'failure: ${r.failure}', - for (final h in r.hidden) ' ${h.package} (${h.reason.explanation})', - ].join('\n'); -} diff --git a/focus_owner/lib/device_policy.dart b/focus_owner/lib/device_policy.dart deleted file mode 100644 index 0ff3ad9a..00000000 --- a/focus_owner/lib/device_policy.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/services.dart'; - -/// Provisioning state reported by the native side. -class DevicePolicyStatus { - const DevicePolicyStatus({ - required this.packageName, - required this.isDeviceOwner, - required this.isAdminActive, - required this.sdkInt, - required this.restrictionsApplied, - this.hasAccounts = true, - }); - - /// Builds a status from the raw platform-channel map. - factory DevicePolicyStatus.fromMap(Map 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 status() async { - final result = - await channel.invokeMethod>('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 runEnforcementNow() async => - await channel.invokeMethod('runEnforcementNow') ?? false; - - /// Cancels any pending scheduled evaluation. - Future cancelEnforcement() async => - await channel.invokeMethod('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> readEnforcementLog({int limit = 200}) async { - final lines = await channel.invokeMethod>( - 'readEnforcementLog', - {'limit': limit}, - ); - return lines?.whereType().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 releaseDeviceOwner() async { - final released = await channel.invokeMethod('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 setHomeToCurrentLocation() async => - channel.invokeMethod('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 hasHomeLocation() async => - await channel.invokeMethod('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 setVpnConfigBlocked({required bool blocked}) async => - await channel.invokeMethod( - 'setVpnConfigBlocked', - {'blocked': blocked}, - ) ?? - false; -} diff --git a/focus_owner/lib/enforcement_record.dart b/focus_owner/lib/enforcement_record.dart deleted file mode 100644 index 31bf6821..00000000 --- a/focus_owner/lib/enforcement_record.dart +++ /dev/null @@ -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 json) { - final fix = json['fix']; - final fixMap = fix is Map ? fix : const {}; - final counts = json['counts']; - final countMap = - counts is Map ? counts : const {}; - 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 parseLines(List lines) { - final records = []; - for (final line in lines) { - try { - final decoded = jsonDecode(line); - if (decoded is Map) { - 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 _parseHidden(Object? raw) { - if (raw is! List) return const []; - return raw - .whereType>() - .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 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 = [ - 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; -} diff --git a/focus_owner/lib/enforcement_status_card.dart b/focus_owner/lib/enforcement_status_card.dart deleted file mode 100644 index 342b99bc..00000000 --- a/focus_owner/lib/enforcement_status_card.dart +++ /dev/null @@ -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'; - } -} diff --git a/focus_owner/lib/focus_policy.dart b/focus_owner/lib/focus_policy.dart deleted file mode 100644 index 609f055e..00000000 --- a/focus_owner/lib/focus_policy.dart +++ /dev/null @@ -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 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) { - 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), - 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 load({ - AssetBundle? bundle, - String assetKey = 'assets/policy.json', - }) async { - final raw = await (bundle ?? rootBundle).loadString(assetKey); - final decoded = jsonDecode(raw); - if (decoded is! Map) { - throw const PolicyFormatException('policy asset is not a JSON object'); - } - return FocusPolicy.fromJson(decoded); - } - - final HomeLocation home; - final Set allowedPackages; - final Set nightAllowedPackages; - final Set neverDisablePrefixes; - final Set 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 allowedPrefixes; - - /// Prefixes that survive the curfew. Subset of [allowedPrefixes]. - final Set 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 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 get workoutExemptPackages { - const domainToPackages = >{ - 'youtube.com': [ - 'com.google.android.youtube', - 'com.google.android.apps.youtube.music', - ], - }; - final packages = {}; - 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 _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 _optionalStringSet(Map 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; -} diff --git a/focus_owner/lib/home_location.dart b/focus_owner/lib/home_location.dart deleted file mode 100644 index aabcfa15..00000000 --- a/focus_owner/lib/home_location.dart +++ /dev/null @@ -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) 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 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 clear() async { - if (file.existsSync()) await file.delete(); - } -} diff --git a/focus_owner/lib/main.dart b/focus_owner/lib/main.dart deleted file mode 100644 index ec1f30d5..00000000 --- a/focus_owner/lib/main.dart +++ /dev/null @@ -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 Function() loadFocusPolicy; - - @override - State createState() => _StatusPageState(); -} diff --git a/focus_owner/lib/policy.dart b/focus_owner/lib/policy.dart deleted file mode 100644 index 45bbc0d2..00000000 --- a/focus_owner/lib/policy.dart +++ /dev/null @@ -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 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 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; -} diff --git a/focus_owner/lib/status_body.dart b/focus_owner/lib/status_body.dart deleted file mode 100644 index a1ba9de3..00000000 --- a/focus_owner/lib/status_body.dart +++ /dev/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 Function() onRelease; - final Future Function() onRunNow; - final Future Function() onSetHome; - final Future Function() onLockVpn; - - /// Whether home coordinates exist; without them enforcement is permanent. - final bool hasHome; - - /// Recorded passes, newest first. - final List 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)); -} diff --git a/focus_owner/lib/status_card_parts.dart b/focus_owner/lib/status_card_parts.dart deleted file mode 100644 index 70a7fdb3..00000000 --- a/focus_owner/lib/status_card_parts.dart +++ /dev/null @@ -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 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, - ), - ), - ); -} diff --git a/focus_owner/lib/status_page_dialogs.dart b/focus_owner/lib/status_page_dialogs.dart deleted file mode 100644 index 8bf225c9..00000000 --- a/focus_owner/lib/status_page_dialogs.dart +++ /dev/null @@ -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 Function() onRelease, - required Future Function() onRunNow, - required Future Function() onSetHome, - required Future Function() onLockVpn, - required bool hasHome, - required List 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'), - ), - ], - ); -} diff --git a/focus_owner/lib/status_page_state.dart b/focus_owner/lib/status_page_state.dart deleted file mode 100644 index 40894263..00000000 --- a/focus_owner/lib/status_page_state.dart +++ /dev/null @@ -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 { - DevicePolicyStatus? _status; - FocusPolicy? _focusPolicy; - String? _policyError; - String? _error; - bool _busy = false; - bool _hasHome = false; - List _log = const []; - - @override - void initState() { - super.initState(); - unawaited(_refresh()); - } - - Future _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 _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 _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 _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 _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 _awaitNewRecord() async { - final before = _log.isEmpty ? null : _log.first.timestamp; - for (var attempt = 0; attempt < _kRefreshAttempts; attempt++) { - await Future.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 _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 _lockVpn() async { - final confirmed = await showDialog( - 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 _release() async { - final hasAccounts = _status?.hasAccounts ?? true; - final confirmed = await showDialog( - 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(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, - ), - ); - } -} diff --git a/focus_owner/lib/theme.dart b/focus_owner/lib/theme.dart deleted file mode 100644 index 5ec0df60..00000000 --- a/focus_owner/lib/theme.dart +++ /dev/null @@ -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; diff --git a/focus_owner/lib/workout_signal.dart b/focus_owner/lib/workout_signal.dart deleted file mode 100644 index cb77a90b..00000000 --- a/focus_owner/lib/workout_signal.dart +++ /dev/null @@ -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 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 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 Function() probe; - - /// How long to wait before treating the lookup as failed. - final Duration timeout; - - @override - Future 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 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 activeSource() async { - for (final source in sources) { - if (await source.isActive()) return source.name; - } - return null; - } - - @override - Future isActive() async => (await activeSource()) != null; -} diff --git a/focus_owner/pubspec.lock b/focus_owner/pubspec.lock deleted file mode 100644 index 4d28b6f1..00000000 --- a/focus_owner/pubspec.lock +++ /dev/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" diff --git a/focus_owner/pubspec.yaml b/focus_owner/pubspec.yaml deleted file mode 100644 index 4582477b..00000000 --- a/focus_owner/pubspec.yaml +++ /dev/null @@ -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 diff --git a/focus_owner/scripts/push_home_location.sh b/focus_owner/scripts/push_home_location.sh deleted file mode 100755 index 84b0ab9f..00000000 --- a/focus_owner/scripts/push_home_location.sh +++ /dev/null @@ -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 ] [--secrets ] [--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 < adb serial (default: $DEFAULT_SERIAL) - --secrets 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 "$@" diff --git a/focus_owner/test/enforcement_record_test.dart b/focus_owner/test/enforcement_record_test.dart deleted file mode 100644 index 00d8375e..00000000 --- a/focus_owner/test/enforcement_record_test.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:focus_owner/enforcement_record.dart'; - -Map _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); - }); - }); -} diff --git a/focus_owner/test/enforcement_status_card_test.dart b/focus_owner/test/enforcement_status_card_test.dart deleted file mode 100644 index d398ec30..00000000 --- a/focus_owner/test/enforcement_status_card_test.dart +++ /dev/null @@ -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 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 _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(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(); - }); - }); -} diff --git a/focus_owner/test/home_location_test.dart b/focus_owner/test/home_location_test.dart deleted file mode 100644 index 5de48cfb..00000000 --- a/focus_owner/test/home_location_test.dart +++ /dev/null @@ -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); - }); - }); -} diff --git a/focus_owner/test/policy_decisions_test.dart b/focus_owner/test/policy_decisions_test.dart deleted file mode 100644 index 87c7e72b..00000000 --- a/focus_owner/test/policy_decisions_test.dart +++ /dev/null @@ -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 _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': [], - }; - -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()), - ); - }); - - 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()), - ); - }); - }); - - 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 load(String key) async => - ByteData.sublistView(utf8.encode(contents)); - - @override - Future 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 load(String key) async { - throw UnimplementedError(key); - } - - @override - Future loadString(String key, {bool cache = true}) async { - final file = File('assets/policy.json'); - return file.readAsString(); - } -} diff --git a/focus_owner/test/policy_test.dart b/focus_owner/test/policy_test.dart deleted file mode 100644 index f5fa2111..00000000 --- a/focus_owner/test/policy_test.dart +++ /dev/null @@ -1,101 +0,0 @@ - -import 'package:flutter_test/flutter_test.dart'; -import 'package:focus_owner/policy.dart'; - -Map _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': [], - }; - -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()), - ); - }); - - test('rejects a document with no home object', () { - final doc = _doc()..remove('home'); - expect( - () => FocusPolicy.fromJson(doc), - throwsA(isA()), - ); - }); - - test('rejects a malformed curfew time', () { - expect( - () => FocusPolicy.fromJson(_doc(curfew: {'start': '25:00', 'end': 'x'})), - throwsA(isA()), - ); - }); - - 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); - }); - }); -} diff --git a/focus_owner/test/status_page_actions_test.dart b/focus_owner/test/status_page_actions_test.dart deleted file mode 100644 index a463eab9..00000000 --- a/focus_owner/test/status_page_actions_test.dart +++ /dev/null @@ -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 calls}) _fakeChannel( - Map responses, -) { - const channel = MethodChannel('test/device_policy'); - final calls = []; - // 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 _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 _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': [], - 'never_disable_prefixes': [], - 'workout_unblock_domains': ['youtube.com'], - 'browser_packages': [], - }); - -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 [], - 'failure': null, - }); diff --git a/focus_owner/test/status_page_test.dart b/focus_owner/test/status_page_test.dart deleted file mode 100644 index 599828aa..00000000 --- a/focus_owner/test/status_page_test.dart +++ /dev/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 calls}) _fakeChannel( - Map responses, -) { - const channel = MethodChannel('test/device_policy'); - final calls = []; - // 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 _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 _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': [], - 'never_disable_prefixes': [], - 'workout_unblock_domains': ['youtube.com'], - 'browser_packages': [], - }); - -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. diff --git a/focus_owner/test/widget_test.dart b/focus_owner/test/widget_test.dart deleted file mode 100644 index 1d1929e6..00000000 --- a/focus_owner/test/widget_test.dart +++ /dev/null @@ -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 calls}) _fakeChannel( - Map responses, -) { - const channel = MethodChannel('test/device_policy'); - final calls = []; - // 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 _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); - }); - }); -} diff --git a/focus_owner/test/workout_signal_test.dart b/focus_owner/test/workout_signal_test.dart deleted file mode 100644 index 06384e7b..00000000 --- a/focus_owner/test/workout_signal_test.dart +++ /dev/null @@ -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 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 _alwaysTrue() async => true; -Future _alwaysThrows() async => throw StateError('probe failed'); -Future _neverCompletes() => Completer().future; diff --git a/kcd2_dice_solver/.gitignore b/kcd2_dice_solver/.gitignore deleted file mode 100644 index 4e8cce15..00000000 --- a/kcd2_dice_solver/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -dist -dist.staging -.deploy.lock -coverage -*.tsbuildinfo diff --git a/kcd2_dice_solver/README.md b/kcd2_dice_solver/README.md deleted file mode 100644 index c5d3bdd1..00000000 --- a/kcd2_dice_solver/README.md +++ /dev/null @@ -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 ** — 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. diff --git a/kcd2_dice_solver/deploy_build.sh b/kcd2_dice_solver/deploy_build.sh deleted file mode 100755 index a2db61cd..00000000 --- a/kcd2_dice_solver/deploy_build.sh +++ /dev/null @@ -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 "$@" diff --git a/kcd2_dice_solver/eslint.config.js b/kcd2_dice_solver/eslint.config.js deleted file mode 100644 index 468b001d..00000000 --- a/kcd2_dice_solver/eslint.config.js +++ /dev/null @@ -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, - }, -); diff --git a/kcd2_dice_solver/index.html b/kcd2_dice_solver/index.html deleted file mode 100644 index 8c6f6680..00000000 --- a/kcd2_dice_solver/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - KCD2 Dice Solver - - -
- - - diff --git a/kcd2_dice_solver/package.json b/kcd2_dice_solver/package.json deleted file mode 100644 index 6fe16994..00000000 --- a/kcd2_dice_solver/package.json +++ /dev/null @@ -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" - } -} diff --git a/kcd2_dice_solver/pnpm-lock.yaml b/kcd2_dice_solver/pnpm-lock.yaml deleted file mode 100644 index 2dbdb8ac..00000000 --- a/kcd2_dice_solver/pnpm-lock.yaml +++ /dev/null @@ -1,2755 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - react: - specifier: ^19.1.0 - version: 19.2.7 - react-dom: - specifier: ^19.1.0 - version: 19.2.7(react@19.2.7) - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.7.0) - '@testing-library/jest-dom': - specifier: ^6.9.1 - version: 6.9.1 - '@testing-library/react': - specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@testing-library/user-event': - specifier: ^14.6.1 - version: 14.6.1(@testing-library/dom@10.4.1) - '@types/react': - specifier: ^19.1.0 - version: 19.2.17 - '@types/react-dom': - specifier: ^19.1.0 - version: 19.2.3(@types/react@19.2.17) - '@vitejs/plugin-react': - specifier: ^4.5.2 - version: 4.7.0(vite@6.4.3) - '@vitest/coverage-v8': - specifier: ^4.1.9 - version: 4.1.10(vitest@4.1.10) - eslint: - specifier: ^10.6.0 - version: 10.7.0 - eslint-plugin-react-hooks: - specifier: ^7.1.1 - version: 7.1.1(eslint@10.7.0) - jsdom: - specifier: ^29.1.1 - version: 29.1.1 - typescript: - specifier: ~5.8.3 - version: 5.8.3 - typescript-eslint: - specifier: ^8.62.0 - version: 8.64.0(eslint@10.7.0)(typescript@5.8.3) - vite: - specifier: ^6.3.5 - version: 6.4.3 - vitest: - specifier: ^4.1.9 - version: 4.1.10(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@6.4.3) - -packages: - - '@adobe/css-tools@4.5.0': - resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - - '@asamuzakjp/css-color@5.1.11': - resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/dom-selector@7.1.1': - resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/generational-cache@1.0.1': - resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - '@asamuzakjp/nwsapi@2.3.9': - resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} - - '@bramus/specificity@2.4.2': - resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} - hasBin: true - - '@csstools/color-helpers@6.1.0': - resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} - engines: {node: '>=20.19.0'} - - '@csstools/css-calc@3.2.1': - resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 - - '@csstools/css-color-parser@4.1.9': - resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 - - '@csstools/css-parser-algorithms@4.0.0': - resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-tokenizer': ^4.0.0 - - '@csstools/css-syntax-patches-for-csstree@1.1.6': - resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} - peerDependencies: - css-tree: ^3.2.1 - peerDependenciesMeta: - css-tree: - optional: true - - '@csstools/css-tokenizer@4.0.0': - resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} - engines: {node: '>=20.19.0'} - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.23.5': - resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.2.1': - resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/object-schema@3.0.5': - resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.7.2': - resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@exodus/bytes@1.15.1': - resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - '@noble/hashes': ^1.8.0 || ^2.0.0 - peerDependenciesMeta: - '@noble/hashes': - optional: true - - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - - '@rollup/rollup-android-arm-eabi@4.62.2': - resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.62.2': - resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.62.2': - resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.62.2': - resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.62.2': - resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.62.2': - resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.62.2': - resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-loong64-musl@4.62.2': - resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.62.2': - resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.62.2': - resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openbsd-x64@4.62.2': - resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.62.2': - resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.62.2': - resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.62.2': - resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} - cpu: [x64] - os: [win32] - - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - - '@testing-library/react@16.3.2': - resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} - engines: {node: '>=12', npm: '>=6'} - peerDependencies: - '@testing-library/dom': '>=7.21.4' - - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - - '@typescript-eslint/eslint-plugin@8.64.0': - resolution: {integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.64.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.64.0': - resolution: {integrity: sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.64.0': - resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.64.0': - resolution: {integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.64.0': - resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.64.0': - resolution: {integrity: sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.64.0': - resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.64.0': - resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.64.0': - resolution: {integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.64.0': - resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - '@vitest/coverage-v8@4.1.10': - resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} - peerDependencies: - '@vitest/browser': 4.1.10 - vitest: 4.1.10 - peerDependenciesMeta: - '@vitest/browser': - optional: true - - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - ast-v8-to-istanbul@1.0.5: - resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.10.43: - resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - bidi-js@1.0.3: - resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} - - browserslist@4.28.6: - resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} - - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - css-tree@3.2.1: - resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - data-urls@7.0.0: - resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - - electron-to-chromium@1.5.393: - resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} - - entities@8.0.0: - resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} - engines: {node: '>=20.19.0'} - - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-plugin-react-hooks@7.1.1: - resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - - eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.7.0: - resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - expect-type@1.4.0: - resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} - engines: {node: '>=12.0.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - - html-encoding-sniffer@6.0.0: - resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.6: - resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} - engines: {node: '>= 4'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsdom@29.1.1: - resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} - peerDependencies: - canvas: ^3.0.0 - peerDependenciesMeta: - canvas: - optional: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lru-cache@11.5.2: - resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} - engines: {node: 20 || >=22} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - mdn-data@2.27.1: - resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} - engines: {node: '>=18'} - - obug@2.1.4: - resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} - engines: {node: '>=12.20.0'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parse5@8.0.1: - resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - - postcss@8.5.20: - resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} - peerDependencies: - react: ^19.2.7 - - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} - engines: {node: '>=0.10.0'} - - redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - rollup@4.62.2: - resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.2.0: - resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} - engines: {node: '>=18'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - - tldts-core@7.4.9: - resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} - - tldts@7.4.9: - resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} - hasBin: true - - tough-cookie@6.0.2: - resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} - engines: {node: '>=16'} - - tr46@6.0.0: - resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} - engines: {node: '>=20'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.64.0: - resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} - engines: {node: '>=14.17'} - hasBin: true - - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} - engines: {node: '>=20.18.1'} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - vite@6.4.3: - resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - webidl-conversions@8.0.1: - resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} - engines: {node: '>=20'} - - whatwg-mimetype@5.0.0: - resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} - engines: {node: '>=20'} - - whatwg-url@16.0.1: - resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - -snapshots: - - '@adobe/css-tools@4.5.0': {} - - '@asamuzakjp/css-color@5.1.11': - dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - - '@asamuzakjp/dom-selector@7.1.1': - dependencies: - '@asamuzakjp/generational-cache': 1.0.1 - '@asamuzakjp/nwsapi': 2.3.9 - bidi-js: 1.0.3 - css-tree: 3.2.1 - is-potential-custom-element-name: 1.0.1 - - '@asamuzakjp/generational-cache@1.0.1': {} - - '@asamuzakjp/nwsapi@2.3.9': {} - - '@babel/code-frame@7.29.7': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.7': {} - - '@babel/core@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.7': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.29.7': - dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.6 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.29.7': {} - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.29.7': {} - - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/helper-validator-option@7.29.7': {} - - '@babel/helpers@7.29.7': - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/runtime@7.29.7': {} - - '@babel/template@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@bcoe/v8-coverage@1.0.2': {} - - '@bramus/specificity@2.4.2': - dependencies: - css-tree: 3.2.1 - - '@csstools/color-helpers@6.1.0': {} - - '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - - '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/color-helpers': 6.1.0 - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - - '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/css-tokenizer': 4.0.0 - - '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 - - '@csstools/css-tokenizer@4.0.0': {} - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0)': - dependencies: - eslint: 10.7.0 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.23.5': - dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.6.0': - dependencies: - '@eslint/core': 1.2.1 - - '@eslint/core@1.2.1': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/js@10.0.1(eslint@10.7.0)': - optionalDependencies: - eslint: 10.7.0 - - '@eslint/object-schema@3.0.5': {} - - '@eslint/plugin-kit@0.7.2': - dependencies: - '@eslint/core': 1.2.1 - levn: 0.4.1 - - '@exodus/bytes@1.15.1': {} - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@rolldown/pluginutils@1.0.0-beta.27': {} - - '@rollup/rollup-android-arm-eabi@4.62.2': - optional: true - - '@rollup/rollup-android-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-x64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.62.2': - optional: true - - '@rollup/rollup-openbsd-x64@4.62.2': - optional: true - - '@rollup/rollup-openharmony-arm64@4.62.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.62.2': - optional: true - - '@standard-schema/spec@1.1.0': {} - - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/runtime': 7.29.7 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/jest-dom@6.9.1': - dependencies: - '@adobe/css-tools': 4.5.0 - aria-query: 5.3.2 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - picocolors: 1.1.1 - redent: 3.0.0 - - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': - dependencies: - '@testing-library/dom': 10.4.1 - - '@types/aria-query@5.0.4': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/esrecurse@4.3.1': {} - - '@types/estree@1.0.9': {} - - '@types/json-schema@7.0.15': {} - - '@types/react-dom@19.2.3(@types/react@19.2.17)': - dependencies: - '@types/react': 19.2.17 - - '@types/react@19.2.17': - dependencies: - csstype: 3.2.3 - - '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@10.7.0)(typescript@5.8.3))(eslint@10.7.0)(typescript@5.8.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.64.0(eslint@10.7.0)(typescript@5.8.3) - '@typescript-eslint/scope-manager': 8.64.0 - '@typescript-eslint/type-utils': 8.64.0(eslint@10.7.0)(typescript@5.8.3) - '@typescript-eslint/utils': 8.64.0(eslint@10.7.0)(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.64.0 - eslint: 10.7.0 - ignore: 7.0.6 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.64.0(eslint@10.7.0)(typescript@5.8.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.64.0 - '@typescript-eslint/types': 8.64.0 - '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.64.0 - debug: 4.4.3 - eslint: 10.7.0 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.64.0(typescript@5.8.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.8.3) - '@typescript-eslint/types': 8.64.0 - debug: 4.4.3 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.64.0': - dependencies: - '@typescript-eslint/types': 8.64.0 - '@typescript-eslint/visitor-keys': 8.64.0 - - '@typescript-eslint/tsconfig-utils@8.64.0(typescript@5.8.3)': - dependencies: - typescript: 5.8.3 - - '@typescript-eslint/type-utils@8.64.0(eslint@10.7.0)(typescript@5.8.3)': - dependencies: - '@typescript-eslint/types': 8.64.0 - '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.64.0(eslint@10.7.0)(typescript@5.8.3) - debug: 4.4.3 - eslint: 10.7.0 - ts-api-utils: 2.5.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.64.0': {} - - '@typescript-eslint/typescript-estree@8.64.0(typescript@5.8.3)': - dependencies: - '@typescript-eslint/project-service': 8.64.0(typescript@5.8.3) - '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.8.3) - '@typescript-eslint/types': 8.64.0 - '@typescript-eslint/visitor-keys': 8.64.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.64.0(eslint@10.7.0)(typescript@5.8.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) - '@typescript-eslint/scope-manager': 8.64.0 - '@typescript-eslint/types': 8.64.0 - '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.8.3) - eslint: 10.7.0 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.64.0': - dependencies: - '@typescript-eslint/types': 8.64.0 - eslint-visitor-keys: 5.0.1 - - '@vitejs/plugin-react@4.7.0(vite@6.4.3)': - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.3 - transitivePeerDependencies: - - supports-color - - '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.10 - ast-v8-to-istanbul: 1.0.5 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.4 - std-env: 4.2.0 - tinyrainbow: 3.1.0 - vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@6.4.3) - - '@vitest/expect@4.1.10': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.10(vite@6.4.3)': - dependencies: - '@vitest/spy': 4.1.10 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 6.4.3 - - '@vitest/pretty-format@4.1.10': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.10': - dependencies: - '@vitest/utils': 4.1.10 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.10': - dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.10': {} - - '@vitest/utils@4.1.10': - dependencies: - '@vitest/pretty-format': 4.1.10 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - - acorn-jsx@5.3.2(acorn@8.17.0): - dependencies: - acorn: 8.17.0 - - acorn@8.17.0: {} - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-regex@5.0.1: {} - - ansi-styles@5.2.0: {} - - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - - aria-query@5.3.2: {} - - assertion-error@2.0.1: {} - - ast-v8-to-istanbul@1.0.5: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.10.43: {} - - bidi-js@1.0.3: - dependencies: - require-from-string: 2.0.2 - - brace-expansion@5.0.7: - dependencies: - balanced-match: 4.0.4 - - browserslist@4.28.6: - dependencies: - baseline-browser-mapping: 2.10.43 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.393 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.6) - - caniuse-lite@1.0.30001806: {} - - chai@6.2.2: {} - - convert-source-map@2.0.0: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - css-tree@3.2.1: - dependencies: - mdn-data: 2.27.1 - source-map-js: 1.2.1 - - css.escape@1.5.1: {} - - csstype@3.2.3: {} - - data-urls@7.0.0: - dependencies: - whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 - transitivePeerDependencies: - - '@noble/hashes' - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js@10.6.0: {} - - deep-is@0.1.4: {} - - dequal@2.0.3: {} - - dom-accessibility-api@0.5.16: {} - - dom-accessibility-api@0.6.3: {} - - electron-to-chromium@1.5.393: {} - - entities@8.0.0: {} - - es-module-lexer@2.3.1: {} - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-plugin-react-hooks@7.1.1(eslint@10.7.0): - dependencies: - '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 - eslint: 10.7.0 - hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) - transitivePeerDependencies: - - supports-color - - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.9 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.7.0: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.2 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@11.2.0: - dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - esutils@2.0.3: {} - - expect-type@1.4.0: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fdir@6.5.0(picomatch@4.0.5): - optionalDependencies: - picomatch: 4.0.5 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - fsevents@2.3.3: - optional: true - - gensync@1.0.0-beta.2: {} - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - has-flag@4.0.0: {} - - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - - html-encoding-sniffer@6.0.0: - dependencies: - '@exodus/bytes': 1.15.1 - transitivePeerDependencies: - - '@noble/hashes' - - html-escaper@2.0.2: {} - - ignore@5.3.2: {} - - ignore@7.0.6: {} - - imurmurhash@0.1.4: {} - - indent-string@4.0.0: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-potential-custom-element-name@1.0.1: {} - - isexe@2.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - js-tokens@10.0.0: {} - - js-tokens@4.0.0: {} - - jsdom@29.1.1: - dependencies: - '@asamuzakjp/css-color': 5.1.11 - '@asamuzakjp/dom-selector': 7.1.1 - '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) - '@exodus/bytes': 1.15.1 - css-tree: 3.2.1 - data-urls: 7.0.0 - decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.2 - parse5: 8.0.1 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 6.0.2 - undici: 7.28.0 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 8.0.1 - whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - '@noble/hashes' - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lru-cache@11.5.2: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lz-string@1.5.0: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.5.3: - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.8.5 - - mdn-data@2.27.1: {} - - min-indent@1.0.1: {} - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.7 - - ms@2.1.3: {} - - nanoid@3.3.16: {} - - natural-compare@1.4.0: {} - - node-releases@2.0.51: {} - - obug@2.1.4: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parse5@8.0.1: - dependencies: - entities: 8.0.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.5: {} - - postcss@8.5.20: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - - punycode@2.3.1: {} - - react-dom@19.2.7(react@19.2.7): - dependencies: - react: 19.2.7 - scheduler: 0.27.0 - - react-is@17.0.2: {} - - react-refresh@0.17.0: {} - - react@19.2.7: {} - - redent@3.0.0: - dependencies: - indent-string: 4.0.0 - strip-indent: 3.0.0 - - require-from-string@2.0.2: {} - - rollup@4.62.2: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.2 - '@rollup/rollup-android-arm64': 4.62.2 - '@rollup/rollup-darwin-arm64': 4.62.2 - '@rollup/rollup-darwin-x64': 4.62.2 - '@rollup/rollup-freebsd-arm64': 4.62.2 - '@rollup/rollup-freebsd-x64': 4.62.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 - '@rollup/rollup-linux-arm-musleabihf': 4.62.2 - '@rollup/rollup-linux-arm64-gnu': 4.62.2 - '@rollup/rollup-linux-arm64-musl': 4.62.2 - '@rollup/rollup-linux-loong64-gnu': 4.62.2 - '@rollup/rollup-linux-loong64-musl': 4.62.2 - '@rollup/rollup-linux-ppc64-gnu': 4.62.2 - '@rollup/rollup-linux-ppc64-musl': 4.62.2 - '@rollup/rollup-linux-riscv64-gnu': 4.62.2 - '@rollup/rollup-linux-riscv64-musl': 4.62.2 - '@rollup/rollup-linux-s390x-gnu': 4.62.2 - '@rollup/rollup-linux-x64-gnu': 4.62.2 - '@rollup/rollup-linux-x64-musl': 4.62.2 - '@rollup/rollup-openbsd-x64': 4.62.2 - '@rollup/rollup-openharmony-arm64': 4.62.2 - '@rollup/rollup-win32-arm64-msvc': 4.62.2 - '@rollup/rollup-win32-ia32-msvc': 4.62.2 - '@rollup/rollup-win32-x64-gnu': 4.62.2 - '@rollup/rollup-win32-x64-msvc': 4.62.2 - fsevents: 2.3.3 - - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - semver@7.8.5: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - siginfo@2.0.0: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - std-env@4.2.0: {} - - strip-indent@3.0.0: - dependencies: - min-indent: 1.0.1 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - symbol-tree@3.2.4: {} - - tinybench@2.9.0: {} - - tinyexec@1.2.4: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - - tinyrainbow@3.1.0: {} - - tldts-core@7.4.9: {} - - tldts@7.4.9: - dependencies: - tldts-core: 7.4.9 - - tough-cookie@6.0.2: - dependencies: - tldts: 7.4.9 - - tr46@6.0.0: - dependencies: - punycode: 2.3.1 - - ts-api-utils@2.5.0(typescript@5.8.3): - dependencies: - typescript: 5.8.3 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.64.0(eslint@10.7.0)(typescript@5.8.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/parser@8.64.0(eslint@10.7.0)(typescript@5.8.3))(eslint@10.7.0)(typescript@5.8.3) - '@typescript-eslint/parser': 8.64.0(eslint@10.7.0)(typescript@5.8.3) - '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.64.0(eslint@10.7.0)(typescript@5.8.3) - eslint: 10.7.0 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - typescript@5.8.3: {} - - undici@7.28.0: {} - - update-browserslist-db@1.2.3(browserslist@4.28.6): - dependencies: - browserslist: 4.28.6 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - vite@6.4.3: - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - postcss: 8.5.20 - rollup: 4.62.2 - tinyglobby: 0.2.17 - optionalDependencies: - fsevents: 2.3.3 - - vitest@4.1.10(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@6.4.3): - dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@6.4.3) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 - expect-type: 1.4.0 - magic-string: 0.30.21 - obug: 2.1.4 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.2.0 - tinybench: 2.9.0 - tinyexec: 1.2.4 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 6.4.3 - why-is-node-running: 2.3.0 - optionalDependencies: - '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - jsdom: 29.1.1 - transitivePeerDependencies: - - msw - - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - - webidl-conversions@8.0.1: {} - - whatwg-mimetype@5.0.0: {} - - whatwg-url@16.0.1: - dependencies: - '@exodus/bytes': 1.15.1 - tr46: 6.0.0 - webidl-conversions: 8.0.1 - transitivePeerDependencies: - - '@noble/hashes' - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - word-wrap@1.2.5: {} - - xml-name-validator@5.0.0: {} - - xmlchars@2.2.0: {} - - yallist@3.1.1: {} - - yocto-queue@0.1.0: {} - - zod-validation-error@4.0.2(zod@4.4.3): - dependencies: - zod: 4.4.3 - - zod@4.4.3: {} diff --git a/kcd2_dice_solver/run.sh b/kcd2_dice_solver/run.sh deleted file mode 100755 index 6e019f4e..00000000 --- a/kcd2_dice_solver/run.sh +++ /dev/null @@ -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 "$@" diff --git a/kcd2_dice_solver/src/App.tsx b/kcd2_dice_solver/src/App.tsx deleted file mode 100644 index 87c54e23..00000000 --- a/kcd2_dice_solver/src/App.tsx +++ /dev/null @@ -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; - /** 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( - () => - 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 ( -
- {/* - * 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. - */} -
-
- -
- -
-
-
-

KCD2 Dice Solver

-

- Pick what you own; get the best six dice and a badge for each tier. -

-
- -
- { - setQuery(event.target.value); - }} - /> - - {total} {total === 1 ? "die" : "dice"} - - -
- - {!persist && ( -

- {linkApplied - ? "Loaded from a shared link — not saved on this device yet." - : "Some saved entries could not be read — not saved over yet."}{" "} - {" "} - {/* 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 && ( - - )} -

- )} - - {dropped.length > 0 && ( -

- {`Ignored: ${dropped.join(" ")}`} -

- )} -
- -
- - - {/* Collapsed by default: badges matter far less than the dice, and - open they cost more vertical space than everything else here. */} -
- Badges - -
- - - -
-

What these numbers are

-
    -
  • - Expected score and bust chance are exact, computed over - every possible outcome of the six dice — not sampled. -
  • -
  • - Points per turn is a simulation of a push-your-luck turn - that banks at 300 points. Change how you play and it changes. -
  • -
  • - Badge values are estimates for ranking badges against each - other, not a prediction of a scoreline. -
  • -
  • - Balatro’s die has one joker face, not six, and the game - publishes no face probabilities 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. -
  • -
  • - The three “Advantage” formations and the Headstart leads have{" "} - no published point values; the figures used are marked - UNVERIFIED in the source. -
  • -
-
-
-
-
-
- ); -} diff --git a/kcd2_dice_solver/src/AppLinks.test.tsx b/kcd2_dice_solver/src/AppLinks.test.tsx deleted file mode 100644 index 7775b277..00000000 --- a/kcd2_dice_solver/src/AppLinks.test.tsx +++ /dev/null @@ -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) => 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); - } - }, - 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 { - const store = new Map(); - 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): 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( - 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( - 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( - 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( - 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( - , - ); - 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(); - }); -}); diff --git a/kcd2_dice_solver/src/AppRecovery.test.tsx b/kcd2_dice_solver/src/AppRecovery.test.tsx deleted file mode 100644 index 35acf1de..00000000 --- a/kcd2_dice_solver/src/AppRecovery.test.tsx +++ /dev/null @@ -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) => 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); - } - }, - 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 { - const store = new Map(); - 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): 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( - 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( - , - ); - 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( - , - ); - 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(); - // 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(); - expect(screen.getByText(/Unknown die “future_patch_die”/)).toBeInTheDocument(); - - await userEvent.click(screen.getByRole("button", { name: "Keep" })); - expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 }); - }); -}); diff --git a/kcd2_dice_solver/src/AppShell.test.tsx b/kcd2_dice_solver/src/AppShell.test.tsx deleted file mode 100644 index 127b2540..00000000 --- a/kcd2_dice_solver/src/AppShell.test.tsx +++ /dev/null @@ -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) => 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); - } - }, - 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 { - const store = new Map(); - 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): 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( - 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(); - 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(); - }); -}); diff --git a/kcd2_dice_solver/src/AppStorage.test.tsx b/kcd2_dice_solver/src/AppStorage.test.tsx deleted file mode 100644 index b7fc6f47..00000000 --- a/kcd2_dice_solver/src/AppStorage.test.tsx +++ /dev/null @@ -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) => 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); - } - }, - 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 { - const store = new Map(); - 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): 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( - 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(); - 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(); - 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(); - 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) => 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( - , - ); - await waitFor(() => { - expect(screen.getByText("solver exploded")).toBeInTheDocument(); - }); - }); -}); diff --git a/kcd2_dice_solver/src/components/BadgePicker.tsx b/kcd2_dice_solver/src/components/BadgePicker.tsx deleted file mode 100644 index 5ca08ada..00000000 --- a/kcd2_dice_solver/src/components/BadgePicker.tsx +++ /dev/null @@ -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; - 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 ( -
- {TIERS.map((tier) => { - const inTier = BADGES.filter( - (badge) => badge.tier === tier && matched.has(badge.id), - ); - if (inTier.length === 0) { - return null; - } - return ( -
-

{tier}

-
    - {inTier.map((badge) => ( -
  • - -
  • - ))} -
-
- ); - })} -
- ); -} diff --git a/kcd2_dice_solver/src/components/DiceList.tsx b/kcd2_dice_solver/src/components/DiceList.tsx deleted file mode 100644 index a4762c76..00000000 --- a/kcd2_dice_solver/src/components/DiceList.tsx +++ /dev/null @@ -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>; - 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

No die matches “{query}”.

; - } - - return ( - // `role="list"` is not redundant: the list-style:none needed for the grid - // layout makes Safari/VoiceOver drop list semantics entirely. -
    - {matches.map(({ item, indices }) => ( - { - onChange(item.id, update); - }} - /> - ))} -
- ); -} diff --git a/kcd2_dice_solver/src/components/DieRow.tsx b/kcd2_dice_solver/src/components/DieRow.tsx deleted file mode 100644 index c548b68c..00000000 --- a/kcd2_dice_solver/src/components/DieRow.tsx +++ /dev/null @@ -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. - {run.text} - ) : ( - {run.text} - ), - )} - - ); -} - -/** - * 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 ( -
  • 0 ? "die-row owned" : "die-row"}> - - -
  • - ); -} diff --git a/kcd2_dice_solver/src/components/InventoryIo.tsx b/kcd2_dice_solver/src/components/InventoryIo.tsx deleted file mode 100644 index 0d7707d5..00000000 --- a/kcd2_dice_solver/src/components/InventoryIo.tsx +++ /dev/null @@ -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(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): 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 ( -
    - Import / export - -
    - - - -
    - - - -