testsAndMisc/focus_owner/lib/policy.dart
Krzysztof kuhy Rudnicki 82e206116a
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Make the geofence tell the truth, and the phone explain itself
The enforcer failed in both directions: nothing blocked at home, and
everything blocked at the office. The comparison was never inverted --
`metres <= threshold` was always right. Three separate causes:

Only ACCESS_COARSE_LOCATION was declared. Android 12+ fuzzes coarse to a
~1-2 km grid against a 150 m fence, so a fix taken at home routinely landed
outside it, read as AWAY, and restored every app the geofence exists to hide.
FINE is now declared and self-granted by the device owner, along with coarse
and background, re-asserted every pass so a reinstall cannot silently drop it.

There were three states but two behaviours. LOCATION_UNKNOWN applies the
identical sweep to AT_HOME -- correct fail-closed design, indistinguishable
from outside. And nothing ever requested a fix: the pass read whatever another
app happened to cache, so at the office with no navigation running the cache
was empty. Passes now acquire a fix inside a location-typed foreground
service, bounded at 20 s, and treat anything older than 30 min as no fix. That
window is deliberately longer than the 15-minute cadence; a shorter one marks
the newest cache stale on every pass of an idle phone and blocks everything.

None of it was diagnosable. `adb logcat -d -s FocusOwner:V` came back empty
while dumpsys showed 82 alarms had fired, and `run-as` is refused on the
release build device owner requires. So the pass now writes a ring-buffered
JSONL record read back in-app: reason, distance vs the fence, fix
age/provider/accuracy, curfew state, and every hidden app with the rule that
hid it. Coordinates are never recorded -- a distance is enough to debug a
geofence, and this is a home address.

Also fixed along the way:

- Hysteresis keyed off `isEnforcing`, which is true under AWAY too because
  YouTube stays hidden, so the threshold was permanently radius+hysteresis.
  It now keys off whether the previous pass was inside the fence.
- The scheduler's curfew-boundary alarms were documented but never scheduled;
  both callers invoked `scheduleNext()` bare, so 05:00 could land 15 min late.
- Installing over the app cancelled its pending alarms, so `adb install -r`
  silently stopped enforcement until someone opened the app. MY_PACKAGE_REPLACED
  now re-arms it, verified on device without launching the app.
- A wrong-typed log record threw instead of being skipped, and the exception
  escaped far enough to leave the page stuck busy -- which disables the
  release-device-owner button, the one control that must work when everything
  else has gone wrong.
- `lib/enforcement.dart` was a second decision layer with no always-blocked
  concept, cited by KDoc as pinned by a `PolicyParityTest` that never existed.
  It would have made the new screen report YouTube visible while it is hidden.
  Deleted; the UI renders the Kotlin record rather than re-deciding.
- Anchoring home accepted a stale or unknown-precision fix. A wrong home
  offsets the fence everywhere and forever, which is what produced a 766 m
  reading while standing at home.

Policy: Play Store is allowed by day (not during curfew) so infakt can be
installed and updated; the sweep is default-deny for third-party packages, so
anything installed from Play is hidden on the next at-home pass. Tachiyomi's
per-source extension apks are covered by a new prefix allowlist rather than an
exact list that goes stale whenever an extension is installed.

`export.py` stopped subtracting the allowlist from `blockable_system_packages`.
Sweepable is not hideable: that list decides which system packages are
eligible for a decision, and `isAllowed` is what protects them. Removing an
allowed system package from the sweep freezes it in its last state -- it would
have stranded Play hidden permanently the moment it was allowlisted.

Verified on the Pixel 6a: FINE granted POLICY_FIXED, `applied AT_HOME` with a
0 m distance from a +/-11 m GPS fix, YouTube/Chrome/Firefox absent from
`pm list packages -e`, Play Store, infakt, RunnerUp and all five Tachiyomi
packages present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjxL9kdAzZmGqQA5tMNrZ8
2026-08-14 13:43:05 +02:00

273 lines
9.7 KiB
Dart

