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
This commit is contained in:
Krzysztof Rudnicki 2026-08-09 21:36:26 +02:00
parent 7cfe188bbe
commit 1b7958143d
9 changed files with 693 additions and 0 deletions

View File

@ -0,0 +1,24 @@
{
"title": "Prove DPM app-hiding persists, and build the enforcement decision layer on it",
"objective": "The Device Owner design rests on setApplicationHidden surviving a reboot, which had never been tested - both shell primitives were measured to be cleared by one. Prove or disprove that WITHOUT spending a factory reset, then build the decision layer that turns the parsed policy plus current conditions into a hide/show set. Success is a measured persistence result with its scope stated honestly, and a pure, fully tested decision function.",
"acceptance_criteria": [
"setApplicationHidden persistence measured across a real reboot, using a removable managed profile so no wipe is spent",
"The test profile and its profile-owner grant are removed afterwards, with user 0 verified untouched",
"The result is recorded with its scope limit: profile owner in a work profile is NOT device owner in user 0",
"EnforcementDecision is a pure function of its inputs - testable with no device, clock or location provider",
"packagesToShow is always the complete allowed set rather than a delta, so a missed run self-repairs",
"Unknown location enforces rather than releasing, matching focus_daemon.sh",
"A policy with redacted coordinates still enforces rather than reading as always-away",
"Geofence hysteresis prevents flapping between radius and radius+hysteresis",
"The workout exception releases the YouTube packages and nothing else",
"Workout signal sources are verified on-device before being built, not assumed"
],
"out_of_scope": [
"Device Owner provisioning and the factory reset - unchanged, still not done",
"The foreground service, BOOT_COMPLETED receiver and alarm scheduling - next step",
"DNS filtering, which remains parked",
"com.kuhy.workout_app as a signal source: verified to expose nothing readable",
"org.runnerup: not installed on this device"
],
"verifier": "pm create-user/dpm set-profile-owner + adb reboot + dumpsys package, then pm remove-user and re-check user 0; cd focus_owner && flutter analyze && flutter test"
}

View File

@ -0,0 +1,51 @@
{
"intent": "Settle whether DPM app-hiding survives a reboot without spending a factory reset on the question, then build the enforcement decision layer on the answer.",
"scope": [
"focus_owner/lib/enforcement.dart - new: pure hide/show decision function",
"focus_owner/lib/policy.dart - workoutExemptPackages, mapping unblock domains to packages",
"focus_owner/android/.../DevicePolicyBridge.kt - setApplicationHidden / isApplicationHidden / isProfileOwner",
"focus_owner/android/.../HideTestReceiver.kt - new, TEMPORARY: adb-driveable test hook",
"focus_owner/test/enforcement_test.dart - new: 16 tests",
"Non-goal: no Device Owner provisioning, no factory reset",
"Non-goal: the foreground service, boot receiver and alarms are the next step"
],
"changes": [
"Measured setApplicationHidden persistence using a removable managed profile (pm create-user --profileOf 0 --managed, dpm set-profile-owner) rather than a factory reset. hidden=true before reboot, hidden=true after, confirmed by both dumpsys and the DPM API. The profile was then removed, taking the profile-owner grant and all hidden state with it.",
"EnforcementDecision.evaluate is a pure function of policy plus conditions, so the whole decision layer is testable with no device, clock or location provider.",
"packagesToShow is always the complete allowed set rather than a delta. Unhiding is the failure mode that matters: failing to hide is a mild disappointment, failing to unhide strands the user without a dialer or a bank after a reboot during curfew. Applying the full set every run makes a missed evaluation self-repairing.",
"The workout exception is expressed as packages, not domains. The rooted system unblocked youtube.com in a hosts file; hiding apps cannot target a domain, so workoutExemptPackages derives the package set from the configured domains and keeps config.sh as the source of truth for intent."
],
"verification": [
{
"command": "pm create-user --profileOf 0 --managed; dpm set-profile-owner; am broadcast HIDE; adb reboot; dumpsys package",
"result": "pass",
"evidence": "hidden=true before reboot and hidden=true after, corroborated by the DPM API's own isApplicationHidden ('RESULT query com.android.chrome -> hidden=true'). This is the assumption the entire Device Owner design rests on, and it holds."
},
{
"command": "pm remove-user 10; dumpsys device_policy; dumpsys package com.android.chrome",
"result": "pass",
"evidence": "'Success: removed user'. Device Owner Type: -1, effective restrictions none, and Chrome in user 0 shows hidden=false - the real user was never touched."
},
{
"command": "cd focus_owner && flutter analyze && flutter test",
"result": "pass",
"evidence": "'No issues found!' and 43 tests passing, 16 of them new: away/at-home/curfew transitions, midnight wrap, hysteresis at 160m in both directions, fail-closed on unknown location, fail-closed on redacted coordinates, the workout exception, and an invariant that hide and show partition the installed set."
},
{
"command": "adb shell pm list packages / dumpsys package (workout signal sources)",
"result": "pass",
"evidence": "StrongLifts declares AND is granted android.permission.health.WRITE_EXERCISE, and com.google.android.apps.healthdata is installed, so the Health Connect path is viable. com.kuhy.workout_app exposes only MainActivity plus Flutter boilerplate - no provider or broadcast to read. org.runnerup is not installed at all. Two of the three proposed sources were checked before being built rather than after."
}
],
"risks": [
"SCOPE LIMIT, stated deliberately: persistence was proven for PROFILE OWNER inside a work profile. That is NOT the same as DEVICE OWNER hiding apps in user 0, which is what the real design needs. DO in user 0 additionally cannot hide the current launcher, the default dialer/SMS/IME, or packages the system deems critical, and setApplicationHidden may return false silently rather than throwing. Test this in the first minute after provisioning, while clearDeviceOwnerApp() is still a cheap exit.",
"HideTestReceiver is exported=true. It is gated behind MANAGE_DEVICE_ADMINS, a signature|privileged permission no third-party app can hold, but it is a receiver that applies policy and it must be deleted before any provisioned build. Tracked.",
"BOOT_COMPLETED does not fire until first unlock. Observed directly: the phone sat at RUNNING_LOCKED for several minutes after a reboot with no network and no app start. Enforcement state therefore persists unchanged across a reboot until the user unlocks - fail-closed for hiding, but also fail-closed for unhiding. The service layer must make boundary transitions alarm-driven and idempotent.",
"workoutExemptPackages hardcodes the youtube.com to package mapping. Adding a new unblock domain to config.sh will not automatically map to a package."
],
"rollback": [
"git revert. enforcement.dart is new and nothing outside focus_owner imports it; the app applies no policy in this build.",
"Nothing on the phone needs undoing - the test profile is already removed and user 0 was verified unchanged.",
"After rollback validate: flutter analyze && flutter test still pass, and dumpsys device_policy still reports Device Owner Type: -1."
]
}

