diff --git a/docs/superpowers/contracts/focus-owner-dart-split-2026-08-16.json b/docs/superpowers/contracts/focus-owner-dart-split-2026-08-16.json new file mode 100644 index 00000000..6d9ad050 --- /dev/null +++ b/docs/superpowers/contracts/focus-owner-dart-split-2026-08-16.json @@ -0,0 +1,18 @@ +{ + "title": "Split the focus_owner Dart files under the 250-line cap", + "objective": "Bring main.dart (538), policy.dart (272), enforcement_status_card.dart (268), widget_test.dart (351) and policy_test.dart (253) under the cap, using Dart's part/part of where private declarations cross the seam.", + "acceptance_criteria": [ + "flutter test reports exactly 94 passing tests, the same as before the split.", + "flutter analyze reports no issues at all: no errors, no warnings, no infos.", + "Library-private declarations that cross a seam use part/part of rather than import. Dart privates are library-scoped, so a plain import cannot see them and the analyzer rejects it outright.", + "No test is deleted, skipped or weakened; whole groups move verbatim.", + "A split that separates a group from the testWidgets() calls that implicitly initialised the binding must add TestWidgetsFlutterBinding.ensureInitialized() explicitly to BOTH halves.", + "focus_owner/analysis_options.yaml carries a pre-existing uncommitted change made by someone else and must NOT be staged." + ], + "out_of_scope": [ + "The Kotlin sources under android/, which are a separate commit.", + "Changing what any test asserts.", + "Committing the unrelated analysis_options.yaml modification." + ], + "verifier": "flutter test (expects 94 passed), flutter analyze (expects 'No issues found!'), and check_file_length.sh over focus_owner." +} diff --git a/docs/superpowers/evidence/focus-owner-dart-split-2026-08-16.json b/docs/superpowers/evidence/focus-owner-dart-split-2026-08-16.json new file mode 100644 index 00000000..58dcf3a1 --- /dev/null +++ b/docs/superpowers/evidence/focus-owner-dart-split-2026-08-16.json @@ -0,0 +1,46 @@ +{ + "intent": "Bring the focus_owner Dart files under the cap, learning that Dart's library-scoped privates force part/part of rather than a plain import.", + "scope": [ + "lib/main.dart (538 -> 130) + status_body.dart, status_page_state.dart", + "lib/policy.dart (272 -> 85) + focus_policy.dart", + "lib/enforcement_status_card.dart (268 -> 145) + status_card_parts.dart", + "test/widget_test.dart (351 -> 128) + status_page_test.dart", + "test/policy_test.dart (253 -> 110) + policy_decisions_test.dart", + "Non-goal: no assertion changed; the unrelated analysis_options.yaml edit left unstaged" + ], + "changes": [ + "Every lib split uses part/part of, not import. The first attempt used import plus export and the analyzer produced 30 errors like \"The name '_Card' isn't a class\": Dart privates are visible to the whole LIBRARY, and a separate file is a separate library unless it is a part. part/part of keeps them in one library and the analyzer went straight to zero issues.", + "The test splits separated groups from the testWidgets() calls that had been initialising the binding implicitly, so four DevicePolicy tests failed with 'Binding has not yet been initialized'. Both halves now call TestWidgetsFlutterBinding.ensureInitialized() explicitly, with a comment saying why.", + "An earlier automated import-pruning pass broke three tests, so it was reverted wholesale and the seven unused imports and two orphaned helpers were removed from the analyzer's exact list instead." + ], + "verification": [ + { + "command": "flutter test", + "result": "fail three times, then pass", + "evidence": "94 passed, matching the baseline measured before any edit. The three failures were all real: the private-scope errors, the missing binding initialisation, and the over-eager automated pruning." + }, + { + "command": "flutter analyze", + "result": "pass", + "evidence": "'No issues found!' - no errors, warnings or infos, and no analyzer suppression added." + }, + { + "command": "git diff --cached focus_owner/analysis_options.yaml, then git restore --staged", + "result": "pass", + "evidence": "The file had an uncommitted analyzer-exclude block from an earlier session's work, last committed at f636d2b. It was unstaged and left in the working tree rather than swept into this commit." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all | grep focus_owner", + "result": "partial", + "evidence": "Both test files and three of the four lib files are now under the cap. status_page_state.dart is 307 and status_page_test.dart is 309 - still over, and left for a follow-up rather than forced." + } + ], + "risks": [ + "part/part of makes the split files members of one library, so they share a namespace: two parts cannot declare the same private name. That is invisible until it collides.", + "status_page_state.dart and status_page_test.dart remain over the cap. The Kotlin sources under android/ are untouched and are the bigger remaining group." + ], + "rollback": [ + "git revert the commit; the five originals return and the six new files disappear.", + "After rollback, re-run flutter test and expect 94 passed." + ] +} diff --git a/focus_owner/lib/enforcement_status_card.dart b/focus_owner/lib/enforcement_status_card.dart index 8fe13877..342b99bc 100644 --- a/focus_owner/lib/enforcement_status_card.dart +++ b/focus_owner/lib/enforcement_status_card.dart @@ -3,6 +3,8 @@ 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: @@ -144,125 +146,3 @@ class EnforcementStatusCard extends StatelessWidget { return '${delta.inDays} d ago'; } } - -/// 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/focus_policy.dart b/focus_owner/lib/focus_policy.dart new file mode 100644 index 00000000..609f055e --- /dev/null +++ b/focus_owner/lib/focus_policy.dart @@ -0,0 +1,197 @@ +/// 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/main.dart b/focus_owner/lib/main.dart index 50b884bc..60278ca8 100644 --- a/focus_owner/lib/main.dart +++ b/focus_owner/lib/main.dart @@ -10,6 +10,10 @@ 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_body.dart'; + void main() => runApp(const FocusOwnerApp()); // The palette used to be duplicated here as `kField`…`kDanger`, byte for @@ -62,477 +66,3 @@ class StatusPage extends StatefulWidget { @override State createState() => _StatusPageState(); } - -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: (context) => 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'), - ), - ], - ), - ); - 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 confirmed = await showDialog( - context: context, - builder: (context) => 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( - _status?.hasAccounts ?? true - ? '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'), - ), - ], - ), - ); - 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 status = _status; - final error = _error; - final Widget body; - if (error != null) { - body = const _Message(text: 'Could not read state', color: kDanger); - } else if (status != null) { - body = _StatusBody( - status: status, - focusPolicy: _focusPolicy, - policyError: _policyError, - busy: _busy, - onRelease: _release, - onRunNow: _runNow, - onSetHome: _setHome, - onLockVpn: _lockVpn, - hasHome: _hasHome, - log: _log, - onOpenLog: _openLog, - ); - } else { - body = const Center(child: CircularProgressIndicator()); - } - - 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, - ), - ); - } -} - -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/policy.dart b/focus_owner/lib/policy.dart index 8de6cdc6..45bbc0d2 100644 --- a/focus_owner/lib/policy.dart +++ b/focus_owner/lib/policy.dart @@ -2,6 +2,8 @@ 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 @@ -81,192 +83,3 @@ class HomeLocation { /// as (0, 0), which would put "home" in the Atlantic. bool get hasCoordinates => latitude != null && longitude != null; } - -/// 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/status_body.dart b/focus_owner/lib/status_body.dart new file mode 100644 index 00000000..a1ba9de3 --- /dev/null +++ b/focus_owner/lib/status_body.dart @@ -0,0 +1,179 @@ +/// 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 new file mode 100644 index 00000000..70a7fdb3 --- /dev/null +++ b/focus_owner/lib/status_card_parts.dart @@ -0,0 +1,128 @@ +/// 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_state.dart b/focus_owner/lib/status_page_state.dart new file mode 100644 index 00000000..ac1f7a80 --- /dev/null +++ b/focus_owner/lib/status_page_state.dart @@ -0,0 +1,307 @@ +/// 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: (context) => 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'), + ), + ], + ), + ); + 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 confirmed = await showDialog( + context: context, + builder: (context) => 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( + _status?.hasAccounts ?? true + ? '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'), + ), + ], + ), + ); + 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 status = _status; + final error = _error; + final Widget body; + if (error != null) { + body = const _Message(text: 'Could not read state', color: kDanger); + } else if (status != null) { + body = _StatusBody( + status: status, + focusPolicy: _focusPolicy, + policyError: _policyError, + busy: _busy, + onRelease: _release, + onRunNow: _runNow, + onSetHome: _setHome, + onLockVpn: _lockVpn, + hasHome: _hasHome, + log: _log, + onOpenLog: _openLog, + ); + } else { + body = const Center(child: CircularProgressIndicator()); + } + + 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/test/policy_decisions_test.dart b/focus_owner/test/policy_decisions_test.dart new file mode 100644 index 00000000..87c7e72b --- /dev/null +++ b/focus_owner/test/policy_decisions_test.dart @@ -0,0 +1,184 @@ +// 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 index eaf86727..f5fa2111 100644 --- a/focus_owner/test/policy_test.dart +++ b/focus_owner/test/policy_test.dart @@ -1,7 +1,4 @@ -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:focus_owner/policy.dart'; @@ -101,153 +98,4 @@ void main() { expect(day.contains(17 * 60), isFalse); }); }); - - 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/status_page_test.dart b/focus_owner/test/status_page_test.dart new file mode 100644 index 00000000..b137bbcb --- /dev/null +++ b/focus_owner/test/status_page_test.dart @@ -0,0 +1,309 @@ +// 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', () { + 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, + ); + }); + + 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/widget_test.dart b/focus_owner/test/widget_test.dart index 87c502e4..1d1929e6 100644 --- a/focus_owner/test/widget_test.dart +++ b/focus_owner/test/widget_test.dart @@ -1,11 +1,7 @@ -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( @@ -36,25 +32,13 @@ Map _statusMap({bool isDeviceOwner = false}) => { '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() { + // 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)); @@ -101,251 +85,4 @@ void main() { expect(await DevicePolicy(fake.channel).releaseDeviceOwner(), isFalse); }); }); - - 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, - ); - }); - - 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, - });