testsAndMisc/focus_owner/test/widget_test.dart
Krzysztof kuhy Rudnicki f636d2b21b Make focus policy and blocklist usable without root; fix workout CDN unblock
phone_focus_mode is built for a rooted phone: the hosts layer is a read-only
bind mount over /system/etc/hosts, chosen so that even root-in-a-terminal
cannot overwrite it. None of that ports to a stock locked device. This splits
out the parts that are device-independent so one policy can drive both.

python_pkg/focus_policy parses config.sh rather than copying it, so the shell
config stays the source of truth and the two cannot drift. It rejects the
shipped REDACTED_* coordinate placeholder instead of defaulting to (0,0),
which would move "home" to the Atlantic and quietly disable the location gate.

blocklist.py converts the hosts file to domains for a DoT resolver or a local
VpnService. Three properties of the real 194k-line file make the obvious parser
wrong, and all three are handled: generate_hosts_file.sh *comments out*
deliberate unblocks rather than deleting them, so stripping '#' re-blocks
facebook/messenger/linkedin/4chan; the StevenBlack header carries loopback and
IPv6 housekeeping that would blackhole localhost; and ~1060 upstream annotation
tokens ('#[adware.zeno]', '"spam') render as address=/"spam/, which makes
dnsmasq refuse to start -- taking DNS down rather than blocking anything.
Verified with dnsmasq --test over all 185681 generated domains.

strip_workout_hosts.py had the same class of bug on the live rooted phone:
exact set membership meant the allowlisted googlevideo.com never matched the
dynamic r*---sn-*.googlevideo.com hosts YouTube actually streams from, so the
workout unblock was only partly working. Now suffix-matched (17 lines stripped,
was 14). The three new tests were checked against the original code in a
throwaway worktree; two of them fail there, so they pin the fix rather than
passing vacuously.

focus_owner is a device-owner app that deliberately does nothing yet. It ships
clearDeviceOwnerApp() implemented and tested first, because dpm
remove-active-admin does not work on a device owner: an app that locks the
device down before proving it can let go leaves a soft-brick recoverable only
through fastboot on a locked bootloader. Built, installed and launched on the
Pixel 6a; dumpsys afterwards confirms Device Owner Type -1 and no user
restrictions, i.e. the phone is unchanged.

Measured while scoping this, and not yet reflected in any enforcement code:
on Android 16 both pm suspend and pm disable-user are cleared by a reboot, so
shell-level app blocking is per-session only. setApplicationHidden is assumed
to persist under Device Owner but is untested.

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

148 lines
5.3 KiB
Dart

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';
/// 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>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
if (!responses.containsKey(call.method)) {
throw PlatformException(code: 'unimplemented', message: 'no stub');
}
return responses[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,
};
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', () {
// The app is provisioning-capable but inert; enforcement ships only
// after the release path is verified on a device.
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))),
);
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))),
);
await tester.pumpAndSettle();
expect(find.text('Release device owner'), findsOneWidget);
expect(find.text('none (inert build)'), 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))),
);
await tester.pumpAndSettle();
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))),
);
await tester.pumpAndSettle();
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);
});
});
}