View File

@ -41,6 +41,18 @@
</intent-filter>
</receiver>
<!-- ADB-driveable hook for the app-hiding persistence test. Not
exported: only the shell user and this app can broadcast to it. -->
<receiver
android:name=".HideTestReceiver"
android:exported="true"
android:permission="android.permission.MANAGE_DEVICE_ADMINS">
<intent-filter>
<action android:name="com.kuhy.focus_owner.HIDE" />
<action android:name="com.kuhy.focus_owner.QUERY" />
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -25,6 +25,42 @@ class DevicePolicyBridge(private val context: Context) {
/** Whether this package currently holds device owner. */
fun isDeviceOwner(): Boolean = dpm.isDeviceOwnerApp(context.packageName)
/** Whether this package is profile owner (a work profile, not the device). */
fun isProfileOwner(): Boolean = dpm.isProfileOwnerApp(context.packageName)
/**
* Hides or unhides a package, the DPM equivalent of `pm disable-user`.
*
* This is the mechanism the whole Device Owner design rests on, and unlike
* `pm suspend` and `pm disable-user` — both measured to be cleared by a
* reboot on this device — it is expected to persist. That expectation is
* the thing worth testing before a factory reset is spent on it.
*
* Requires device owner or profile owner; returns false when held by
* neither, rather than throwing.
*/
fun setApplicationHidden(packageName: String, hidden: Boolean): Boolean {
if (!isDeviceOwner() && !isProfileOwner()) {
Log.w(FocusDeviceAdminReceiver.TAG, "not DO/PO; cannot hide $packageName")
return false
}
return runCatching {
val applied = dpm.setApplicationHidden(admin, packageName, hidden)
Log.i(FocusDeviceAdminReceiver.TAG, "setApplicationHidden($packageName,$hidden)=$applied")
applied
}.getOrElse { error ->
Log.e(FocusDeviceAdminReceiver.TAG, "setApplicationHidden failed", error)
false
}
}
/** Whether a package is currently hidden by this admin. */
fun isApplicationHidden(packageName: String): Boolean {
if (!isDeviceOwner() && !isProfileOwner()) return false
return runCatching { dpm.isApplicationHidden(admin, packageName) }
.getOrDefault(false)
}
/** Whether the admin component is active (a weaker state than ownership). */
fun isAdminActive(): Boolean = dpm.isAdminActive(admin)

View File

@ -0,0 +1,58 @@
package com.kuhy.focus_owner
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
/**
* ADB-driveable hook for exercising [DevicePolicyBridge.setApplicationHidden].
*
* It exists to answer one question that a factory reset should not be spent
* guessing at: does DPM app-hiding survive a reboot? Both `pm suspend` and
* `pm disable-user` were measured to be cleared by one on this device, and the
* Device Owner design assumes `setApplicationHidden` behaves differently.
*
* Driving that from a UI would mean tapping through a screen before and after
* every reboot; a broadcast can be scripted, so the test is repeatable.
*
* adb shell am broadcast -a com.kuhy.focus_owner.HIDE \
* --es package <pkg> --ez hidden true --user <id>
*
* It is exported so `am broadcast` can reach it at all — a non-exported
* receiver silently drops shell broadcasts — but gated behind
* MANAGE_DEVICE_ADMINS, a signature|privileged permission no third-party app
* can hold. In practice that means the shell and the system, and nothing else.
*
* It applies policy that this build otherwise never applies, which is why it is
* confined to a test affordance rather than wired into normal startup. Remove
* it once app-hiding persistence is settled.
*/
class HideTestReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val bridge = DevicePolicyBridge(context.applicationContext)
val pkg = intent.getStringExtra("package")
if (pkg == null) {
Log.w(TAG, "HIDE broadcast with no package extra")
return
}
when (intent.action) {
ACTION_HIDE -> {
val hidden = intent.getBooleanExtra("hidden", true)
val applied = bridge.setApplicationHidden(pkg, hidden)
Log.i(TAG, "RESULT hide $pkg -> requested=$hidden applied=$applied")
}
ACTION_QUERY -> {
Log.i(TAG, "RESULT query $pkg -> hidden=${bridge.isApplicationHidden(pkg)}")
}
else -> Log.w(TAG, "unexpected action ${intent.action}")
}
}
companion object {
private const val TAG = FocusDeviceAdminReceiver.TAG
const val ACTION_HIDE = "com.kuhy.focus_owner.HIDE"
const val ACTION_QUERY = "com.kuhy.focus_owner.QUERY"
}
}

