mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 19:07:04 +01:00
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
273 lines
9.7 KiB
Dart
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;
|
|
}
|