mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 14:07:49 +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
128 lines
4.9 KiB
Dart
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;
|
|
}
|