testsAndMisc/focus_owner/test/policy_test.dart
Krzysztof kuhy Rudnicki 82e206116a
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Make the geofence tell the truth, and the phone explain itself
The enforcer failed in both directions: nothing blocked at home, and
everything blocked at the office. The comparison was never inverted --
`metres <= threshold` was always right. Three separate causes:

Only ACCESS_COARSE_LOCATION was declared. Android 12+ fuzzes coarse to a
~1-2 km grid against a 150 m fence, so a fix taken at home routinely landed
outside it, read as AWAY, and restored every app the geofence exists to hide.
FINE is now declared and self-granted by the device owner, along with coarse
and background, re-asserted every pass so a reinstall cannot silently drop it.

There were three states but two behaviours. LOCATION_UNKNOWN applies the
identical sweep to AT_HOME -- correct fail-closed design, indistinguishable
from outside. And nothing ever requested a fix: the pass read whatever another
app happened to cache, so at the office with no navigation running the cache
was empty. Passes now acquire a fix inside a location-typed foreground
service, bounded at 20 s, and treat anything older than 30 min as no fix. That
window is deliberately longer than the 15-minute cadence; a shorter one marks
the newest cache stale on every pass of an idle phone and blocks everything.

None of it was diagnosable. `adb logcat -d -s FocusOwner:V` came back empty
while dumpsys showed 82 alarms had fired, and `run-as` is refused on the
release build device owner requires. So the pass now writes a ring-buffered
JSONL record read back in-app: reason, distance vs the fence, fix
age/provider/accuracy, curfew state, and every hidden app with the rule that
hid it. Coordinates are never recorded -- a distance is enough to debug a
geofence, and this is a home address.

Also fixed along the way:

- Hysteresis keyed off `isEnforcing`, which is true under AWAY too because
  YouTube stays hidden, so the threshold was permanently radius+hysteresis.
  It now keys off whether the previous pass was inside the fence.
- The scheduler's curfew-boundary alarms were documented but never scheduled;
  both callers invoked `scheduleNext()` bare, so 05:00 could land 15 min late.
- Installing over the app cancelled its pending alarms, so `adb install -r`
  silently stopped enforcement until someone opened the app. MY_PACKAGE_REPLACED
  now re-arms it, verified on device without launching the app.
- A wrong-typed log record threw instead of being skipped, and the exception
  escaped far enough to leave the page stuck busy -- which disables the
  release-device-owner button, the one control that must work when everything
  else has gone wrong.
- `lib/enforcement.dart` was a second decision layer with no always-blocked
  concept, cited by KDoc as pinned by a `PolicyParityTest` that never existed.
  It would have made the new screen report YouTube visible while it is hidden.
  Deleted; the UI renders the Kotlin record rather than re-deciding.
- Anchoring home accepted a stale or unknown-precision fix. A wrong home
  offsets the fence everywhere and forever, which is what produced a 766 m
  reading while standing at home.

Policy: Play Store is allowed by day (not during curfew) so infakt can be
installed and updated; the sweep is default-deny for third-party packages, so
anything installed from Play is hidden on the next at-home pass. Tachiyomi's
per-source extension apks are covered by a new prefix allowlist rather than an
exact list that goes stale whenever an extension is installed.

`export.py` stopped subtracting the allowlist from `blockable_system_packages`.
Sweepable is not hideable: that list decides which system packages are
eligible for a decision, and `isAllowed` is what protects them. Removing an
allowed system package from the sweep freezes it in its last state -- it would
have stranded Play hidden permanently the moment it was allowlisted.

Verified on the Pixel 6a: FINE granted POLICY_FIXED, `applied AT_HOME` with a
0 m distance from a +/-11 m GPS fix, YouTube/Chrome/Firefox absent from
`pm list packages -e`, Play Store, infakt, RunnerUp and all five Tachiyomi
packages present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjxL9kdAzZmGqQA5tMNrZ8
2026-08-14 13:43:05 +02:00