import 'dart:convert';
import 'package:flutter/services.dart' show AssetBundle, rootBundle;
/// Schema version this build understands.
///
/// The asset is generated by `python3 -m python_pkg.focus_policy`, which is the
/// single source of truth. Refusing an unknown version is deliberate: silently
/// accepting a newer schema would mean enforcing a policy this code has
/// misread, and a misread allowlist is an allowlist that blocks the dialer.
const int kSupportedSchemaVersion = 1;
/// Raised when the bundled policy cannot be trusted.
class PolicyFormatException implements Exception {
const PolicyFormatException(this.message);
final String message;
@override
String toString() => 'PolicyFormatException: $message';
}
/// A nightly window that may wrap midnight (23:00 -> 05:00).
class CurfewWindow {
const CurfewWindow({required this.startMinutes, required this.endMinutes});
/// Parses an `{"start": "23:00", "end": "05:00"}` object.
factory CurfewWindow.fromJson(Map<String, Object?> json) {
return CurfewWindow(
startMinutes: _parseHhMm(json['start'], 'curfew.start'),
endMinutes: _parseHhMm(json['end'], 'curfew.end'),
);
}
/// Minutes since midnight.
final int startMinutes;
final int endMinutes;
/// Whether [minutesSinceMidnight] falls inside the window.
///
/// Wrapping windows are the normal case here, so "inside" means at or after
/// the start *or* before the end, not both.
bool contains(int minutesSinceMidnight) {
if (startMinutes <= endMinutes) {
return minutesSinceMidnight >= startMinutes &&
minutesSinceMidnight < endMinutes;
}
return minutesSinceMidnight >= startMinutes ||
minutesSinceMidnight < endMinutes;
}
}
/// The home anchor. Coordinates are null in a redacted policy.
class HomeLocation {
const HomeLocation({
required this.latitude,
required this.longitude,
required this.radiusM,
required this.hysteresisM,
});
/// Parses the `home` object.
factory HomeLocation.fromJson(Map<String, Object?> json) {
return HomeLocation(
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
radiusM: _requireNum(json['radius_m'], 'home.radius_m'),
hysteresisM: _requireNum(json['hysteresis_m'], 'home.hysteresis_m'),
);
}
final double? latitude;
final double? longitude;
final double radiusM;
final double hysteresisM;
/// Whether this policy carries real coordinates.
///
/// The committed asset is redacted, so the location gate cannot run against
/// it. Enforcement must check this rather than treating a missing coordinate
/// as (0, 0), which would put "home" in the Atlantic.
bool get hasCoordinates => latitude != null && longitude != null;
}
/// The device-independent focus policy, as generated from `config.sh`.
class FocusPolicy {
const FocusPolicy({
required this.home,
required this.allowedPackages,
required this.nightAllowedPackages,
required this.neverDisablePrefixes,
required this.workoutUnblockDomains,
required this.curfew,
required this.launcherPackage,
this.allowedPrefixes = const {},
this.nightAllowedPrefixes = const {},
});
/// Parses a rendered policy document.
factory FocusPolicy.fromJson(Map<String, Object?> json) {
final version = json['schema_version'];
if (version != kSupportedSchemaVersion) {
throw PolicyFormatException(
'unsupported schema_version $version '
'(this build understands $kSupportedSchemaVersion)',
);
}
final home = json['home'];
if (home is! Map<String, Object?>) {
throw const PolicyFormatException('missing "home" object');
}
final curfew = json['curfew'];
return FocusPolicy(
home: HomeLocation.fromJson(home),
allowedPackages: _stringSet(json['allowed_packages'], 'allowed_packages'),
nightAllowedPackages:
_stringSet(json['night_allowed_packages'], 'night_allowed_packages'),
neverDisablePrefixes: _stringSet(
json['never_disable_prefixes'],
'never_disable_prefixes',
),
workoutUnblockDomains: _stringSet(
json['workout_unblock_domains'],
'workout_unblock_domains',
),
curfew: curfew == null
? null
: CurfewWindow.fromJson(curfew as Map<String, Object?>),
launcherPackage: json['launcher_package'] as String?,
// Optional: absent from an asset rendered before prefixes existed, and
// reading a missing key as empty restores exactly the old exact-match
// behaviour rather than failing to parse the whole policy.
allowedPrefixes: _optionalStringSet(
json,
'allowed_prefixes',
),
nightAllowedPrefixes: _optionalStringSet(
json,
'night_allowed_prefixes',
),
);
}
/// Loads the policy bundled with the app.
static Future<FocusPolicy> load({
AssetBundle? bundle,
String assetKey = 'assets/policy.json',
}) async {
final raw = await (bundle ?? rootBundle).loadString(assetKey);
final decoded = jsonDecode(raw);
if (decoded is! Map<String, Object?>) {
throw const PolicyFormatException('policy asset is not a JSON object');
}
return FocusPolicy.fromJson(decoded);
}
final HomeLocation home;
final Set<String> allowedPackages;
final Set<String> nightAllowedPackages;
final Set<String> neverDisablePrefixes;
final Set<String> workoutUnblockDomains;
final CurfewWindow? curfew;
final String? launcherPackage;
/// Prefix-matched day allowlist, for apps shipping as a package family.
///
/// Tachiyomi installs every source as its own apk, so an exact list goes
/// stale the moment a new extension is installed.
final Set<String> allowedPrefixes;
/// Prefixes that survive the curfew. Subset of [allowedPrefixes].
final Set<String> nightAllowedPrefixes;
/// Whether [package] is covered by any entry of [prefixes].
///
/// Matched on whole labels, so `com.android.providers` covers
/// `com.android.providers.telephony` but not `com.android.providersomething`.
/// Shared by every prefix list so the boundary rule cannot drift.
static bool _matchesPrefix(String package, Set<String> prefixes) =>
prefixes.any(
(prefix) => package == prefix || package.startsWith('$prefix.'),
);
/// Whether a package must never be disabled.
bool isProtected(String package) =>
_matchesPrefix(package, neverDisablePrefixes);
/// Whether a package may run under the given conditions.
bool isAllowed(String package, {bool duringCurfew = false}) {
if (isProtected(package)) return true;
// Hiding the launcher leaves no home screen, so it outranks the lists --
// matching FocusPolicy.kt, which has always had this check.
if (package == launcherPackage) return true;
final allowed = duringCurfew ? nightAllowedPackages : allowedPackages;
if (allowed.contains(package)) return true;
final prefixes = duringCurfew ? nightAllowedPrefixes : allowedPrefixes;
return _matchesPrefix(package, prefixes);
}
/// Whether the curfew is in force at [minutesSinceMidnight].
bool isCurfewActive(int minutesSinceMidnight) =>
curfew?.contains(minutesSinceMidnight) ?? false;
/// Packages released while a workout is in progress.
///
/// The rooted system expressed this exception as *domains*, because it
/// enforced through a hosts file. Hiding apps is a coarser instrument: there
/// is no way to unblock youtube.com without unhiding the YouTube app, so the
/// exception is expressed as packages here.
///
/// Derived from [workoutUnblockDomains] rather than configured separately,
/// so that the shell config stays the single source of truth for *intent*
/// even though the mechanism differs.
Set<String> get workoutExemptPackages {
const domainToPackages = <String, List<String>>{
'youtube.com': [
'com.google.android.youtube',
'com.google.android.apps.youtube.music',
],
};
final packages = <String>{};
for (final entry in domainToPackages.entries) {
final covered = workoutUnblockDomains.any(
(domain) => domain == entry.key || domain.endsWith('.${entry.key}'),
);
if (covered) packages.addAll(entry.value);
}
return packages;
}
}
Set<String> _stringSet(Object? value, String field) {
if (value is! List) {
throw PolicyFormatException('"$field" must be a list');
}
return value.map((e) => e! as String).toSet();
}
/// A list that may be absent, read as empty.
///
/// Used for fields added after assets were already shipped. The strict
/// [_stringSet] would throw, and an unparsable policy is worse than a missing
/// field: it disables the feature entirely rather than degrading to the
/// previous behaviour. A present-but-wrong type is still an error, since that
/// is a real mistake. Mirrors `optionalStringSet` in `FocusPolicy.kt`.
Set<String> _optionalStringSet(Map<String, Object?> json, String field) {
if (!json.containsKey(field)) return const {};
return _stringSet(json[field], field);
}
double _requireNum(Object? value, String field) {
if (value is! num) {
throw PolicyFormatException('"$field" must be a number');
}
return value.toDouble();
}
int _parseHhMm(Object? value, String field) {
if (value is! String) {
throw PolicyFormatException('"$field" must be a "HH:MM" string');
}
final parts = value.split(':');
if (parts.length != 2) {
throw PolicyFormatException('"$field" must be "HH:MM", got "$value"');
}
final hour = int.tryParse(parts[0]);
final minute = int.tryParse(parts[1]);
if (hour == null || minute == null || hour > 23 || minute > 59) {
throw PolicyFormatException('"$field" is not a valid time: "$value"');
}
return hour * 60 + minute;
}