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

128 lines
4.9 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,
this.hasAccounts = true,
});
/// 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,
// Absent from an older native side; default to the scarier reading.
hasAccounts: map['hasAccounts'] as bool? ?? true,
);
}
final String packageName;
final bool isDeviceOwner;
final bool isAdminActive;
final int sdkInt;
/// Whether any user restriction is currently enforced.
///
/// Hardcoded false by the native side and NOT a statement about enforcement
/// — the app is live and hiding packages, and it does apply user
/// restrictions (`DISALLOW_CONFIG_VPN`, `DISALLOW_CONFIG_PRIVATE_DNS`) once
/// those layers are locked. Nothing reads this back from the system yet, so
/// treat it as "not reported" rather than "nothing applied"; the honest
/// enforcement state is the hidden-app count in the latest
/// [EnforcementRecord], which is what the status screen shows.
final bool restrictionsApplied;
/// Whether any account exists, which decides if releasing is reversible.
///
/// `dpm set-device-owner` refuses while accounts exist, so once one is
/// added, releasing can only be undone by a factory reset.
final bool hasAccounts;
}
/// 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!);
}
/// Runs one enforcement pass now and arms the schedule.
///
/// The service schedules the following run at the end of each pass, so this
/// is also how the chain is started on a device that has not rebooted since
/// the app was installed.
Future<bool> runEnforcementNow() async =>
await channel.invokeMethod<bool>('runEnforcementNow') ?? false;
/// Cancels any pending scheduled evaluation.
Future<bool> cancelEnforcement() async =>
await channel.invokeMethod<bool>('cancelEnforcement') ?? false;
/// Reads the durable enforcement log, newest first.
///
/// This is the only way to see what the enforcer has been doing: logcat
/// rotates (measured empty while 82 alarms had fired) and `run-as` is
/// refused on the release build device owner requires, so neither adb route
/// works. Returns raw JSON lines for [EnforcementRecord.parseLines].
Future<List<String>> readEnforcementLog({int limit = 200}) async {
final lines = await channel.invokeMethod<List<Object?>>(
'readEnforcementLog',
{'limit': limit},
);
return lines?.whereType<String>().toList() ?? const [];
}
/// 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;
}
/// Records the current location as home, enabling the geofence.
///
/// Returns null on success, or a message explaining the failure. Lives in
/// the app rather than a script because `run-as` does not work on the
/// release build that device owner requires.
Future<String?> setHomeToCurrentLocation() async =>
channel.invokeMethod<String>('setHomeToCurrentLocation');
/// Whether home coordinates are provisioned.
///
/// Without them every pass decides LOCATION_UNKNOWN and fails closed, so
/// enforcement applies everywhere rather than only at home.
Future<bool> hasHomeLocation() async =>
await channel.invokeMethod<bool>('hasHomeLocation') ?? false;
/// Prevents the user from turning the always-on VPN off in Settings.
///
/// Applied only after the VPN filter is confirmed working: pinning it over
/// a broken configuration would lock the broken state in.
Future<bool> setVpnConfigBlocked({required bool blocked}) async =>
await channel.invokeMethod<bool>(
'setVpnConfigBlocked',
{'blocked': blocked},
) ??
false;
}