mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
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
352 lines
12 KiB
Dart
352 lines
12 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:focus_owner/device_policy.dart';
|
|
import 'package:focus_owner/main.dart';
|
|
import 'package:focus_owner/policy.dart';
|
|
|
|
/// Builds a channel whose handler answers from [responses], recording calls.
|
|
({MethodChannel channel, List<MethodCall> calls}) _fakeChannel(
|
|
Map<String, Object?> responses,
|
|
) {
|
|
const channel = MethodChannel('test/device_policy');
|
|
final calls = <MethodCall>[];
|
|
// hasHomeLocation is read on every status refresh, so it is defaulted here
|
|
// rather than restated in each widget test. An explicit stub still wins,
|
|
// which is what the geofence-state tests rely on.
|
|
final stubs = {'hasHomeLocation': false, ...responses};
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, (call) async {
|
|
calls.add(call);
|
|
if (!stubs.containsKey(call.method)) {
|
|
throw PlatformException(code: 'unimplemented', message: 'no stub');
|
|
}
|
|
return stubs[call.method];
|
|
});
|
|
return (channel: channel, calls: calls);
|
|
}
|
|
|
|
Map<String, Object?> _statusMap({bool isDeviceOwner = false}) => {
|
|
'packageName': 'com.kuhy.focus_owner',
|
|
'isDeviceOwner': isDeviceOwner,
|
|
'isAdminActive': isDeviceOwner,
|
|
'sdkInt': 36,
|
|
'restrictionsApplied': false,
|
|
};
|
|
|
|
/// Minimal policy so widget tests never touch the (empty) test rootBundle.
|
|
Future<FocusPolicy> _stubPolicy() async => FocusPolicy.fromJson({
|
|
'schema_version': kSupportedSchemaVersion,
|
|
'home': {
|
|
'latitude': null,
|
|
'longitude': null,
|
|
'radius_m': 150.0,
|
|
'hysteresis_m': 30.0,
|
|
},
|
|
'curfew': {'start': '23:00', 'end': '05:00'},
|
|
'launcher_package': 'com.launcher',
|
|
'allowed_packages': ['com.good'],
|
|
'night_allowed_packages': <String>[],
|
|
'never_disable_prefixes': <String>[],
|
|
'workout_unblock_domains': ['youtube.com'],
|
|
'browser_packages': <String>[],
|
|
});
|
|
|
|
void main() {
|
|
group('DevicePolicyStatus', () {
|
|
test('parses the native status map', () {
|
|
final status = DevicePolicyStatus.fromMap(_statusMap(isDeviceOwner: true));
|
|
expect(status.packageName, 'com.kuhy.focus_owner');
|
|
expect(status.isDeviceOwner, isTrue);
|
|
expect(status.sdkInt, 36);
|
|
});
|
|
|
|
test('this build never reports restrictions as applied', () {
|
|
// Not a claim that nothing is enforced -- the app is live and hiding
|
|
// packages, and it does apply user restrictions once the VPN and DNS
|
|
// layers are locked. The native side simply never reads this back, so
|
|
// it is always false; the real state is the hidden-app count.
|
|
expect(
|
|
DevicePolicyStatus.fromMap(_statusMap()).restrictionsApplied,
|
|
isFalse,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('DevicePolicy', () {
|
|
test('status() invokes the native status method', () async {
|
|
final fake = _fakeChannel({'status': _statusMap()});
|
|
final status = await DevicePolicy(fake.channel).status();
|
|
expect(fake.calls.single.method, 'status');
|
|
expect(status.isDeviceOwner, isFalse);
|
|
});
|
|
|
|
test('releaseDeviceOwner() forwards the native result', () async {
|
|
final fake = _fakeChannel({'releaseDeviceOwner': true});
|
|
expect(await DevicePolicy(fake.channel).releaseDeviceOwner(), isTrue);
|
|
expect(fake.calls.single.method, 'releaseDeviceOwner');
|
|
});
|
|
|
|
test('releaseDeviceOwner() reports failure rather than throwing', () async {
|
|
// The button that calls this is the last resort before a factory reset,
|
|
// so a failure must surface in the UI, never as an unhandled error.
|
|
final fake = _fakeChannel({'releaseDeviceOwner': false});
|
|
expect(await DevicePolicy(fake.channel).releaseDeviceOwner(), isFalse);
|
|
});
|
|
|
|
test('releaseDeviceOwner() treats a null reply as failure', () async {
|
|
final fake = _fakeChannel({'releaseDeviceOwner': null});
|
|
expect(await DevicePolicy(fake.channel).releaseDeviceOwner(), isFalse);
|
|
});
|
|
});
|
|
|
|
group('StatusPage', () {
|
|
testWidgets('hides the release button when not device owner',
|
|
(tester) async {
|
|
final fake = _fakeChannel({'status': _statusMap()});
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: StatusPage(
|
|
policy: DevicePolicy(fake.channel),
|
|
loadFocusPolicy: _stubPolicy,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Release device owner'), findsNothing);
|
|
expect(find.textContaining('Nothing is enforced'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows the release button when device owner', (tester) async {
|
|
final fake = _fakeChannel({'status': _statusMap(isDeviceOwner: true)});
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: StatusPage(
|
|
policy: DevicePolicy(fake.channel),
|
|
loadFocusPolicy: _stubPolicy,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Release device owner'), findsOneWidget);
|
|
// The old "none (inert build)" label was a leftover from before this app
|
|
// enforced anything, and it stayed on screen for weeks while YouTube was
|
|
// in fact being hidden. With no pass recorded the honest answer is that
|
|
// nothing is known yet, not that nothing is applied.
|
|
expect(find.text('no pass recorded yet'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('reports when no home is set, since that means everywhere',
|
|
(tester) async {
|
|
// Without coordinates every pass decides LOCATION_UNKNOWN and fails
|
|
// closed, so enforcement is permanent rather than geofenced. That is a
|
|
// large behavioural difference and must be visible on screen.
|
|
final fake = _fakeChannel({
|
|
'status': _statusMap(isDeviceOwner: true),
|
|
'hasHomeLocation': false,
|
|
});
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: StatusPage(
|
|
policy: DevicePolicy(fake.channel),
|
|
loadFocusPolicy: _stubPolicy,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.textContaining('enforcing everywhere'), findsOneWidget);
|
|
expect(find.text('Set home to current location'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('offers to update home once one is set', (tester) async {
|
|
final fake = _fakeChannel({
|
|
'status': _statusMap(isDeviceOwner: true),
|
|
'hasHomeLocation': true,
|
|
});
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: StatusPage(
|
|
policy: DevicePolicy(fake.channel),
|
|
loadFocusPolicy: _stubPolicy,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('active'), findsOneWidget);
|
|
expect(find.text('Update home to here'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('release is confirmed before it runs', (tester) async {
|
|
final fake = _fakeChannel({
|
|
'status': _statusMap(isDeviceOwner: true),
|
|
'releaseDeviceOwner': true,
|
|
});
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: StatusPage(
|
|
policy: DevicePolicy(fake.channel),
|
|
loadFocusPolicy: _stubPolicy,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.scrollUntilVisible(find.text('Release device owner'), 100);
|
|
await tester.tap(find.text('Release device owner'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Release device owner?'), findsOneWidget);
|
|
|
|
// Cancelling must not call through to the platform.
|
|
await tester.tap(find.text('Cancel'));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
fake.calls.where((c) => c.method == 'releaseDeviceOwner'),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
testWidgets('confirming release calls the platform', (tester) async {
|
|
final fake = _fakeChannel({
|
|
'status': _statusMap(isDeviceOwner: true),
|
|
'releaseDeviceOwner': true,
|
|
});
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: StatusPage(
|
|
policy: DevicePolicy(fake.channel),
|
|
loadFocusPolicy: _stubPolicy,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
await tester.scrollUntilVisible(find.text('Release device owner'), 100);
|
|
await tester.tap(find.text('Release device owner'));
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Release'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(
|
|
fake.calls.where((c) => c.method == 'releaseDeviceOwner'),
|
|
hasLength(1),
|
|
);
|
|
expect(find.text('Device owner released'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('running enforcement waits for the new record', (tester) async {
|
|
// The pass runs in a separate service process and now waits for a
|
|
// location fix, so re-reading immediately returns the PREVIOUS record.
|
|
// Measured: after re-anchoring home the card still showed the old
|
|
// distance, which reads as "the button did nothing".
|
|
var passRan = false;
|
|
const channel = MethodChannel('test/refresh');
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, (call) async {
|
|
switch (call.method) {
|
|
case 'status':
|
|
return _statusMap(isDeviceOwner: true);
|
|
case 'hasHomeLocation':
|
|
return true;
|
|
case 'runEnforcementNow':
|
|
passRan = true;
|
|
return true;
|
|
case 'readEnforcementLog':
|
|
return [_logLine(passRan ? 'AT_HOME' : 'AWAY')];
|
|
default:
|
|
throw PlatformException(code: 'unimplemented');
|
|
}
|
|
});
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: StatusPage(
|
|
policy: DevicePolicy(channel),
|
|
loadFocusPolicy: _stubPolicy,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('AWAY'), findsOneWidget);
|
|
|
|
await tester.scrollUntilVisible(find.text('Run enforcement now'), 100);
|
|
await tester.tap(find.text('Run enforcement now'));
|
|
// Long enough for at least one poll interval to elapse.
|
|
await tester.pump(const Duration(seconds: 3));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('AT HOME'), findsOneWidget);
|
|
expect(find.text('AWAY'), findsNothing);
|
|
});
|
|
|
|
testWidgets('a stuck pass stops polling instead of hanging',
|
|
(tester) async {
|
|
// If no new record ever lands the UI must still settle, not spin.
|
|
const channel = MethodChannel('test/stuck');
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, (call) async {
|
|
switch (call.method) {
|
|
case 'status':
|
|
return _statusMap(isDeviceOwner: true);
|
|
case 'hasHomeLocation':
|
|
return true;
|
|
case 'runEnforcementNow':
|
|
return true;
|
|
case 'readEnforcementLog':
|
|
return [_logLine('AWAY')];
|
|
default:
|
|
throw PlatformException(code: 'unimplemented');
|
|
}
|
|
});
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: StatusPage(
|
|
policy: DevicePolicy(channel),
|
|
loadFocusPolicy: _stubPolicy,
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.scrollUntilVisible(find.text('Run enforcement now'), 100);
|
|
await tester.tap(find.text('Run enforcement now'));
|
|
// Past the polling budget (~30 s).
|
|
await tester.pump(const Duration(seconds: 40));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('AWAY'), findsOneWidget);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// One JSON-lines record, as the platform channel returns them.
|
|
String _logLine(String reason) => jsonEncode({
|
|
'ts': DateTime.now().millisecondsSinceEpoch,
|
|
'reason': reason,
|
|
'distance_m': reason == 'AWAY' ? 766.0 : 0.0,
|
|
'threshold_m': 150.0,
|
|
'inside_fence': reason != 'AWAY',
|
|
'home_configured': true,
|
|
'fix': {
|
|
'age_ms': 5000,
|
|
'provider': 'gps',
|
|
'accuracy_m': 11.0,
|
|
'outcome': 'ACTIVE_OK',
|
|
},
|
|
'curfew_active': false,
|
|
'curfew_window': '23:00-05:00',
|
|
'counts': {
|
|
'to_hide': 3,
|
|
'to_show': 58,
|
|
'hid_delta': 0,
|
|
'restored_delta': 0,
|
|
},
|
|
'hidden': const <Object?>[],
|
|
'failure': null,
|
|
});
|