View File

@ -14,6 +14,23 @@ class MainActivity : FlutterActivity() {
when (call.method) {
"status" -> result.success(bridge.status())
"releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner())
"setApplicationHidden" -> {
val pkg = call.argument<String>("package")
val hidden = call.argument<Boolean>("hidden")
if (pkg == null || hidden == null) {
result.error("bad_args", "package and hidden required", null)
} else {
result.success(bridge.setApplicationHidden(pkg, hidden))
}
}
"isApplicationHidden" -> {
val pkg = call.argument<String>("package")
if (pkg == null) {
result.error("bad_args", "package required", null)
} else {
result.success(bridge.isApplicationHidden(pkg))
}
}
else -> result.notImplemented()
}
}

View File

@ -0,0 +1,192 @@
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;
}

View File

@ -168,6 +168,33 @@ class FocusPolicy {
/// 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) {

View File

@ -0,0 +1,276 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/enforcement.dart';
import 'package:focus_owner/policy.dart';
/// Warsaw, used only as a fixed reference point for the geofence maths.
const double _homeLat = 52.2297;
const double _homeLon = 21.0122;
const double _metresPerDegreeLat = 111320;
FocusPolicy _policy({bool withCoordinates = true, Object? curfew}) =>
FocusPolicy.fromJson({
'schema_version': kSupportedSchemaVersion,
'home': {
'latitude': withCoordinates ? _homeLat : null,
'longitude': withCoordinates ? _homeLon : null,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': curfew ?? const {'start': '23:00', 'end': '05:00'},
'launcher_package': 'com.launcher',
'allowed_packages': [
'com.launcher',
'pl.mbank',
'com.discord',
'com.google.android.youtube',
],
'night_allowed_packages': ['com.launcher', 'pl.mbank'],
'never_disable_prefixes': ['com.android.settings'],
'workout_unblock_domains': ['youtube.com', 'googlevideo.com'],
'browser_packages': <String>[],
});
const _installed = {
'com.launcher',
'pl.mbank',
'com.discord',
'com.google.android.youtube',
'com.evil',
'com.android.settings',
};
/// A point [metres] north of home.
double _latOffset(double metres) => _homeLat + metres / _metresPerDegreeLat;
void main() {
group('away from home', () {
test('nothing is hidden and everything is shown', () {
final decision = EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 12 * 60,
latitude: _latOffset(5000),
longitude: _homeLon,
),
);
expect(decision.reason, EnforcementReason.away);
expect(decision.packagesToHide, isEmpty);
expect(decision.packagesToShow, _installed);
expect(decision.isEnforcing, isFalse);
});
});
group('at home during the day', () {
late EnforcementDecision decision;
setUp(() {
decision = EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 12 * 60,
latitude: _homeLat,
longitude: _homeLon,
),
);
});
test('only non-allowlisted apps are hidden', () {
expect(decision.reason, EnforcementReason.atHome);
expect(decision.packagesToHide, {'com.evil'});
});
test('protected system packages are never hidden', () {
expect(decision.packagesToShow, contains('com.android.settings'));
});
test('the launcher is never hidden', () {
expect(decision.packagesToShow, contains('com.launcher'));
});
});
group('curfew', () {
test('the night list is much shorter than the day list', () {
final decision = EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 23 * 60 + 30,
latitude: _homeLat,
longitude: _homeLon,
),
);
expect(decision.reason, EnforcementReason.curfew);
expect(decision.packagesToHide, containsAll(['com.discord', 'com.evil']));
expect(decision.packagesToShow, containsAll(['pl.mbank', 'com.launcher']));
});
test('curfew wraps past midnight', () {
final decision = EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 3 * 60,
latitude: _homeLat,
longitude: _homeLon,
),
);
expect(decision.reason, EnforcementReason.curfew);
});
test('after the curfew ends the day list applies again', () {
final decision = EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 6 * 60,
latitude: _homeLat,
longitude: _homeLon,
),
);
expect(decision.reason, EnforcementReason.atHome);
expect(decision.packagesToShow, contains('com.discord'));
});
});
group('geofence hysteresis', () {
// 160m is outside the 150m radius but inside radius+hysteresis (180m), so
// the answer must depend on the current state or the enforcer flaps.
EnforcementDecision at160m({required bool enforcing}) =>
EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 12 * 60,
latitude: _latOffset(160),
longitude: _homeLon,
currentlyEnforcing: enforcing,
),
);
test('arriving: not yet inside', () {
expect(at160m(enforcing: false).reason, EnforcementReason.away);
});
test('leaving: still inside', () {
expect(at160m(enforcing: true).reason, EnforcementReason.atHome);
});
test('well beyond the band is away in both states', () {
for (final enforcing in [true, false]) {
final decision = EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 12 * 60,
latitude: _latOffset(500),
longitude: _homeLon,
currentlyEnforcing: enforcing,
),
);
expect(decision.reason, EnforcementReason.away);
}
});
});
group('fail-closed behaviour', () {
test('unknown location enforces rather than releasing', () {
// Losing GPS must not be a way to switch enforcement off. Matches
// focus_daemon.sh, which defaults to focus mode when location fails.
final decision = EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 12 * 60,
),
);
expect(decision.reason, EnforcementReason.locationUnknown);
expect(decision.packagesToHide, contains('com.evil'));
});
test('a policy with redacted coordinates still enforces', () {
// The committed policy.json has no coordinates. That must not silently
// disable the geofence into "always away".
final decision = EnforcementDecision.evaluate(
_policy(withCoordinates: false),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 12 * 60,
latitude: _latOffset(5000),
longitude: _homeLon,
),
);
expect(decision.packagesToHide, contains('com.evil'));
});
});
group('workout exception', () {
test('releases YouTube without releasing anything else', () {
final decision = EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 23 * 60 + 30,
latitude: _homeLat,
longitude: _homeLon,
workoutActive: true,
),
);
expect(decision.reason, EnforcementReason.workout);
expect(decision.packagesToShow, contains('com.google.android.youtube'));
expect(decision.packagesToHide, contains('com.evil'));
expect(decision.packagesToHide, contains('com.discord'));
});
test('domains map to packages, since hiding cannot target a domain', () {
expect(
_policy().workoutExemptPackages,
contains('com.google.android.youtube'),
);
});
test('no workout domains means no exempt packages', () {
final policy = FocusPolicy.fromJson({
'schema_version': kSupportedSchemaVersion,
'home': {
'latitude': null,
'longitude': null,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': null,
'launcher_package': null,
'allowed_packages': <String>[],
'night_allowed_packages': <String>[],
'never_disable_prefixes': <String>[],
'workout_unblock_domains': <String>[],
'browser_packages': <String>[],
});
expect(policy.workoutExemptPackages, isEmpty);
});
});
group('unhide reliability', () {
test('packagesToShow is the full allowed set, not a delta', () {
// The service applies packagesToShow unconditionally on every run, so a
// missed evaluation self-repairs. A delta would require knowing what the
// previous run did, and a reboot loses that.
final decision = EnforcementDecision.evaluate(
_policy(),
EnforcementInputs(
installedPackages: _installed,
minutesSinceMidnight: 12 * 60,
latitude: _homeLat,
longitude: _homeLon,
),
);
final union = {...decision.packagesToHide, ...decision.packagesToShow};
expect(union, _installed);
expect(
decision.packagesToHide.intersection(decision.packagesToShow),
isEmpty,
);
});
});
}