mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 16:34:06 +01:00
main.dart 538 -> 130, policy.dart 272 -> 85, enforcement_status_card.dart 268 -> 145, plus both test files. Every lib split uses part/part of rather than import. The first attempt used import+export and the analyzer gave 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. The test splits separated groups from the testWidgets() calls that had been initialising the binding implicitly, so four tests failed with "Binding has not yet been initialized" - both halves now call ensureInitialized() explicitly. 94 tests passing before and after; flutter analyze reports no issues. focus_owner/analysis_options.yaml has an uncommitted change from an earlier session and is deliberately left unstaged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
198 lines
7.2 KiB
Dart
198 lines
7.2 KiB
Dart
/// The FocusPolicy aggregate and its JSON parsing.
|
|
///
|
|
/// Split out of policy.dart to keep it under the 250-line cap. A `part`
|
|
/// rather than a separate library: Dart privates are library-scoped, and
|
|
/// this code uses policy.dart's _requireNum and _parseHhMm.
|
|
|
|
part of 'policy.dart';
|
|
|
|
|
|
/// The device-independent focus policy, as generated from `config.sh`.
|
|
class FocusPolicy {
|
|
const FocusPolicy({
|
|
required this.home,
|
|
required this.allowedPackages,
|
|
required this.nightAllowedPackages,
|
|
required this.neverDisablePrefixes,
|
|
required this.workoutUnblockDomains,
|
|
required this.curfew,
|
|
required this.launcherPackage,
|
|
this.allowedPrefixes = const {},
|
|
this.nightAllowedPrefixes = const {},
|
|
});
|
|
|
|
/// Parses a rendered policy document.
|
|
factory FocusPolicy.fromJson(Map<String, Object?> json) {
|
|
final version = json['schema_version'];
|
|
if (version != kSupportedSchemaVersion) {
|
|
throw PolicyFormatException(
|
|
'unsupported schema_version $version '
|
|
'(this build understands $kSupportedSchemaVersion)',
|
|
);
|
|
}
|
|
final home = json['home'];
|
|
if (home is! Map<String, Object?>) {
|
|
throw const PolicyFormatException('missing "home" object');
|
|
}
|
|
final curfew = json['curfew'];
|
|
return FocusPolicy(
|
|
home: HomeLocation.fromJson(home),
|
|
allowedPackages: _stringSet(json['allowed_packages'], 'allowed_packages'),
|
|
nightAllowedPackages:
|
|
_stringSet(json['night_allowed_packages'], 'night_allowed_packages'),
|
|
neverDisablePrefixes: _stringSet(
|
|
json['never_disable_prefixes'],
|
|
'never_disable_prefixes',
|
|
),
|
|
workoutUnblockDomains: _stringSet(
|
|
json['workout_unblock_domains'],
|
|
'workout_unblock_domains',
|
|
),
|
|
curfew: curfew == null
|
|
? null
|
|
: CurfewWindow.fromJson(curfew as Map<String, Object?>),
|
|
launcherPackage: json['launcher_package'] as String?,
|
|
// Optional: absent from an asset rendered before prefixes existed, and
|
|
// reading a missing key as empty restores exactly the old exact-match
|
|
// behaviour rather than failing to parse the whole policy.
|
|
allowedPrefixes: _optionalStringSet(
|
|
json,
|
|
'allowed_prefixes',
|
|
),
|
|
nightAllowedPrefixes: _optionalStringSet(
|
|
json,
|
|
'night_allowed_prefixes',
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Loads the policy bundled with the app.
|
|
static Future<FocusPolicy> load({
|
|
AssetBundle? bundle,
|
|
String assetKey = 'assets/policy.json',
|
|
}) async {
|
|
final raw = await (bundle ?? rootBundle).loadString(assetKey);
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! Map<String, Object?>) {
|
|
throw const PolicyFormatException('policy asset is not a JSON object');
|
|
}
|
|
return FocusPolicy.fromJson(decoded);
|
|
}
|
|
|
|
final HomeLocation home;
|
|
final Set<String> allowedPackages;
|
|
final Set<String> nightAllowedPackages;
|
|
final Set<String> neverDisablePrefixes;
|
|
final Set<String> workoutUnblockDomains;
|
|
final CurfewWindow? curfew;
|
|
final String? launcherPackage;
|
|
|
|
/// Prefix-matched day allowlist, for apps shipping as a package family.
|
|
///
|
|
/// Tachiyomi installs every source as its own apk, so an exact list goes
|
|
/// stale the moment a new extension is installed.
|
|
final Set<String> allowedPrefixes;
|
|
|
|
/// Prefixes that survive the curfew. Subset of [allowedPrefixes].
|
|
final Set<String> nightAllowedPrefixes;
|
|
|
|
/// Whether [package] is covered by any entry of [prefixes].
|
|
///
|
|
/// Matched on whole labels, so `com.android.providers` covers
|
|
/// `com.android.providers.telephony` but not `com.android.providersomething`.
|
|
/// Shared by every prefix list so the boundary rule cannot drift.
|
|
static bool _matchesPrefix(String package, Set<String> prefixes) =>
|
|
prefixes.any(
|
|
(prefix) => package == prefix || package.startsWith('$prefix.'),
|
|
);
|
|
|
|
/// Whether a package must never be disabled.
|
|
bool isProtected(String package) =>
|
|
_matchesPrefix(package, neverDisablePrefixes);
|
|
|
|
/// Whether a package may run under the given conditions.
|
|
bool isAllowed(String package, {bool duringCurfew = false}) {
|
|
if (isProtected(package)) return true;
|
|
// Hiding the launcher leaves no home screen, so it outranks the lists --
|
|
// matching FocusPolicy.kt, which has always had this check.
|
|
if (package == launcherPackage) return true;
|
|
final allowed = duringCurfew ? nightAllowedPackages : allowedPackages;
|
|
if (allowed.contains(package)) return true;
|
|
final prefixes = duringCurfew ? nightAllowedPrefixes : allowedPrefixes;
|
|
return _matchesPrefix(package, prefixes);
|
|
}
|
|
|
|
/// Whether the curfew is in force at [minutesSinceMidnight].
|
|
bool isCurfewActive(int minutesSinceMidnight) =>
|
|
curfew?.contains(minutesSinceMidnight) ?? false;
|
|
|
|
/// Packages released while a workout is in progress.
|
|
///
|
|
/// The rooted system expressed this exception as *domains*, because it
|
|
/// enforced through a hosts file. Hiding apps is a coarser instrument: there
|
|
/// is no way to unblock youtube.com without unhiding the YouTube app, so the
|
|
/// exception is expressed as packages here.
|
|
///
|
|
/// Derived from [workoutUnblockDomains] rather than configured separately,
|
|
/// so that the shell config stays the single source of truth for *intent*
|
|
/// even though the mechanism differs.
|
|
Set<String> get workoutExemptPackages {
|
|
const domainToPackages = <String, List<String>>{
|
|
'youtube.com': [
|
|
'com.google.android.youtube',
|
|
'com.google.android.apps.youtube.music',
|
|
],
|
|
};
|
|
final packages = <String>{};
|
|
for (final entry in domainToPackages.entries) {
|
|
final covered = workoutUnblockDomains.any(
|
|
(domain) => domain == entry.key || domain.endsWith('.${entry.key}'),
|
|
);
|
|
if (covered) packages.addAll(entry.value);
|
|
}
|
|
return packages;
|
|
}
|
|
}
|
|
|
|
Set<String> _stringSet(Object? value, String field) {
|
|
if (value is! List) {
|
|
throw PolicyFormatException('"$field" must be a list');
|
|
}
|
|
return value.map((e) => e! as String).toSet();
|
|
}
|
|
|
|
/// A list that may be absent, read as empty.
|
|
///
|
|
/// Used for fields added after assets were already shipped. The strict
|
|
/// [_stringSet] would throw, and an unparsable policy is worse than a missing
|
|
/// field: it disables the feature entirely rather than degrading to the
|
|
/// previous behaviour. A present-but-wrong type is still an error, since that
|
|
/// is a real mistake. Mirrors `optionalStringSet` in `FocusPolicy.kt`.
|
|
Set<String> _optionalStringSet(Map<String, Object?> json, String field) {
|
|
if (!json.containsKey(field)) return const {};
|
|
return _stringSet(json[field], field);
|
|
}
|
|
|
|
double _requireNum(Object? value, String field) {
|
|
if (value is! num) {
|
|
throw PolicyFormatException('"$field" must be a number');
|
|
}
|
|
return value.toDouble();
|
|
}
|
|
|
|
int _parseHhMm(Object? value, String field) {
|
|
if (value is! String) {
|
|
throw PolicyFormatException('"$field" must be a "HH:MM" string');
|
|
}
|
|
final parts = value.split(':');
|
|
if (parts.length != 2) {
|
|
throw PolicyFormatException('"$field" must be "HH:MM", got "$value"');
|
|
}
|
|
final hour = int.tryParse(parts[0]);
|
|
final minute = int.tryParse(parts[1]);
|
|
if (hour == null || minute == null || hour > 23 || minute > 59) {
|
|
throw PolicyFormatException('"$field" is not a valid time: "$value"');
|
|
}
|
|
return hour * 60 + minute;
|
|
}
|