testsAndMisc/focus_owner/test/enforcement_test.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

277 lines
8.8 KiB
Dart

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,
);
});
});
}