testsAndMisc/focus_owner/lib/enforcement.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

193 lines
5.8 KiB
Dart

import 'dart:math' as math;
import 'package:focus_owner/policy.dart';
/// Why enforcement is currently on or off.
///
/// Carried alongside the decision so the UI and the logs can say *why* an app
/// is hidden. "Blocked" with no reason is the kind of thing that makes a
/// wellbeing tool feel broken rather than deliberate.
enum EnforcementReason {
/// Away from home, so nothing is enforced.
away,
/// At home during the day: the full allowlist applies.
atHome,
/// Inside the curfew window: the much shorter night allowlist applies.
curfew,
/// A workout is in progress, so the workout exceptions are released.
workout,
/// Location is unknown. Treated as "at home" deliberately — see
/// [EnforcementDecision.evaluate].
locationUnknown,
}
/// Inputs to one enforcement decision.
///
/// Grouped into a value type so the decision is a pure function of its inputs
/// and can be tested without a device, a clock, or a location provider.
class EnforcementInputs {
const EnforcementInputs({
required this.installedPackages,
required this.minutesSinceMidnight,
this.latitude,
this.longitude,
this.currentlyEnforcing = false,
this.workoutActive = false,
});
/// Third-party packages present on the device.
final Set<String> installedPackages;
/// Local time, as minutes since midnight.
final int minutesSinceMidnight;
/// Current position, or null when location is unavailable.
final double? latitude;
final double? longitude;
/// Whether enforcement is already active, for geofence hysteresis.
final bool currentlyEnforcing;
/// Whether a workout is in progress.
final bool workoutActive;
}
/// The outcome of evaluating the policy against the current conditions.
class EnforcementDecision {
const EnforcementDecision({
required this.reason,
required this.packagesToHide,
required this.packagesToShow,
});
/// Decides which packages should be hidden right now.
///
/// Fail-closed on unknown location, matching `focus_daemon.sh`, which logs
/// "Location unavailable - defaulting to focus mode". Losing GPS must not be
/// a way to switch enforcement off.
///
/// Note the asymmetry that makes this safe to run unattended: every package
/// the policy allows appears in [packagesToShow] on *every* evaluation, not
/// only on transitions. A missed run therefore leaves apps hidden that
/// should be visible only until the next run, and the next run repairs it
/// without needing to know what the previous one did.
factory EnforcementDecision.evaluate(
FocusPolicy policy,
EnforcementInputs inputs,
) {
final hasFix = inputs.latitude != null && inputs.longitude != null;
final atHome = hasFix &&
_isInside(
policy,
inputs.latitude!,
inputs.longitude!,
currentlyEnforcing: inputs.currentlyEnforcing,
);
if (hasFix && !atHome) {
return EnforcementDecision(
reason: EnforcementReason.away,
packagesToHide: const {},
packagesToShow: inputs.installedPackages,
);
}
final duringCurfew = policy.isCurfewActive(inputs.minutesSinceMidnight);
final reason = !hasFix
? EnforcementReason.locationUnknown
: duringCurfew
? EnforcementReason.curfew
: EnforcementReason.atHome;
final hide = <String>{};
final show = <String>{};
for (final package in inputs.installedPackages) {
if (policy.isAllowed(package, duringCurfew: duringCurfew)) {
show.add(package);
} else {
hide.add(package);
}
}
if (inputs.workoutActive) {
// A workout releases its exception apps from whatever the current
// state would otherwise be, without disturbing anything else.
for (final package in policy.workoutExemptPackages) {
if (hide.remove(package)) show.add(package);
}
return EnforcementDecision(
reason: EnforcementReason.workout,
packagesToHide: hide,
packagesToShow: show,
);
}
return EnforcementDecision(
reason: reason,
packagesToHide: hide,
packagesToShow: show,
);
}
/// Why this decision was reached.
final EnforcementReason reason;
/// Packages that must be hidden.
final Set<String> packagesToHide;
/// Packages that must be visible.
///
/// Always the complete allowed set, never a delta. Unhiding is the failure
/// mode that matters: a system that fails to hide is a mild disappointment,
/// one that fails to unhide strands the user without a dialer or a bank.
final Set<String> packagesToShow;
/// Whether anything is currently restricted.
bool get isEnforcing => packagesToHide.isNotEmpty;
static bool _isInside(
FocusPolicy policy,
double latitude,
double longitude, {
required bool currentlyEnforcing,
}) {
final home = policy.home;
if (!home.hasCoordinates) {
// No coordinates configured: the geofence cannot be evaluated, so it
// must not silently pass. Treated as "at home" by the caller via the
// hasFix path above; here we simply refuse to claim the user is away.
return true;
}
final metres = _haversineMetres(
home.latitude!,
home.longitude!,
latitude,
longitude,
);
final threshold = home.radiusM + (currentlyEnforcing ? home.hysteresisM : 0);
return metres <= threshold;
}
static double _haversineMetres(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const earthRadiusM = 6371000.0;
final dLat = _radians(lat2 - lat1);
final dLon = _radians(lon2 - lon1);
final a = math.pow(math.sin(dLat / 2), 2) +
math.cos(_radians(lat1)) *
math.cos(_radians(lat2)) *
math.pow(math.sin(dLon / 2), 2);
return 2 * earthRadiusM * math.asin(math.sqrt(a.toDouble()));
}
static double _radians(double degrees) => degrees * math.pi / 180.0;
}