testsAndMisc/focus_owner/lib/policy.dart
Krzysztof kuhy Rudnicki 1b7958143d Prove DPM app-hiding survives a reboot; build the enforcement decision layer
The whole Device Owner design assumed setApplicationHidden persists across a
reboot, and that had never been tested -- while both shell primitives were
measured to be cleared by one. Settling it looked like it needed a factory
reset. It does not: a managed profile is removable and takes its profile-owner
grant with it, so the DPM API can be exercised and then erased.

  pm create-user --profileOf 0 --managed   -> user 10
  dpm set-profile-owner --user 10 ...
  ... hide, reboot, re-check ...
  pm remove-user 10

hidden=true before the reboot, hidden=true after, agreed by dumpsys and by the
DPM API itself. Afterwards: Device Owner Type -1, no restrictions, and Chrome
in user 0 untouched.

Scope limit, stated because it is easy to overclaim: this proves the API's
hidden state persists under PROFILE OWNER inside a work profile. It does not
prove a DEVICE OWNER can hide a given app in user 0, which is what the design
actually needs -- DO there additionally cannot touch the launcher, the default
dialer/SMS/IME or packages the system deems critical, and the call can return
false silently. That must be tested in the first minute after provisioning,
while clearDeviceOwnerApp() is still a cheap exit.

On top of that, EnforcementDecision turns policy plus conditions into a
hide/show set. It is a pure function, so the whole decision layer is tested
without a device, a clock or a location provider.

packagesToShow is deliberately the complete allowed set rather than a delta.
Failing to hide something is a mild disappointment; failing to UNHIDE strands
the user without a dialer or a bank at 09:00 because the phone rebooted during
curfew. Applying the full set on every evaluation makes a missed run
self-repairing, which matters because BOOT_COMPLETED does not fire until first
unlock -- observed directly, the phone sat at RUNNING_LOCKED for minutes after
a reboot with no network at all.

The workout exception is expressed as packages rather than domains. The rooted
system unblocked youtube.com in a hosts file; hiding an app cannot target a
domain, so the package set is derived from the configured domains and config.sh
stays the source of truth for intent.

Signal sources were checked on the device before being built: StrongLifts is
granted health.WRITE_EXERCISE and Health Connect is installed, so that path is
viable; com.kuhy.workout_app exposes only a MainActivity and Flutter
boilerplate; org.runnerup is not installed here at all. Only the two that can
actually produce a signal will be wired.

HideTestReceiver is temporary and must be deleted before any provisioned build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
2026-08-09 21:36:26 +02:00

229 lines
7.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,
});
/// 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?,
);
}
/// 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;
/// Whether a package must never be disabled.
///
/// Prefix-matched on whole labels, so `com.android.providers` covers
/// `com.android.providers.telephony` but not `com.android.providersomething`.
bool isProtected(String package) => neverDisablePrefixes.any(
(prefix) => package == prefix || package.startsWith('$prefix.'),
);
/// Whether a package may run under the given conditions.
bool isAllowed(String package, {bool duringCurfew = false}) {
if (isProtected(package)) return true;
return duringCurfew
? nightAllowedPackages.contains(package)
: allowedPackages.contains(package);
}
/// 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();
}
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;
}