testsAndMisc/focus_owner/lib/device_policy.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

62 lines
2.0 KiB
Dart

import 'package:flutter/services.dart';
/// Provisioning state reported by the native side.
class DevicePolicyStatus {
const DevicePolicyStatus({
required this.packageName,
required this.isDeviceOwner,
required this.isAdminActive,
required this.sdkInt,
required this.restrictionsApplied,
});
/// Builds a status from the raw platform-channel map.
factory DevicePolicyStatus.fromMap(Map<Object?, Object?> map) {
return DevicePolicyStatus(
packageName: map['packageName']! as String,
isDeviceOwner: map['isDeviceOwner']! as bool,
isAdminActive: map['isAdminActive']! as bool,
sdkInt: map['sdkInt']! as int,
restrictionsApplied: map['restrictionsApplied']! as bool,
);
}
final String packageName;
final bool isDeviceOwner;
final bool isAdminActive;
final int sdkInt;
/// Whether any user restriction is currently enforced.
///
/// Always false in this build: the app is provisioning-capable but inert
/// until the release path has been verified on a real device.
final bool restrictionsApplied;
}
/// Dart side of the device-policy platform channel.
class DevicePolicy {
const DevicePolicy([this.channel = _defaultChannel]);
static const MethodChannel _defaultChannel =
MethodChannel('com.kuhy.focus_owner/device_policy');
final MethodChannel channel;
/// Reads the current provisioning state.
Future<DevicePolicyStatus> status() async {
final result =
await channel.invokeMethod<Map<Object?, Object?>>('status');
return DevicePolicyStatus.fromMap(result!);
}
/// Relinquishes device ownership without wiping the device.
///
/// This is the escape hatch. It is deliberately reachable from the app's
/// main screen with no PC attached, because the situation it exists for is
/// one where ADB may already be disallowed.
Future<bool> releaseDeviceOwner() async {
final released = await channel.invokeMethod<bool>('releaseDeviceOwner');
return released ?? false;
}
}