254 lines
8.8 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_owner/policy.dart';
Map<String, Object?> _doc({
Object? schemaVersion = kSupportedSchemaVersion,
Object? curfew = const {'start': '23:00', 'end': '05:00'},
double? latitude,
double? longitude,
}) =>
{
'schema_version': schemaVersion,
'home': {
'latitude': latitude,
'longitude': longitude,
'radius_m': 150.0,
'hysteresis_m': 30.0,
},
'curfew': curfew,
'launcher_package': 'com.qqlabs.minimalistlauncher',
'allowed_packages': ['com.good', 'pl.mbank'],
'night_allowed_packages': ['pl.mbank'],
'never_disable_prefixes': ['com.android.providers'],
'workout_unblock_domains': ['youtube.com'],
'browser_packages': <String>[],
};
void main() {
group('FocusPolicy.fromJson', () {
test('parses a rendered policy', () {
final policy = FocusPolicy.fromJson(_doc());
expect(policy.allowedPackages, {'com.good', 'pl.mbank'});
expect(policy.nightAllowedPackages, {'pl.mbank'});
expect(policy.launcherPackage, 'com.qqlabs.minimalistlauncher');
expect(policy.home.radiusM, 150.0);
});
test('rejects an unknown schema version', () {
// Accepting a newer schema would mean enforcing a misread policy.
expect(
() => FocusPolicy.fromJson(_doc(schemaVersion: 999)),
throwsA(isA<PolicyFormatException>()),
);
});
test('rejects a document with no home object', () {
final doc = _doc()..remove('home');
expect(
() => FocusPolicy.fromJson(doc),
throwsA(isA<PolicyFormatException>()),
);
});
test('rejects a malformed curfew time', () {
expect(
() => FocusPolicy.fromJson(_doc(curfew: {'start': '25:00', 'end': 'x'})),
throwsA(isA<PolicyFormatException>()),
);
});
test('accepts an absent curfew', () {
expect(FocusPolicy.fromJson(_doc(curfew: null)).curfew, isNull);
});
});
group('HomeLocation', () {
test('redacted coordinates are reported as absent', () {
// The committed asset is redacted; enforcement must not treat a missing
// coordinate as (0, 0), which would put "home" in the Atlantic.
final policy = FocusPolicy.fromJson(_doc());
expect(policy.home.hasCoordinates, isFalse);
expect(policy.home.radiusM, 150.0);
});
test('real coordinates are reported as present', () {
final policy =
FocusPolicy.fromJson(_doc(latitude: 52.2297, longitude: 21.0122));
expect(policy.home.hasCoordinates, isTrue);
});
});
group('CurfewWindow', () {
const window = CurfewWindow(startMinutes: 23 * 60, endMinutes: 5 * 60);
test('wraps midnight', () {
expect(window.contains(22 * 60 + 59), isFalse);
expect(window.contains(23 * 60), isTrue);
expect(window.contains(0), isTrue);
expect(window.contains(4 * 60 + 59), isTrue);
expect(window.contains(5 * 60), isFalse);
expect(window.contains(12 * 60), isFalse);
});
test('handles a same-day window', () {
const day = CurfewWindow(startMinutes: 9 * 60, endMinutes: 17 * 60);
expect(day.contains(8 * 60), isFalse);
expect(day.contains(9 * 60), isTrue);
expect(day.contains(17 * 60), isFalse);
});
});
group('allow decisions', () {
final policy = FocusPolicy.fromJson(_doc());
test('protects system prefixes on whole labels only', () {
expect(policy.isProtected('com.android.providers'), isTrue);
expect(policy.isProtected('com.android.providers.telephony'), isTrue);
expect(policy.isProtected('com.android.providersomething'), isFalse);
});
test('protected packages are allowed even during curfew', () {
expect(
policy.isAllowed('com.android.providers', duringCurfew: true),
isTrue,
);
});
test('curfew narrows the allowlist', () {
expect(policy.isAllowed('com.good'), isTrue);
expect(policy.isAllowed('com.good', duringCurfew: true), isFalse);
expect(policy.isAllowed('pl.mbank', duringCurfew: true), isTrue);
});
test('unknown packages are blocked', () {
expect(policy.isAllowed('com.evil'), isFalse);
});
test('the launcher is allowed even when absent from the lists', () {
// Hiding it leaves no home screen. FocusPolicy.kt has always had this
// check; Dart had drifted without it.
expect(policy.allowedPackages, isNot(contains(policy.launcherPackage)));
expect(policy.isAllowed('com.qqlabs.minimalistlauncher'), isTrue);
expect(
policy.isAllowed('com.qqlabs.minimalistlauncher', duringCurfew: true),
isTrue,
);
});
test('an asset without prefix keys parses as empty', () {
// Back-compat: a policy rendered before prefixes existed must still
// parse, degrading to exact matching rather than failing entirely.
expect(policy.allowedPrefixes, isEmpty);
expect(policy.nightAllowedPrefixes, isEmpty);
});
test('allowed prefixes cover a family of packages', () {
final prefixed = FocusPolicy.fromJson(
_doc()
..['allowed_prefixes'] = ['eu.kanade.tachiyomi']
..['night_allowed_prefixes'] = ['eu.kanade.tachiyomi'],
);
expect(prefixed.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
expect(
prefixed.isAllowed('eu.kanade.tachiyomi.extension.all.mangadex'),
isTrue,
);
// Whole labels only, so an unrelated package is not swept in.
expect(prefixed.isAllowed('eu.kanade.tachiyomisomething'), isFalse);
// Chosen 2026-08-14: manga stays available during the curfew.
expect(
prefixed.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
isTrue,
);
});
test('a day-only prefix is blocked during curfew', () {
final dayOnly = FocusPolicy.fromJson(
_doc()..['allowed_prefixes'] = ['eu.kanade.tachiyomi'],
);
expect(dayOnly.isAllowed('eu.kanade.tachiyomi.sy'), isTrue);
expect(
dayOnly.isAllowed('eu.kanade.tachiyomi.sy', duringCurfew: true),
isFalse,
);
});
test('a prefix list of the wrong type is still an error', () {
// Absent is fine; present-but-wrong is a real mistake.
expect(
() => FocusPolicy.fromJson(_doc()..['allowed_prefixes'] = 'nope'),
throwsA(isA<PolicyFormatException>()),
);
});
test('isCurfewActive follows the window', () {
expect(policy.isCurfewActive(23 * 60 + 30), isTrue);
expect(policy.isCurfewActive(12 * 60), isFalse);
});
});
group('FocusPolicy.load', () {
test('reads and parses the bundled asset', () async {
final bundle = _StubBundle(jsonEncode(_doc()));
final policy = await FocusPolicy.load(bundle: bundle);
expect(policy.allowedPackages, contains('pl.mbank'));
});
test('rejects an asset that is not a JSON object', () async {
final bundle = _StubBundle('[]');
await expectLater(
FocusPolicy.load(bundle: bundle),
throwsA(isA<PolicyFormatException>()),
);
});
});
test('the real committed asset parses and is redacted', () async {
// Guards the generator: a policy.json that this app cannot read, or that
// leaks the home coordinates into git, is a build error.
final policy = await FocusPolicy.load(bundle: _RealAssetBundle());
// Guards against an empty or truncated render, not against a specific
// size. The threshold was 50 when the allowlist still described the rooted
// Blackview; the 2026-08-11 rewrite to an explicit list for this phone cut
// it to ~26, so a high bound would only pin the old policy in place.
expect(policy.allowedPackages.length, greaterThan(10));
// The launcher must survive enforcement or the device has no home screen.
expect(policy.launcherPackage, isNotNull);
expect(policy.allowedPackages, contains(policy.launcherPackage));
expect(policy.home.hasCoordinates, isFalse,
reason: 'committed policy.json must not carry home coordinates');
expect(policy.curfew, isNotNull);
});
}
class _StubBundle extends CachingAssetBundle {
_StubBundle(this.contents);
final String contents;
@override
Future<ByteData> load(String key) async =>
ByteData.sublistView(utf8.encode(contents));
@override
Future<String> loadString(String key, {bool cache = true}) async => contents;
}
/// Reads the asset straight off disk, bypassing the (empty) test rootBundle.
class _RealAssetBundle extends CachingAssetBundle {
@override
Future<ByteData> load(String key) async {
throw UnimplementedError(key);
}
@override
Future<String> loadString(String key, {bool cache = true}) async {
final file = File('assets/policy.json');
return file.readAsString();
}
}