mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
Prove DPM app-hiding survives a reboot; build the enforcement decision layer
The whole Device Owner design assumed setApplicationHidden persists across a reboot, and that had never been tested -- while both shell primitives were measured to be cleared by one. Settling it looked like it needed a factory reset. It does not: a managed profile is removable and takes its profile-owner grant with it, so the DPM API can be exercised and then erased. pm create-user --profileOf 0 --managed -> user 10 dpm set-profile-owner --user 10 ... ... hide, reboot, re-check ... pm remove-user 10 hidden=true before the reboot, hidden=true after, agreed by dumpsys and by the DPM API itself. Afterwards: Device Owner Type -1, no restrictions, and Chrome in user 0 untouched. Scope limit, stated because it is easy to overclaim: this proves the API's hidden state persists under PROFILE OWNER inside a work profile. It does not prove a DEVICE OWNER can hide a given app in user 0, which is what the design actually needs -- DO there additionally cannot touch the launcher, the default dialer/SMS/IME or packages the system deems critical, and the call can return false silently. That must be tested in the first minute after provisioning, while clearDeviceOwnerApp() is still a cheap exit. On top of that, EnforcementDecision turns policy plus conditions into a hide/show set. It is a pure function, so the whole decision layer is tested without a device, a clock or a location provider. packagesToShow is deliberately the complete allowed set rather than a delta. Failing to hide something is a mild disappointment; failing to UNHIDE strands the user without a dialer or a bank at 09:00 because the phone rebooted during curfew. Applying the full set on every evaluation makes a missed run self-repairing, which matters because BOOT_COMPLETED does not fire until first unlock -- observed directly, the phone sat at RUNNING_LOCKED for minutes after a reboot with no network at all. The workout exception is expressed as packages rather than domains. The rooted system unblocked youtube.com in a hosts file; hiding an app cannot target a domain, so the package set is derived from the configured domains and config.sh stays the source of truth for intent. Signal sources were checked on the device before being built: StrongLifts is granted health.WRITE_EXERCISE and Health Connect is installed, so that path is viable; com.kuhy.workout_app exposes only a MainActivity and Flutter boilerplate; org.runnerup is not installed here at all. Only the two that can actually produce a signal will be wired. HideTestReceiver is temporary and must be deleted before any provisioned build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
This commit is contained in:
parent
7cfe188bbe
commit
1b7958143d
@ -0,0 +1,24 @@
|
||||
{
|
||||
"title": "Prove DPM app-hiding persists, and build the enforcement decision layer on it",
|
||||
"objective": "The Device Owner design rests on setApplicationHidden surviving a reboot, which had never been tested - both shell primitives were measured to be cleared by one. Prove or disprove that WITHOUT spending a factory reset, then build the decision layer that turns the parsed policy plus current conditions into a hide/show set. Success is a measured persistence result with its scope stated honestly, and a pure, fully tested decision function.",
|
||||
"acceptance_criteria": [
|
||||
"setApplicationHidden persistence measured across a real reboot, using a removable managed profile so no wipe is spent",
|
||||
"The test profile and its profile-owner grant are removed afterwards, with user 0 verified untouched",
|
||||
"The result is recorded with its scope limit: profile owner in a work profile is NOT device owner in user 0",
|
||||
"EnforcementDecision is a pure function of its inputs - testable with no device, clock or location provider",
|
||||
"packagesToShow is always the complete allowed set rather than a delta, so a missed run self-repairs",
|
||||
"Unknown location enforces rather than releasing, matching focus_daemon.sh",
|
||||
"A policy with redacted coordinates still enforces rather than reading as always-away",
|
||||
"Geofence hysteresis prevents flapping between radius and radius+hysteresis",
|
||||
"The workout exception releases the YouTube packages and nothing else",
|
||||
"Workout signal sources are verified on-device before being built, not assumed"
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Device Owner provisioning and the factory reset - unchanged, still not done",
|
||||
"The foreground service, BOOT_COMPLETED receiver and alarm scheduling - next step",
|
||||
"DNS filtering, which remains parked",
|
||||
"com.kuhy.workout_app as a signal source: verified to expose nothing readable",
|
||||
"org.runnerup: not installed on this device"
|
||||
],
|
||||
"verifier": "pm create-user/dpm set-profile-owner + adb reboot + dumpsys package, then pm remove-user and re-check user 0; cd focus_owner && flutter analyze && flutter test"
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
{
|
||||
"intent": "Settle whether DPM app-hiding survives a reboot without spending a factory reset on the question, then build the enforcement decision layer on the answer.",
|
||||
"scope": [
|
||||
"focus_owner/lib/enforcement.dart - new: pure hide/show decision function",
|
||||
"focus_owner/lib/policy.dart - workoutExemptPackages, mapping unblock domains to packages",
|
||||
"focus_owner/android/.../DevicePolicyBridge.kt - setApplicationHidden / isApplicationHidden / isProfileOwner",
|
||||
"focus_owner/android/.../HideTestReceiver.kt - new, TEMPORARY: adb-driveable test hook",
|
||||
"focus_owner/test/enforcement_test.dart - new: 16 tests",
|
||||
"Non-goal: no Device Owner provisioning, no factory reset",
|
||||
"Non-goal: the foreground service, boot receiver and alarms are the next step"
|
||||
],
|
||||
"changes": [
|
||||
"Measured setApplicationHidden persistence using a removable managed profile (pm create-user --profileOf 0 --managed, dpm set-profile-owner) rather than a factory reset. hidden=true before reboot, hidden=true after, confirmed by both dumpsys and the DPM API. The profile was then removed, taking the profile-owner grant and all hidden state with it.",
|
||||
"EnforcementDecision.evaluate is a pure function of policy plus conditions, so the whole decision layer is testable with no device, clock or location provider.",
|
||||
"packagesToShow is always the complete allowed set rather than a delta. Unhiding is the failure mode that matters: failing to hide is a mild disappointment, failing to unhide strands the user without a dialer or a bank after a reboot during curfew. Applying the full set every run makes a missed evaluation self-repairing.",
|
||||
"The workout exception is expressed as packages, not domains. The rooted system unblocked youtube.com in a hosts file; hiding apps cannot target a domain, so workoutExemptPackages derives the package set from the configured domains and keeps config.sh as the source of truth for intent."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "pm create-user --profileOf 0 --managed; dpm set-profile-owner; am broadcast HIDE; adb reboot; dumpsys package",
|
||||
"result": "pass",
|
||||
"evidence": "hidden=true before reboot and hidden=true after, corroborated by the DPM API's own isApplicationHidden ('RESULT query com.android.chrome -> hidden=true'). This is the assumption the entire Device Owner design rests on, and it holds."
|
||||
},
|
||||
{
|
||||
"command": "pm remove-user 10; dumpsys device_policy; dumpsys package com.android.chrome",
|
||||
"result": "pass",
|
||||
"evidence": "'Success: removed user'. Device Owner Type: -1, effective restrictions none, and Chrome in user 0 shows hidden=false - the real user was never touched."
|
||||
},
|
||||
{
|
||||
"command": "cd focus_owner && flutter analyze && flutter test",
|
||||
"result": "pass",
|
||||
"evidence": "'No issues found!' and 43 tests passing, 16 of them new: away/at-home/curfew transitions, midnight wrap, hysteresis at 160m in both directions, fail-closed on unknown location, fail-closed on redacted coordinates, the workout exception, and an invariant that hide and show partition the installed set."
|
||||
},
|
||||
{
|
||||
"command": "adb shell pm list packages / dumpsys package (workout signal sources)",
|
||||
"result": "pass",
|
||||
"evidence": "StrongLifts declares AND is granted android.permission.health.WRITE_EXERCISE, and com.google.android.apps.healthdata is installed, so the Health Connect path is viable. com.kuhy.workout_app exposes only MainActivity plus Flutter boilerplate - no provider or broadcast to read. org.runnerup is not installed at all. Two of the three proposed sources were checked before being built rather than after."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"SCOPE LIMIT, stated deliberately: persistence was proven for PROFILE OWNER inside a work profile. That is NOT the same as DEVICE OWNER hiding apps in user 0, which is what the real design needs. DO in user 0 additionally cannot hide the current launcher, the default dialer/SMS/IME, or packages the system deems critical, and setApplicationHidden may return false silently rather than throwing. Test this in the first minute after provisioning, while clearDeviceOwnerApp() is still a cheap exit.",
|
||||
"HideTestReceiver is exported=true. It is gated behind MANAGE_DEVICE_ADMINS, a signature|privileged permission no third-party app can hold, but it is a receiver that applies policy and it must be deleted before any provisioned build. Tracked.",
|
||||
"BOOT_COMPLETED does not fire until first unlock. Observed directly: the phone sat at RUNNING_LOCKED for several minutes after a reboot with no network and no app start. Enforcement state therefore persists unchanged across a reboot until the user unlocks - fail-closed for hiding, but also fail-closed for unhiding. The service layer must make boundary transitions alarm-driven and idempotent.",
|
||||
"workoutExemptPackages hardcodes the youtube.com to package mapping. Adding a new unblock domain to config.sh will not automatically map to a package."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert. enforcement.dart is new and nothing outside focus_owner imports it; the app applies no policy in this build.",
|
||||
"Nothing on the phone needs undoing - the test profile is already removed and user 0 was verified unchanged.",
|
||||
"After rollback validate: flutter analyze && flutter test still pass, and dumpsys device_policy still reports Device Owner Type: -1."
|
||||
]
|
||||
}
|
||||
@ -41,6 +41,18 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- ADB-driveable hook for the app-hiding persistence test. Not
|
||||
exported: only the shell user and this app can broadcast to it. -->
|
||||
<receiver
|
||||
android:name=".HideTestReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.MANAGE_DEVICE_ADMINS">
|
||||
<intent-filter>
|
||||
<action android:name="com.kuhy.focus_owner.HIDE" />
|
||||
<action android:name="com.kuhy.focus_owner.QUERY" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@ -25,6 +25,42 @@ class DevicePolicyBridge(private val context: Context) {
|
||||
/** Whether this package currently holds device owner. */
|
||||
fun isDeviceOwner(): Boolean = dpm.isDeviceOwnerApp(context.packageName)
|
||||
|
||||
/** Whether this package is profile owner (a work profile, not the device). */
|
||||
fun isProfileOwner(): Boolean = dpm.isProfileOwnerApp(context.packageName)
|
||||
|
||||
/**
|
||||
* Hides or unhides a package, the DPM equivalent of `pm disable-user`.
|
||||
*
|
||||
* This is the mechanism the whole Device Owner design rests on, and unlike
|
||||
* `pm suspend` and `pm disable-user` — both measured to be cleared by a
|
||||
* reboot on this device — it is expected to persist. That expectation is
|
||||
* the thing worth testing before a factory reset is spent on it.
|
||||
*
|
||||
* Requires device owner or profile owner; returns false when held by
|
||||
* neither, rather than throwing.
|
||||
*/
|
||||
fun setApplicationHidden(packageName: String, hidden: Boolean): Boolean {
|
||||
if (!isDeviceOwner() && !isProfileOwner()) {
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "not DO/PO; cannot hide $packageName")
|
||||
return false
|
||||
}
|
||||
return runCatching {
|
||||
val applied = dpm.setApplicationHidden(admin, packageName, hidden)
|
||||
Log.i(FocusDeviceAdminReceiver.TAG, "setApplicationHidden($packageName,$hidden)=$applied")
|
||||
applied
|
||||
}.getOrElse { error ->
|
||||
Log.e(FocusDeviceAdminReceiver.TAG, "setApplicationHidden failed", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a package is currently hidden by this admin. */
|
||||
fun isApplicationHidden(packageName: String): Boolean {
|
||||
if (!isDeviceOwner() && !isProfileOwner()) return false
|
||||
return runCatching { dpm.isApplicationHidden(admin, packageName) }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
/** Whether the admin component is active (a weaker state than ownership). */
|
||||
fun isAdminActive(): Boolean = dpm.isAdminActive(admin)
|
||||
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* ADB-driveable hook for exercising [DevicePolicyBridge.setApplicationHidden].
|
||||
*
|
||||
* It exists to answer one question that a factory reset should not be spent
|
||||
* guessing at: does DPM app-hiding survive a reboot? Both `pm suspend` and
|
||||
* `pm disable-user` were measured to be cleared by one on this device, and the
|
||||
* Device Owner design assumes `setApplicationHidden` behaves differently.
|
||||
*
|
||||
* Driving that from a UI would mean tapping through a screen before and after
|
||||
* every reboot; a broadcast can be scripted, so the test is repeatable.
|
||||
*
|
||||
* adb shell am broadcast -a com.kuhy.focus_owner.HIDE \
|
||||
* --es package <pkg> --ez hidden true --user <id>
|
||||
*
|
||||
* It is exported so `am broadcast` can reach it at all — a non-exported
|
||||
* receiver silently drops shell broadcasts — but gated behind
|
||||
* MANAGE_DEVICE_ADMINS, a signature|privileged permission no third-party app
|
||||
* can hold. In practice that means the shell and the system, and nothing else.
|
||||
*
|
||||
* It applies policy that this build otherwise never applies, which is why it is
|
||||
* confined to a test affordance rather than wired into normal startup. Remove
|
||||
* it once app-hiding persistence is settled.
|
||||
*/
|
||||
class HideTestReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val bridge = DevicePolicyBridge(context.applicationContext)
|
||||
val pkg = intent.getStringExtra("package")
|
||||
if (pkg == null) {
|
||||
Log.w(TAG, "HIDE broadcast with no package extra")
|
||||
return
|
||||
}
|
||||
when (intent.action) {
|
||||
ACTION_HIDE -> {
|
||||
val hidden = intent.getBooleanExtra("hidden", true)
|
||||
val applied = bridge.setApplicationHidden(pkg, hidden)
|
||||
Log.i(TAG, "RESULT hide $pkg -> requested=$hidden applied=$applied")
|
||||
}
|
||||
ACTION_QUERY -> {
|
||||
Log.i(TAG, "RESULT query $pkg -> hidden=${bridge.isApplicationHidden(pkg)}")
|
||||
}
|
||||
else -> Log.w(TAG, "unexpected action ${intent.action}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = FocusDeviceAdminReceiver.TAG
|
||||
const val ACTION_HIDE = "com.kuhy.focus_owner.HIDE"
|
||||
const val ACTION_QUERY = "com.kuhy.focus_owner.QUERY"
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,23 @@ class MainActivity : FlutterActivity() {
|
||||
when (call.method) {
|
||||
"status" -> result.success(bridge.status())
|
||||
"releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner())
|
||||
"setApplicationHidden" -> {
|
||||
val pkg = call.argument<String>("package")
|
||||
val hidden = call.argument<Boolean>("hidden")
|
||||
if (pkg == null || hidden == null) {
|
||||
result.error("bad_args", "package and hidden required", null)
|
||||
} else {
|
||||
result.success(bridge.setApplicationHidden(pkg, hidden))
|
||||
}
|
||||
}
|
||||
"isApplicationHidden" -> {
|
||||
val pkg = call.argument<String>("package")
|
||||
if (pkg == null) {
|
||||
result.error("bad_args", "package required", null)
|
||||
} else {
|
||||
result.success(bridge.isApplicationHidden(pkg))
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
192
focus_owner/lib/enforcement.dart
Normal file
192
focus_owner/lib/enforcement.dart
Normal file
@ -0,0 +1,192 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:focus_owner/policy.dart';
|
||||
|
||||
/// Why enforcement is currently on or off.
|
||||
///
|
||||
/// Carried alongside the decision so the UI and the logs can say *why* an app
|
||||
/// is hidden. "Blocked" with no reason is the kind of thing that makes a
|
||||
/// wellbeing tool feel broken rather than deliberate.
|
||||
enum EnforcementReason {
|
||||
/// Away from home, so nothing is enforced.
|
||||
away,
|
||||
|
||||
/// At home during the day: the full allowlist applies.
|
||||
atHome,
|
||||
|
||||
/// Inside the curfew window: the much shorter night allowlist applies.
|
||||
curfew,
|
||||
|
||||
/// A workout is in progress, so the workout exceptions are released.
|
||||
workout,
|
||||
|
||||
/// Location is unknown. Treated as "at home" deliberately — see
|
||||
/// [EnforcementDecision.evaluate].
|
||||
locationUnknown,
|
||||
}
|
||||
|
||||
/// Inputs to one enforcement decision.
|
||||
///
|
||||
/// Grouped into a value type so the decision is a pure function of its inputs
|
||||
/// and can be tested without a device, a clock, or a location provider.
|
||||
class EnforcementInputs {
|
||||
const EnforcementInputs({
|
||||
required this.installedPackages,
|
||||
required this.minutesSinceMidnight,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.currentlyEnforcing = false,
|
||||
this.workoutActive = false,
|
||||
});
|
||||
|
||||
/// Third-party packages present on the device.
|
||||
final Set<String> installedPackages;
|
||||
|
||||
/// Local time, as minutes since midnight.
|
||||
final int minutesSinceMidnight;
|
||||
|
||||
/// Current position, or null when location is unavailable.
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
/// Whether enforcement is already active, for geofence hysteresis.
|
||||
final bool currentlyEnforcing;
|
||||
|
||||
/// Whether a workout is in progress.
|
||||
final bool workoutActive;
|
||||
}
|
||||
|
||||
/// The outcome of evaluating the policy against the current conditions.
|
||||
class EnforcementDecision {
|
||||
const EnforcementDecision({
|
||||
required this.reason,
|
||||
required this.packagesToHide,
|
||||
required this.packagesToShow,
|
||||
});
|
||||
|
||||
/// Decides which packages should be hidden right now.
|
||||
///
|
||||
/// Fail-closed on unknown location, matching `focus_daemon.sh`, which logs
|
||||
/// "Location unavailable - defaulting to focus mode". Losing GPS must not be
|
||||
/// a way to switch enforcement off.
|
||||
///
|
||||
/// Note the asymmetry that makes this safe to run unattended: every package
|
||||
/// the policy allows appears in [packagesToShow] on *every* evaluation, not
|
||||
/// only on transitions. A missed run therefore leaves apps hidden that
|
||||
/// should be visible only until the next run, and the next run repairs it
|
||||
/// without needing to know what the previous one did.
|
||||
factory EnforcementDecision.evaluate(
|
||||
FocusPolicy policy,
|
||||
EnforcementInputs inputs,
|
||||
) {
|
||||
final hasFix = inputs.latitude != null && inputs.longitude != null;
|
||||
final atHome = hasFix &&
|
||||
_isInside(
|
||||
policy,
|
||||
inputs.latitude!,
|
||||
inputs.longitude!,
|
||||
currentlyEnforcing: inputs.currentlyEnforcing,
|
||||
);
|
||||
|
||||
if (hasFix && !atHome) {
|
||||
return EnforcementDecision(
|
||||
reason: EnforcementReason.away,
|
||||
packagesToHide: const {},
|
||||
packagesToShow: inputs.installedPackages,
|
||||
);
|
||||
}
|
||||
|
||||
final duringCurfew = policy.isCurfewActive(inputs.minutesSinceMidnight);
|
||||
final reason = !hasFix
|
||||
? EnforcementReason.locationUnknown
|
||||
: duringCurfew
|
||||
? EnforcementReason.curfew
|
||||
: EnforcementReason.atHome;
|
||||
|
||||
final hide = <String>{};
|
||||
final show = <String>{};
|
||||
for (final package in inputs.installedPackages) {
|
||||
if (policy.isAllowed(package, duringCurfew: duringCurfew)) {
|
||||
show.add(package);
|
||||
} else {
|
||||
hide.add(package);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.workoutActive) {
|
||||
// A workout releases its exception apps from whatever the current
|
||||
// state would otherwise be, without disturbing anything else.
|
||||
for (final package in policy.workoutExemptPackages) {
|
||||
if (hide.remove(package)) show.add(package);
|
||||
}
|
||||
return EnforcementDecision(
|
||||
reason: EnforcementReason.workout,
|
||||
packagesToHide: hide,
|
||||
packagesToShow: show,
|
||||
);
|
||||
}
|
||||
|
||||
return EnforcementDecision(
|
||||
reason: reason,
|
||||
packagesToHide: hide,
|
||||
packagesToShow: show,
|
||||
);
|
||||
}
|
||||
|
||||
/// Why this decision was reached.
|
||||
final EnforcementReason reason;
|
||||
|
||||
/// Packages that must be hidden.
|
||||
final Set<String> packagesToHide;
|
||||
|
||||
/// Packages that must be visible.
|
||||
///
|
||||
/// Always the complete allowed set, never a delta. Unhiding is the failure
|
||||
/// mode that matters: a system that fails to hide is a mild disappointment,
|
||||
/// one that fails to unhide strands the user without a dialer or a bank.
|
||||
final Set<String> packagesToShow;
|
||||
|
||||
/// Whether anything is currently restricted.
|
||||
bool get isEnforcing => packagesToHide.isNotEmpty;
|
||||
|
||||
static bool _isInside(
|
||||
FocusPolicy policy,
|
||||
double latitude,
|
||||
double longitude, {
|
||||
required bool currentlyEnforcing,
|
||||
}) {
|
||||
final home = policy.home;
|
||||
if (!home.hasCoordinates) {
|
||||
// No coordinates configured: the geofence cannot be evaluated, so it
|
||||
// must not silently pass. Treated as "at home" by the caller via the
|
||||
// hasFix path above; here we simply refuse to claim the user is away.
|
||||
return true;
|
||||
}
|
||||
final metres = _haversineMetres(
|
||||
home.latitude!,
|
||||
home.longitude!,
|
||||
latitude,
|
||||
longitude,
|
||||
);
|
||||
final threshold = home.radiusM + (currentlyEnforcing ? home.hysteresisM : 0);
|
||||
return metres <= threshold;
|
||||
}
|
||||
|
||||
static double _haversineMetres(
|
||||
double lat1,
|
||||
double lon1,
|
||||
double lat2,
|
||||
double lon2,
|
||||
) {
|
||||
const earthRadiusM = 6371000.0;
|
||||
final dLat = _radians(lat2 - lat1);
|
||||
final dLon = _radians(lon2 - lon1);
|
||||
final a = math.pow(math.sin(dLat / 2), 2) +
|
||||
math.cos(_radians(lat1)) *
|
||||
math.cos(_radians(lat2)) *
|
||||
math.pow(math.sin(dLon / 2), 2);
|
||||
return 2 * earthRadiusM * math.asin(math.sqrt(a.toDouble()));
|
||||
}
|
||||
|
||||
static double _radians(double degrees) => degrees * math.pi / 180.0;
|
||||
}
|
||||
@ -168,6 +168,33 @@ class FocusPolicy {
|
||||
/// Whether the curfew is in force at [minutesSinceMidnight].
|
||||
bool isCurfewActive(int minutesSinceMidnight) =>
|
||||
curfew?.contains(minutesSinceMidnight) ?? false;
|
||||
|
||||
/// Packages released while a workout is in progress.
|
||||
///
|
||||
/// The rooted system expressed this exception as *domains*, because it
|
||||
/// enforced through a hosts file. Hiding apps is a coarser instrument: there
|
||||
/// is no way to unblock youtube.com without unhiding the YouTube app, so the
|
||||
/// exception is expressed as packages here.
|
||||
///
|
||||
/// Derived from [workoutUnblockDomains] rather than configured separately,
|
||||
/// so that the shell config stays the single source of truth for *intent*
|
||||
/// even though the mechanism differs.
|
||||
Set<String> get workoutExemptPackages {
|
||||
const domainToPackages = <String, List<String>>{
|
||||
'youtube.com': [
|
||||
'com.google.android.youtube',
|
||||
'com.google.android.apps.youtube.music',
|
||||
],
|
||||
};
|
||||
final packages = <String>{};
|
||||
for (final entry in domainToPackages.entries) {
|
||||
final covered = workoutUnblockDomains.any(
|
||||
(domain) => domain == entry.key || domain.endsWith('.${entry.key}'),
|
||||
);
|
||||
if (covered) packages.addAll(entry.value);
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> _stringSet(Object? value, String field) {
|
||||
|
||||
276
focus_owner/test/enforcement_test.dart
Normal file
276
focus_owner/test/enforcement_test.dart
Normal file
@ -0,0 +1,276 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_owner/enforcement.dart';
|
||||
import 'package:focus_owner/policy.dart';
|
||||
|
||||
/// Warsaw, used only as a fixed reference point for the geofence maths.
|
||||
const double _homeLat = 52.2297;
|
||||
const double _homeLon = 21.0122;
|
||||
const double _metresPerDegreeLat = 111320;
|
||||
|
||||
FocusPolicy _policy({bool withCoordinates = true, Object? curfew}) =>
|
||||
FocusPolicy.fromJson({
|
||||
'schema_version': kSupportedSchemaVersion,
|
||||
'home': {
|
||||
'latitude': withCoordinates ? _homeLat : null,
|
||||
'longitude': withCoordinates ? _homeLon : null,
|
||||
'radius_m': 150.0,
|
||||
'hysteresis_m': 30.0,
|
||||
},
|
||||
'curfew': curfew ?? const {'start': '23:00', 'end': '05:00'},
|
||||
'launcher_package': 'com.launcher',
|
||||
'allowed_packages': [
|
||||
'com.launcher',
|
||||
'pl.mbank',
|
||||
'com.discord',
|
||||
'com.google.android.youtube',
|
||||
],
|
||||
'night_allowed_packages': ['com.launcher', 'pl.mbank'],
|
||||
'never_disable_prefixes': ['com.android.settings'],
|
||||
'workout_unblock_domains': ['youtube.com', 'googlevideo.com'],
|
||||
'browser_packages': <String>[],
|
||||
});
|
||||
|
||||
const _installed = {
|
||||
'com.launcher',
|
||||
'pl.mbank',
|
||||
'com.discord',
|
||||
'com.google.android.youtube',
|
||||
'com.evil',
|
||||
'com.android.settings',
|
||||
};
|
||||
|
||||
/// A point [metres] north of home.
|
||||
double _latOffset(double metres) => _homeLat + metres / _metresPerDegreeLat;
|
||||
|
||||
void main() {
|
||||
group('away from home', () {
|
||||
test('nothing is hidden and everything is shown', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _latOffset(5000),
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.away);
|
||||
expect(decision.packagesToHide, isEmpty);
|
||||
expect(decision.packagesToShow, _installed);
|
||||
expect(decision.isEnforcing, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('at home during the day', () {
|
||||
late EnforcementDecision decision;
|
||||
|
||||
setUp(() {
|
||||
decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('only non-allowlisted apps are hidden', () {
|
||||
expect(decision.reason, EnforcementReason.atHome);
|
||||
expect(decision.packagesToHide, {'com.evil'});
|
||||
});
|
||||
|
||||
test('protected system packages are never hidden', () {
|
||||
expect(decision.packagesToShow, contains('com.android.settings'));
|
||||
});
|
||||
|
||||
test('the launcher is never hidden', () {
|
||||
expect(decision.packagesToShow, contains('com.launcher'));
|
||||
});
|
||||
});
|
||||
|
||||
group('curfew', () {
|
||||
test('the night list is much shorter than the day list', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 23 * 60 + 30,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.curfew);
|
||||
expect(decision.packagesToHide, containsAll(['com.discord', 'com.evil']));
|
||||
expect(decision.packagesToShow, containsAll(['pl.mbank', 'com.launcher']));
|
||||
});
|
||||
|
||||
test('curfew wraps past midnight', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 3 * 60,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.curfew);
|
||||
});
|
||||
|
||||
test('after the curfew ends the day list applies again', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 6 * 60,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.atHome);
|
||||
expect(decision.packagesToShow, contains('com.discord'));
|
||||
});
|
||||
});
|
||||
|
||||
group('geofence hysteresis', () {
|
||||
// 160m is outside the 150m radius but inside radius+hysteresis (180m), so
|
||||
// the answer must depend on the current state or the enforcer flaps.
|
||||
EnforcementDecision at160m({required bool enforcing}) =>
|
||||
EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _latOffset(160),
|
||||
longitude: _homeLon,
|
||||
currentlyEnforcing: enforcing,
|
||||
),
|
||||
);
|
||||
|
||||
test('arriving: not yet inside', () {
|
||||
expect(at160m(enforcing: false).reason, EnforcementReason.away);
|
||||
});
|
||||
|
||||
test('leaving: still inside', () {
|
||||
expect(at160m(enforcing: true).reason, EnforcementReason.atHome);
|
||||
});
|
||||
|
||||
test('well beyond the band is away in both states', () {
|
||||
for (final enforcing in [true, false]) {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _latOffset(500),
|
||||
longitude: _homeLon,
|
||||
currentlyEnforcing: enforcing,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.away);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('fail-closed behaviour', () {
|
||||
test('unknown location enforces rather than releasing', () {
|
||||
// Losing GPS must not be a way to switch enforcement off. Matches
|
||||
// focus_daemon.sh, which defaults to focus mode when location fails.
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.locationUnknown);
|
||||
expect(decision.packagesToHide, contains('com.evil'));
|
||||
});
|
||||
|
||||
test('a policy with redacted coordinates still enforces', () {
|
||||
// The committed policy.json has no coordinates. That must not silently
|
||||
// disable the geofence into "always away".
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(withCoordinates: false),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _latOffset(5000),
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
expect(decision.packagesToHide, contains('com.evil'));
|
||||
});
|
||||
});
|
||||
|
||||
group('workout exception', () {
|
||||
test('releases YouTube without releasing anything else', () {
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 23 * 60 + 30,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
workoutActive: true,
|
||||
),
|
||||
);
|
||||
expect(decision.reason, EnforcementReason.workout);
|
||||
expect(decision.packagesToShow, contains('com.google.android.youtube'));
|
||||
expect(decision.packagesToHide, contains('com.evil'));
|
||||
expect(decision.packagesToHide, contains('com.discord'));
|
||||
});
|
||||
|
||||
test('domains map to packages, since hiding cannot target a domain', () {
|
||||
expect(
|
||||
_policy().workoutExemptPackages,
|
||||
contains('com.google.android.youtube'),
|
||||
);
|
||||
});
|
||||
|
||||
test('no workout domains means no exempt packages', () {
|
||||
final policy = FocusPolicy.fromJson({
|
||||
'schema_version': kSupportedSchemaVersion,
|
||||
'home': {
|
||||
'latitude': null,
|
||||
'longitude': null,
|
||||
'radius_m': 150.0,
|
||||
'hysteresis_m': 30.0,
|
||||
},
|
||||
'curfew': null,
|
||||
'launcher_package': null,
|
||||
'allowed_packages': <String>[],
|
||||
'night_allowed_packages': <String>[],
|
||||
'never_disable_prefixes': <String>[],
|
||||
'workout_unblock_domains': <String>[],
|
||||
'browser_packages': <String>[],
|
||||
});
|
||||
expect(policy.workoutExemptPackages, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('unhide reliability', () {
|
||||
test('packagesToShow is the full allowed set, not a delta', () {
|
||||
// The service applies packagesToShow unconditionally on every run, so a
|
||||
// missed evaluation self-repairs. A delta would require knowing what the
|
||||
// previous run did, and a reboot loses that.
|
||||
final decision = EnforcementDecision.evaluate(
|
||||
_policy(),
|
||||
EnforcementInputs(
|
||||
installedPackages: _installed,
|
||||
minutesSinceMidnight: 12 * 60,
|
||||
latitude: _homeLat,
|
||||
longitude: _homeLon,
|
||||
),
|
||||
);
|
||||
final union = {...decision.packagesToHide, ...decision.packagesToShow};
|
||||
expect(union, _installed);
|
||||
expect(
|
||||
decision.packagesToHide.intersection(decision.packagesToShow),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user