mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-13 23:40:48 +01:00
focus_owner: split EnforcementRunner.kt under the 250-line cap
Extracts four cohesive concerns into standalone files: LocationAcquisition (fix acquisition), HomeLocationStore (home read/write), PolicyPinning (VPN/DNS pin + visibility sweep), SweepablePackages (installed-package enumeration). Public API unchanged -- EnforcementRunner.best(...) deliberately stays put since EnforcementRunnerBestTest.kt calls it directly; every other public method is a one-line delegating wrapper. Verified with gradlew compileDebugKotlin + test --rerun-tasks (BUILD SUCCESSFUL, 40 tests / 0 failures, including the 6 EnforcementRunnerBestTest cases unmodified). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
parent
6efb4baf04
commit
7d7b3c71f9
@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Split EnforcementRunner.kt under the 250-line cap",
|
||||
"objective": "Bring focus_owner/android/.../EnforcementRunner.kt (564 lines) under the repo-wide 250-line cap by extracting cohesive concerns into standalone files, without changing EnforcementRunner's public API or EnforcementRunnerBestTest.kt's direct call to EnforcementRunner.best(...).",
|
||||
"acceptance_criteria": [
|
||||
"bash meta/scripts/check_file_length.sh --all no longer lists EnforcementRunner.kt.",
|
||||
"EnforcementRunner still exposes decide(), apply(decision, bridge), setHomeToCurrentLocation(), hasHomeLocation(), acquireLocation(...), and the companion best(...) with identical signatures, since MainActivity.kt and EnforcementRunnerBestTest.kt call these directly.",
|
||||
"gradlew compileDebugKotlin --rerun-tasks succeeds with no compile errors.",
|
||||
"gradlew test --rerun-tasks succeeds with the same test count (40) and zero failures as before the split, with EnforcementRunnerBestTest's 6 tests specifically still passing unmodified.",
|
||||
"No method body logic changed -- only extraction into new files plus delegate wiring."
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Adding new unit tests for EnforcementRunner's decide()/apply() (they depend on Android system services not unit-testable without instrumentation; no prior test existed for them and none is required for a pure extraction).",
|
||||
"Touching MainActivity.kt or EnforcementService.kt, whose call sites were verified but not modified.",
|
||||
"Further splitting the remaining focus_owner Kotlin/Dart files or any of the other 8 files still over the 250-line cap."
|
||||
],
|
||||
"verifier": "JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew compileDebugKotlin test --rerun-tasks (from focus_owner/android), plus bash meta/scripts/check_file_length.sh --all showing EnforcementRunner.kt no longer over cap."
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
{
|
||||
"intent": "Phase 1 of NEXT_SESSION_PROMPT.md's 250-line-cap campaign: bring focus_owner/android/.../EnforcementRunner.kt (564 lines) under the cap.",
|
||||
"scope": [
|
||||
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt",
|
||||
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt (new)",
|
||||
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt (new)",
|
||||
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt (new)",
|
||||
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt (new)"
|
||||
],
|
||||
"changes": [
|
||||
"Extracted four cohesive concerns out of EnforcementRunner into standalone files: LocationAcquisition (fix acquisition -- cached/active/stale fallback), HomeLocationStore (read/write home_location.json), PolicyPinning (VPN/DNS pin + visibility sweep applied during apply()), SweepablePackages (the installed-package enumeration used by decide())",
|
||||
"EnforcementRunner's public API is unchanged -- acquireLocation, setHomeToCurrentLocation, hasHomeLocation all still exist as delegating wrappers, since MainActivity.kt calls setHomeToCurrentLocation/hasHomeLocation directly on an EnforcementRunner instance",
|
||||
"EnforcementRunner.best(...) -- a companion object function called directly by the existing EnforcementRunnerBestTest.kt -- was deliberately NOT moved; it stays on EnforcementRunner and LocationAcquisition's internal cachedFix() calls back into it, avoiding any test-file change",
|
||||
"EnforcementRunner.ACQUIRE_TIMEOUT_MS and FRESH_WINDOW_MS constants kept as aliases pointing at LocationAcquisition's copies, to preserve the public constant names without duplicating the values",
|
||||
"EnforcementRunner.kt: 564 -> 237 lines; four new files at 198/116/94/42 lines",
|
||||
"No behavioral change: every moved method/function body is verbatim"
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "bash meta/scripts/check_file_length.sh --all",
|
||||
"result": "pass",
|
||||
"evidence": "EnforcementRunner.kt off the list; file count over cap dropped from 9 to 8"
|
||||
},
|
||||
{
|
||||
"command": "JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew compileDebugKotlin --rerun-tasks",
|
||||
"result": "pass",
|
||||
"evidence": "BUILD SUCCESSFUL, no compile errors"
|
||||
},
|
||||
{
|
||||
"command": "JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew test --rerun-tasks",
|
||||
"result": "pass",
|
||||
"evidence": "BUILD SUCCESSFUL; test-results XML shows 40 tests total (6+9+12+13), 0 failures, 0 errors, 0 skipped. EnforcementRunnerBestTest (6 tests) specifically confirms EnforcementRunner.best(...) still resolves and behaves identically after the split, since that test calls it by name and was not modified"
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"No dedicated unit test exists for EnforcementRunner.decide()/apply() themselves (they depend on Android system services -- LocationManager, PackageManager, DevicePolicyManager -- not unit-testable without instrumentation), so the decide/apply orchestration split relies on compile-time type-checking plus the unchanged public API surface, same limitation as the DevicePolicyBridge split this session",
|
||||
"PolicyPinning.kt bundles two only loosely related concerns (policy pinning + visibility sweep) in one file rather than two, to keep the file count and per-file complexity down given how much was already extracted from EnforcementRunner this session -- a future split could separate them further if PolicyPinning.kt itself grows"
|
||||
],
|
||||
"rollback": [
|
||||
"git revert this commit",
|
||||
"After rollback, re-run gradlew compileDebugKotlin test --rerun-tasks to confirm the pre-split file still compiles and the same 40 tests pass"
|
||||
]
|
||||
}
|
||||
@ -1,14 +1,7 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Calendar
|
||||
|
||||
/**
|
||||
@ -16,10 +9,16 @@ import java.util.Calendar
|
||||
*
|
||||
* Split out of [EnforcementService] so the service stays a thin lifecycle
|
||||
* wrapper and this part can be reasoned about — and eventually instrumented —
|
||||
* on its own.
|
||||
* on its own. Location-fix acquisition itself lives in [LocationAcquisition];
|
||||
* this class keeps the decide/apply orchestration and delegates to it.
|
||||
*/
|
||||
class EnforcementRunner(private val context: Context) {
|
||||
|
||||
private val locationAcquisition = LocationAcquisition(context)
|
||||
private val homeLocationStore = HomeLocationStore(context) { freshWindowMs, maxAccuracyM ->
|
||||
acquireLocation(freshWindowMs = freshWindowMs, maxAccuracyM = maxAccuracyM)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs from the current pass, carried from [decide] to [apply].
|
||||
*
|
||||
@ -47,7 +46,7 @@ class EnforcementRunner(private val context: Context) {
|
||||
return null
|
||||
}
|
||||
|
||||
val home = readHomeLocation()
|
||||
val home = homeLocationStore.readHomeLocation()
|
||||
// Acquired even when no home is configured, so the log can tell
|
||||
// "never provisioned" apart from "could not get a fix" -- previously
|
||||
// both produced an identical LOCATION_UNKNOWN with nothing to
|
||||
@ -72,7 +71,7 @@ class EnforcementRunner(private val context: Context) {
|
||||
return EnforcementDecision.evaluate(
|
||||
effective,
|
||||
EnforcementInputs(
|
||||
installedPackages = sweepablePackages(effective),
|
||||
installedPackages = sweepablePackages(context, effective),
|
||||
minutesSinceMidnight = minutes,
|
||||
// A stale fix is withheld entirely rather than passed with its
|
||||
// age attached: it must not be able to classify the phone as
|
||||
@ -115,61 +114,14 @@ class EnforcementRunner(private val context: Context) {
|
||||
val granted = bridge.grantLocationPermissions()
|
||||
bridge.enableLocation()
|
||||
|
||||
// Re-pinned every pass, unconditionally rather than tracking the
|
||||
// decision: the network filter is the layer that reaches youtube.com
|
||||
// in Firefox, in a webview, and in clients nobody has enumerated, so
|
||||
// it must not lift when the geofence says AWAY. DISALLOW_CONFIG_VPN is
|
||||
// deliberately NOT applied here -- it is a separate, later step, so a
|
||||
// misconfigured VPN stays repairable from the device.
|
||||
policy?.alwaysOnVpnPackage?.let { vpn ->
|
||||
bridge.setAlwaysOnVpn(vpn, lockdown = policy.vpnLockdown)?.let { failure ->
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "always-on VPN not pinned: $failure")
|
||||
}
|
||||
// Measured hole: `pm uninstall --user 0` removed the provider
|
||||
// while it was pinned, taking the network filter with it and
|
||||
// leaving the pin aimed at a package that no longer existed.
|
||||
bridge.setPackageUninstallBlocked(vpn, true)
|
||||
}
|
||||
// VPN/Private DNS pins + self-uninstall-block re-assertion; see
|
||||
// [pinPolicyToDevice].
|
||||
pinPolicyToDevice(policy, decision, bridge)
|
||||
|
||||
// Pinned every pass for the same reason as the VPN: this is where the
|
||||
// domain rules actually live now, so it must not depend on anyone
|
||||
// remembering to set it. DISALLOW_CONFIG_PRIVATE_DNS is applied
|
||||
// separately, only once the host is confirmed answering.
|
||||
policy?.privateDnsHost?.let { host ->
|
||||
bridge.setPrivateDns(host)?.let { failure ->
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "private DNS not pinned: $failure")
|
||||
}
|
||||
}
|
||||
|
||||
// Re-asserted every pass rather than set once at provisioning time, so
|
||||
// the protection self-heals: `adb uninstall` while enforcing is the one
|
||||
// route that strands device ownership with no holder, and a factory
|
||||
// reset is then the only exit. Tracking the decision means going away
|
||||
// from home lifts it, which keeps the app removable in exactly the
|
||||
// state where enforcement is already off.
|
||||
bridge.setSelfUninstallBlocked(decision.isEnforcing)
|
||||
|
||||
// Named rather than counted, so the log can answer "what changed on
|
||||
// the pass that broke it" instead of only "how many".
|
||||
val restoredNow = mutableListOf<String>()
|
||||
val hiddenNow = mutableListOf<String>()
|
||||
for (pkg in decision.packagesToShow) {
|
||||
if (bridge.isApplicationHidden(pkg)) {
|
||||
if (bridge.setApplicationHidden(pkg, false)) restoredNow.add(pkg)
|
||||
}
|
||||
}
|
||||
for (pkg in decision.packagesToHide) {
|
||||
// The exporter injects this package into both allowlists, so the
|
||||
// decision layer should never propose it. Kept as a belt-and-braces
|
||||
// check because it guards the unrecoverable case: hiding the
|
||||
// enforcer takes the escape hatch and the trigger with it, and a
|
||||
// policy asset rendered before that fix -- or hand-edited -- would
|
||||
// otherwise hide the app on the first enforcing pass.
|
||||
if (pkg == context.packageName) continue
|
||||
if (!bridge.isApplicationHidden(pkg)) {
|
||||
if (bridge.setApplicationHidden(pkg, true)) hiddenNow.add(pkg)
|
||||
}
|
||||
}
|
||||
// Show/hide deltas; see [applyVisibilitySweep]. Show is applied before
|
||||
// hide inside it for the same interrupted-pass reason documented there.
|
||||
val (hiddenNow, restoredNow) =
|
||||
applyVisibilitySweep(decision, bridge, selfPackage = context.packageName)
|
||||
rememberEnforcing(decision.isEnforcing)
|
||||
// Only recorded when the fence actually returned an answer. A pass
|
||||
// with no usable fix must leave the previous verdict standing rather
|
||||
@ -185,7 +137,7 @@ class EnforcementRunner(private val context: Context) {
|
||||
decision = decision,
|
||||
fix = pass?.fix,
|
||||
policy = pass?.policy ?: policy,
|
||||
homeConfigured = pass?.homeConfigured ?: hasHomeLocation(),
|
||||
homeConfigured = pass?.homeConfigured ?: homeLocationStore.hasHomeLocation(),
|
||||
permissions = granted,
|
||||
hidDelta = hiddenNow,
|
||||
restoredDelta = restoredNow,
|
||||
@ -198,271 +150,18 @@ class EnforcementRunner(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Packages the allowlist sweep considers: every third-party app, plus the
|
||||
* system apps the policy names in `blockable_system_packages`.
|
||||
*
|
||||
* System apps are default-deny rather than filtered out entirely, because
|
||||
* the apps most worth blocking are preinstalled — YouTube ships at
|
||||
* `/product/app/YouTube` with `FLAG_SYSTEM`, and so does Chrome — while
|
||||
* most of the rest are platform components. Measured on this device: 320
|
||||
* system packages, of which 243 match no allowlist entry and no
|
||||
* `never_disable_prefix`, including `com.android.cellbroadcastreceiver`
|
||||
* (emergency alerts) and `com.android.devicelockcontroller`. Sweeping
|
||||
* those under Device Owner risks an unrecoverable phone, so eligibility is
|
||||
* opt-in per package.
|
||||
*
|
||||
* [PackageManager.MATCH_UNINSTALLED_PACKAGES] is required, not optional: a
|
||||
* package hidden by `setApplicationHidden` is dropped from the default
|
||||
* enumeration entirely, even though it remains `installed=true`. Querying
|
||||
* with flags `0` therefore returns a set that can never contain anything
|
||||
* this app has already hidden — so [EnforcementDecision.packagesToShow]
|
||||
* would come back empty and nothing could ever be unhidden again. Measured
|
||||
* on device: after a pass hid three apps, the following pass reported
|
||||
* `hid 0, restored 0` and left them hidden permanently.
|
||||
*/
|
||||
private fun sweepablePackages(policy: FocusPolicy): Set<String> {
|
||||
val pm = context.packageManager
|
||||
return pm.getInstalledApplications(PackageManager.MATCH_UNINSTALLED_PACKAGES)
|
||||
.filter { info ->
|
||||
val isSystem =
|
||||
(info.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
|
||||
!isSystem || info.packageName in policy.blockableSystemPackages
|
||||
}
|
||||
.map { it.packageName }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current location as home.
|
||||
*
|
||||
* The app has to do this itself. `push_home_location.sh` stages the file
|
||||
* through `run-as`, which returns "package not debuggable" on the release
|
||||
* build device owner requires -- and making the build debuggable to fix
|
||||
* that would let anyone with adb edit this app's state, which is the
|
||||
* bypass the whole design exists to close.
|
||||
*
|
||||
* Deliberately not an exported intent or receiver: anything that can set
|
||||
* home from outside the app can set it to somewhere else, and the geofence
|
||||
* would then report AWAY forever with enforcement off.
|
||||
*
|
||||
* @return null on success, or a message describing why it failed.
|
||||
*/
|
||||
fun setHomeToCurrentLocation(): String? {
|
||||
// Its own thresholds, so a retry actually requests a new fix rather
|
||||
// than being handed the same cached one it just rejected.
|
||||
val fix = acquireLocation(
|
||||
freshWindowMs = SET_HOME_MAX_AGE_MS,
|
||||
maxAccuracyM = SET_HOME_MAX_ACCURACY_M,
|
||||
)
|
||||
if (!fix.hasCoordinates) {
|
||||
return when (fix.outcome) {
|
||||
FixOutcome.NO_PERMISSION -> "location permission is not granted"
|
||||
FixOutcome.NO_PROVIDER -> "location services are off - turn them on"
|
||||
else -> "no location fix available - go outside or open a maps app, then retry"
|
||||
}
|
||||
}
|
||||
// A wrong home is worse than no home: it silently inverts the geofence
|
||||
// everywhere, forever, and nothing on screen would say so. So this
|
||||
// refuses anything but a fix taken now -- unlike an enforcement pass,
|
||||
// there is a user standing here who can simply retry.
|
||||
if (!fix.isFresh(SET_HOME_MAX_AGE_MS)) {
|
||||
return "only a stale fix (${fix.ageMs / 1000}s old) - wait a moment and retry"
|
||||
}
|
||||
// Unknown accuracy reads as unacceptable, matching `best` and the
|
||||
// cached-fix gate. The opposite reading would let a fix of unknown
|
||||
// precision anchor home, which silently inverts the geofence
|
||||
// everywhere and forever -- and unlike an enforcement pass, there is a
|
||||
// user present who can simply step outside and retry.
|
||||
val accuracy = fix.accuracyM ?: Double.MAX_VALUE
|
||||
if (accuracy > SET_HOME_MAX_ACCURACY_M) {
|
||||
val reported = fix.accuracyM?.toInt()?.let { "$it m" } ?: "unknown"
|
||||
return "fix accuracy is $reported (need under " +
|
||||
"${SET_HOME_MAX_ACCURACY_M.toInt()} m) - " +
|
||||
"go outside for a better signal, then retry"
|
||||
}
|
||||
return try {
|
||||
val json = JSONObject()
|
||||
.put("latitude", fix.latitude)
|
||||
.put("longitude", fix.longitude)
|
||||
File(context.filesDir, "home_location.json").writeText(json.toString())
|
||||
// Never log the coordinates: this is the user's home address and
|
||||
// logcat is world-readable to anyone with adb.
|
||||
Log.i(FocusDeviceAdminReceiver.TAG, "home location written")
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e(FocusDeviceAdminReceiver.TAG, "could not write home location", e)
|
||||
e.message ?: e::class.java.simpleName
|
||||
}
|
||||
}
|
||||
/** Writes the current location as home. See [HomeLocationStore.setHomeToCurrentLocation]. */
|
||||
fun setHomeToCurrentLocation(): String? = homeLocationStore.setHomeToCurrentLocation()
|
||||
|
||||
/** Whether home coordinates are provisioned, without revealing them. */
|
||||
fun hasHomeLocation(): Boolean = readHomeLocation() != null
|
||||
fun hasHomeLocation(): Boolean = homeLocationStore.hasHomeLocation()
|
||||
|
||||
/** Coordinates provisioned by `push_home_location.sh`, or null. */
|
||||
private fun readHomeLocation(): Pair<Double, Double>? {
|
||||
val file = File(context.filesDir, "home_location.json")
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
val json = JSONObject(file.readText())
|
||||
val lat = json.getDouble("latitude")
|
||||
val lon = json.getDouble("longitude")
|
||||
if (lat !in -90.0..90.0 || lon !in -180.0..180.0) null else lat to lon
|
||||
} catch (e: Exception) {
|
||||
// Unreadable coordinates read as "no home", which the decision
|
||||
// layer treats as location-unknown and therefore fails closed.
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "home_location.json unreadable", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires a fix, preferring a fresh one over whatever happens to be cached.
|
||||
*
|
||||
* The old implementation only read [LocationManager.getLastKnownLocation]
|
||||
* and took the newest across providers, with no age check and no request
|
||||
* of its own. That produced both reported failures: at the office with no
|
||||
* navigation app running the cache was empty, so the pass fell through to
|
||||
* LOCATION_UNKNOWN and blocked everything; and a cached fix from hours ago
|
||||
* would have classified the office as home just as readily.
|
||||
*
|
||||
* Bounded by [ACQUIRE_TIMEOUT_MS] so a pass can never hang waiting for a
|
||||
* fix that is not coming. On expiry it falls back to the cache, tagged so
|
||||
* the decision layer and the log can both say the acquisition timed out
|
||||
* rather than silently presenting a stale fix as current.
|
||||
*/
|
||||
/** Acquires a fix. See [LocationAcquisition.acquireLocation]. */
|
||||
fun acquireLocation(
|
||||
timeoutMs: Long = ACQUIRE_TIMEOUT_MS,
|
||||
freshWindowMs: Long = FRESH_WINDOW_MS,
|
||||
timeoutMs: Long = LocationAcquisition.ACQUIRE_TIMEOUT_MS,
|
||||
freshWindowMs: Long = LocationAcquisition.FRESH_WINDOW_MS,
|
||||
maxAccuracyM: Double? = null,
|
||||
): LocationFix {
|
||||
val fine = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val coarse = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!fine && !coarse) return LocationFix.unavailable(FixOutcome.NO_PERMISSION)
|
||||
val manager = context.getSystemService(LocationManager::class.java)
|
||||
?: return LocationFix.unavailable(FixOutcome.NO_PROVIDER)
|
||||
|
||||
// A recent cached fix is both cheaper and no less accurate than one we
|
||||
// would request right now, so it short-circuits acquisition entirely.
|
||||
// This is the common case whenever any app has used location lately.
|
||||
//
|
||||
// The caller's own thresholds are applied HERE, not only after the
|
||||
// fact: "Set home" demands a fix under 2 minutes old and accurate to
|
||||
// 100 m, and if this returned a 5-minute-old cache regardless, every
|
||||
// retry would hand back the identical rejected fix and the button
|
||||
// could never succeed until the cache aged out on its own.
|
||||
cachedFix(manager, freshWindowMs)
|
||||
?.takeIf { it.ageMs <= freshWindowMs }
|
||||
// Unknown accuracy is treated as unacceptable, matching `best`.
|
||||
// The alternative reading (0.0, i.e. perfect) would let a fix of
|
||||
// unknown precision anchor home, which silently inverts the
|
||||
// geofence everywhere and forever.
|
||||
?.takeIf {
|
||||
maxAccuracyM == null ||
|
||||
(it.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM
|
||||
}
|
||||
?.let { return it.copy(outcome = FixOutcome.CACHED_FRESH) }
|
||||
|
||||
val active = runCatching { requestCurrentFix(manager, timeoutMs) }
|
||||
.getOrElse { error ->
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "location request failed", error)
|
||||
null
|
||||
}
|
||||
// The caller's accuracy bound applies to a freshly requested fix too,
|
||||
// not just the cache: a brand-new fix that cannot say where it is to
|
||||
// within maxAccuracyM answers the question no better than an old one.
|
||||
if (active != null &&
|
||||
(maxAccuracyM == null || (active.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM)
|
||||
) {
|
||||
return active.copy(outcome = FixOutcome.ACTIVE_OK)
|
||||
}
|
||||
|
||||
// Nothing fresh. Return the stale cache anyway, labelled: the decision
|
||||
// layer decides what to do with it, and the log needs to say what was
|
||||
// actually available rather than reporting a bare "no fix".
|
||||
// Window passed through so `best` takes its no-fresh-candidates branch
|
||||
// and reports the NEWEST stale fix. Without it every candidate counts
|
||||
// as fresh and the most accurate one wins, which does not change the
|
||||
// decision (the caller rejects anything stale either way) but makes
|
||||
// the log and the set-home error quote a six-hour-old fix when a
|
||||
// forty-minute-old one was available.
|
||||
val stale = cachedFix(manager, freshWindowMs)
|
||||
return stale?.copy(outcome = FixOutcome.TIMEOUT)
|
||||
?: LocationFix.unavailable(FixOutcome.TIMEOUT)
|
||||
}
|
||||
|
||||
/** The newest cached fix across enabled providers, or null. */
|
||||
private fun cachedFix(
|
||||
manager: LocationManager,
|
||||
freshWindowMs: Long = Long.MAX_VALUE,
|
||||
): LocationFix? = try {
|
||||
manager.getProviders(true)
|
||||
.asSequence()
|
||||
.mapNotNull { provider ->
|
||||
manager.getLastKnownLocation(provider)?.let { provider to it }
|
||||
}
|
||||
.map { (provider, location) -> location.toFix(provider) }
|
||||
.toList()
|
||||
.let { best(it, freshWindowMs) }
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "location denied", e)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the platform for a fix now, blocking up to [timeoutMs].
|
||||
*
|
||||
* Uses [LocationManager] rather than the fused Play Services client on
|
||||
* purpose: a device owner should not depend on Play Services, which is
|
||||
* itself inside this app's blast radius.
|
||||
*
|
||||
* The caller must not be on the main thread -- [EnforcementService] runs
|
||||
* the pass on a background executor for exactly this reason.
|
||||
*/
|
||||
private fun requestCurrentFix(manager: LocationManager, timeoutMs: Long): LocationFix? {
|
||||
val provider = PROVIDER_PREFERENCE.firstOrNull { manager.isProviderEnabled(it) }
|
||||
?: return null
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
val holder = java.util.concurrent.atomic.AtomicReference<Location?>()
|
||||
val signal = android.os.CancellationSignal()
|
||||
// The main executor rather than a fresh one per call: the consumer
|
||||
// only stores a reference and counts down, so it does no work worth a
|
||||
// thread, and creating one per pass leaked ~96 threads a day.
|
||||
manager.getCurrentLocation(
|
||||
provider,
|
||||
signal,
|
||||
androidx.core.content.ContextCompat.getMainExecutor(context),
|
||||
) { location ->
|
||||
holder.set(location)
|
||||
latch.countDown()
|
||||
}
|
||||
val answered = latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
if (!answered) {
|
||||
// Stop the provider working on a request nobody is waiting for.
|
||||
runCatching { signal.cancel() }
|
||||
return null
|
||||
}
|
||||
return holder.get()?.toFix(provider)
|
||||
}
|
||||
|
||||
/** Converts a platform [Location] into the fix the decision layer sees. */
|
||||
private fun Location.toFix(provider: String): LocationFix = LocationFix(
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
// elapsedRealtimeNanos is monotonic, so a clock change cannot make a
|
||||
// fix look fresher than it is.
|
||||
ageMs = (android.os.SystemClock.elapsedRealtimeNanos() - elapsedRealtimeNanos) /
|
||||
1_000_000L,
|
||||
accuracyM = if (hasAccuracy()) accuracy.toDouble() else null,
|
||||
provider = provider,
|
||||
outcome = FixOutcome.CACHED_FRESH,
|
||||
)
|
||||
): LocationFix = locationAcquisition.acquireLocation(timeoutMs, freshWindowMs, maxAccuracyM)
|
||||
|
||||
private fun prefs() =
|
||||
context.getSharedPreferences("enforcement", Context.MODE_PRIVATE)
|
||||
@ -492,7 +191,7 @@ class EnforcementRunner(private val context: Context) {
|
||||
private const val KEY_WAS_INSIDE = "was_inside_fence"
|
||||
|
||||
/** Longest a pass will wait for a fresh fix before falling back. */
|
||||
const val ACQUIRE_TIMEOUT_MS = 20_000L
|
||||
const val ACQUIRE_TIMEOUT_MS = LocationAcquisition.ACQUIRE_TIMEOUT_MS
|
||||
|
||||
/**
|
||||
* A fix at most this old is trusted as describing "now".
|
||||
@ -503,20 +202,7 @@ class EnforcementRunner(private val context: Context) {
|
||||
* LOCATION_UNKNOWN and blocking everything -- turning the fail-closed
|
||||
* default from a safety net into the normal case.
|
||||
*/
|
||||
const val FRESH_WINDOW_MS = 30L * 60L * 1000L
|
||||
|
||||
/**
|
||||
* Much tighter than [FRESH_WINDOW_MS], for anchoring home.
|
||||
*
|
||||
* An enforcement pass tolerates an older fix because it re-runs every
|
||||
* 15 minutes and a wrong call self-corrects. Home is written once and
|
||||
* silently inverts the geofence everywhere if it is wrong, and there
|
||||
* is a user present who can just retry -- so it demands a fresh fix.
|
||||
*/
|
||||
const val SET_HOME_MAX_AGE_MS = 2L * 60L * 1000L
|
||||
|
||||
/** Refuse to anchor home to a fix vaguer than the fence is wide. */
|
||||
const val SET_HOME_MAX_ACCURACY_M = 100.0
|
||||
const val FRESH_WINDOW_MS = LocationAcquisition.FRESH_WINDOW_MS
|
||||
|
||||
/**
|
||||
* Picks the fix best able to answer "is the phone inside the fence".
|
||||
@ -547,18 +233,5 @@ class EnforcementRunner(private val context: Context) {
|
||||
.thenBy { it.ageMs },
|
||||
).first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers tried in order when requesting a fix.
|
||||
*
|
||||
* GPS first because it is the only one accurate enough for a 150 m
|
||||
* fence; fused and network are fallbacks that at least distinguish
|
||||
* "same city" from "10 km away".
|
||||
*/
|
||||
private val PROVIDER_PREFERENCE = listOf(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.FUSED_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The home-location read/write half of [EnforcementRunner].
|
||||
*
|
||||
* Split out to keep [EnforcementRunner] under the 250-line cap. Owns writing
|
||||
* the current fix as home and reading it back, while [EnforcementRunner]
|
||||
* keeps the decide/apply orchestration and delegates to an instance of this
|
||||
* class.
|
||||
*/
|
||||
internal class HomeLocationStore(
|
||||
private val context: Context,
|
||||
private val acquireLocation: (freshWindowMs: Long, maxAccuracyM: Double?) -> LocationFix,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Writes the current location as home.
|
||||
*
|
||||
* The app has to do this itself. `push_home_location.sh` stages the file
|
||||
* through `run-as`, which returns "package not debuggable" on the release
|
||||
* build device owner requires -- and making the build debuggable to fix
|
||||
* that would let anyone with adb edit this app's state, which is the
|
||||
* bypass the whole design exists to close.
|
||||
*
|
||||
* Deliberately not an exported intent or receiver: anything that can set
|
||||
* home from outside the app can set it to somewhere else, and the geofence
|
||||
* would then report AWAY forever with enforcement off.
|
||||
*
|
||||
* @return null on success, or a message describing why it failed.
|
||||
*/
|
||||
fun setHomeToCurrentLocation(): String? {
|
||||
// Its own thresholds, so a retry actually requests a new fix rather
|
||||
// than being handed the same cached one it just rejected.
|
||||
val fix = acquireLocation(SET_HOME_MAX_AGE_MS, SET_HOME_MAX_ACCURACY_M)
|
||||
if (!fix.hasCoordinates) {
|
||||
return when (fix.outcome) {
|
||||
FixOutcome.NO_PERMISSION -> "location permission is not granted"
|
||||
FixOutcome.NO_PROVIDER -> "location services are off - turn them on"
|
||||
else -> "no location fix available - go outside or open a maps app, then retry"
|
||||
}
|
||||
}
|
||||
// A wrong home is worse than no home: it silently inverts the geofence
|
||||
// everywhere, forever, and nothing on screen would say so. So this
|
||||
// refuses anything but a fix taken now -- unlike an enforcement pass,
|
||||
// there is a user standing here who can simply retry.
|
||||
if (!fix.isFresh(SET_HOME_MAX_AGE_MS)) {
|
||||
return "only a stale fix (${fix.ageMs / 1000}s old) - wait a moment and retry"
|
||||
}
|
||||
// Unknown accuracy reads as unacceptable, matching `best` and the
|
||||
// cached-fix gate. The opposite reading would let a fix of unknown
|
||||
// precision anchor home, which silently inverts the geofence
|
||||
// everywhere and forever -- and unlike an enforcement pass, there is a
|
||||
// user present who can simply step outside and retry.
|
||||
val accuracy = fix.accuracyM ?: Double.MAX_VALUE
|
||||
if (accuracy > SET_HOME_MAX_ACCURACY_M) {
|
||||
val reported = fix.accuracyM?.toInt()?.let { "$it m" } ?: "unknown"
|
||||
return "fix accuracy is $reported (need under " +
|
||||
"${SET_HOME_MAX_ACCURACY_M.toInt()} m) - " +
|
||||
"go outside for a better signal, then retry"
|
||||
}
|
||||
return try {
|
||||
val json = JSONObject()
|
||||
.put("latitude", fix.latitude)
|
||||
.put("longitude", fix.longitude)
|
||||
File(context.filesDir, "home_location.json").writeText(json.toString())
|
||||
// Never log the coordinates: this is the user's home address and
|
||||
// logcat is world-readable to anyone with adb.
|
||||
Log.i(FocusDeviceAdminReceiver.TAG, "home location written")
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e(FocusDeviceAdminReceiver.TAG, "could not write home location", e)
|
||||
e.message ?: e::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether home coordinates are provisioned, without revealing them. */
|
||||
fun hasHomeLocation(): Boolean = readHomeLocation() != null
|
||||
|
||||
/** Coordinates provisioned by `push_home_location.sh`, or null. */
|
||||
fun readHomeLocation(): Pair<Double, Double>? {
|
||||
val file = File(context.filesDir, "home_location.json")
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
val json = JSONObject(file.readText())
|
||||
val lat = json.getDouble("latitude")
|
||||
val lon = json.getDouble("longitude")
|
||||
if (lat !in -90.0..90.0 || lon !in -180.0..180.0) null else lat to lon
|
||||
} catch (e: Exception) {
|
||||
// Unreadable coordinates read as "no home", which the decision
|
||||
// layer treats as location-unknown and therefore fails closed.
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "home_location.json unreadable", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Much tighter than [LocationAcquisition.FRESH_WINDOW_MS], for
|
||||
* anchoring home.
|
||||
*
|
||||
* An enforcement pass tolerates an older fix because it re-runs every
|
||||
* 15 minutes and a wrong call self-corrects. Home is written once and
|
||||
* silently inverts the geofence everywhere if it is wrong, and there
|
||||
* is a user present who can just retry -- so it demands a fresh fix.
|
||||
*/
|
||||
const val SET_HOME_MAX_AGE_MS = 2L * 60L * 1000L
|
||||
|
||||
/** Refuse to anchor home to a fix vaguer than the fence is wide. */
|
||||
const val SET_HOME_MAX_ACCURACY_M = 100.0
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,198 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* The location-fix acquisition half of [EnforcementRunner].
|
||||
*
|
||||
* Split out to keep [EnforcementRunner] under the 250-line cap. Owns getting
|
||||
* a [LocationFix] out of [LocationManager] -- preferring a fresh cache,
|
||||
* falling back to an active request, then to a stale cache -- while
|
||||
* [EnforcementRunner] keeps the decide/apply orchestration and delegates
|
||||
* acquisition to an instance of this class.
|
||||
*/
|
||||
internal class LocationAcquisition(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Acquires a fix, preferring a fresh one over whatever happens to be cached.
|
||||
*
|
||||
* The old implementation only read [LocationManager.getLastKnownLocation]
|
||||
* and took the newest across providers, with no age check and no request
|
||||
* of its own. That produced both reported failures: at the office with no
|
||||
* navigation app running the cache was empty, so the pass fell through to
|
||||
* LOCATION_UNKNOWN and blocked everything; and a cached fix from hours ago
|
||||
* would have classified the office as home just as readily.
|
||||
*
|
||||
* Bounded by [ACQUIRE_TIMEOUT_MS] so a pass can never hang waiting for a
|
||||
* fix that is not coming. On expiry it falls back to the cache, tagged so
|
||||
* the decision layer and the log can both say the acquisition timed out
|
||||
* rather than silently presenting a stale fix as current.
|
||||
*/
|
||||
fun acquireLocation(
|
||||
timeoutMs: Long = ACQUIRE_TIMEOUT_MS,
|
||||
freshWindowMs: Long = FRESH_WINDOW_MS,
|
||||
maxAccuracyM: Double? = null,
|
||||
): LocationFix {
|
||||
val fine = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val coarse = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (!fine && !coarse) return LocationFix.unavailable(FixOutcome.NO_PERMISSION)
|
||||
val manager = context.getSystemService(LocationManager::class.java)
|
||||
?: return LocationFix.unavailable(FixOutcome.NO_PROVIDER)
|
||||
|
||||
// A recent cached fix is both cheaper and no less accurate than one we
|
||||
// would request right now, so it short-circuits acquisition entirely.
|
||||
// This is the common case whenever any app has used location lately.
|
||||
//
|
||||
// The caller's own thresholds are applied HERE, not only after the
|
||||
// fact: "Set home" demands a fix under 2 minutes old and accurate to
|
||||
// 100 m, and if this returned a 5-minute-old cache regardless, every
|
||||
// retry would hand back the identical rejected fix and the button
|
||||
// could never succeed until the cache aged out on its own.
|
||||
cachedFix(manager, freshWindowMs)
|
||||
?.takeIf { it.ageMs <= freshWindowMs }
|
||||
// Unknown accuracy is treated as unacceptable, matching `best`.
|
||||
// The alternative reading (0.0, i.e. perfect) would let a fix of
|
||||
// unknown precision anchor home, which silently inverts the
|
||||
// geofence everywhere and forever.
|
||||
?.takeIf {
|
||||
maxAccuracyM == null ||
|
||||
(it.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM
|
||||
}
|
||||
?.let { return it.copy(outcome = FixOutcome.CACHED_FRESH) }
|
||||
|
||||
val active = runCatching { requestCurrentFix(manager, timeoutMs) }
|
||||
.getOrElse { error ->
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "location request failed", error)
|
||||
null
|
||||
}
|
||||
// The caller's accuracy bound applies to a freshly requested fix too,
|
||||
// not just the cache: a brand-new fix that cannot say where it is to
|
||||
// within maxAccuracyM answers the question no better than an old one.
|
||||
if (active != null &&
|
||||
(maxAccuracyM == null || (active.accuracyM ?: Double.MAX_VALUE) <= maxAccuracyM)
|
||||
) {
|
||||
return active.copy(outcome = FixOutcome.ACTIVE_OK)
|
||||
}
|
||||
|
||||
// Nothing fresh. Return the stale cache anyway, labelled: the decision
|
||||
// layer decides what to do with it, and the log needs to say what was
|
||||
// actually available rather than reporting a bare "no fix".
|
||||
// Window passed through so `best` takes its no-fresh-candidates branch
|
||||
// and reports the NEWEST stale fix. Without it every candidate counts
|
||||
// as fresh and the most accurate one wins, which does not change the
|
||||
// decision (the caller rejects anything stale either way) but makes
|
||||
// the log and the set-home error quote a six-hour-old fix when a
|
||||
// forty-minute-old one was available.
|
||||
val stale = cachedFix(manager, freshWindowMs)
|
||||
return stale?.copy(outcome = FixOutcome.TIMEOUT)
|
||||
?: LocationFix.unavailable(FixOutcome.TIMEOUT)
|
||||
}
|
||||
|
||||
/** The newest cached fix across enabled providers, or null. */
|
||||
private fun cachedFix(
|
||||
manager: LocationManager,
|
||||
freshWindowMs: Long = Long.MAX_VALUE,
|
||||
): LocationFix? = try {
|
||||
manager.getProviders(true)
|
||||
.asSequence()
|
||||
.mapNotNull { provider ->
|
||||
manager.getLastKnownLocation(provider)?.let { provider to it }
|
||||
}
|
||||
.map { (provider, location) -> location.toFix(provider) }
|
||||
.toList()
|
||||
.let { EnforcementRunner.best(it, freshWindowMs) }
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "location denied", e)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the platform for a fix now, blocking up to [timeoutMs].
|
||||
*
|
||||
* Uses [LocationManager] rather than the fused Play Services client on
|
||||
* purpose: a device owner should not depend on Play Services, which is
|
||||
* itself inside this app's blast radius.
|
||||
*
|
||||
* The caller must not be on the main thread -- [EnforcementService] runs
|
||||
* the pass on a background executor for exactly this reason.
|
||||
*/
|
||||
private fun requestCurrentFix(manager: LocationManager, timeoutMs: Long): LocationFix? {
|
||||
val provider = PROVIDER_PREFERENCE.firstOrNull { manager.isProviderEnabled(it) }
|
||||
?: return null
|
||||
val latch = java.util.concurrent.CountDownLatch(1)
|
||||
val holder = java.util.concurrent.atomic.AtomicReference<Location?>()
|
||||
val signal = android.os.CancellationSignal()
|
||||
// The main executor rather than a fresh one per call: the consumer
|
||||
// only stores a reference and counts down, so it does no work worth a
|
||||
// thread, and creating one per pass leaked ~96 threads a day.
|
||||
manager.getCurrentLocation(
|
||||
provider,
|
||||
signal,
|
||||
androidx.core.content.ContextCompat.getMainExecutor(context),
|
||||
) { location ->
|
||||
holder.set(location)
|
||||
latch.countDown()
|
||||
}
|
||||
val answered = latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
if (!answered) {
|
||||
// Stop the provider working on a request nobody is waiting for.
|
||||
runCatching { signal.cancel() }
|
||||
return null
|
||||
}
|
||||
return holder.get()?.toFix(provider)
|
||||
}
|
||||
|
||||
/** Converts a platform [Location] into the fix the decision layer sees. */
|
||||
private fun Location.toFix(provider: String): LocationFix = LocationFix(
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
// elapsedRealtimeNanos is monotonic, so a clock change cannot make a
|
||||
// fix look fresher than it is.
|
||||
ageMs = (android.os.SystemClock.elapsedRealtimeNanos() - elapsedRealtimeNanos) /
|
||||
1_000_000L,
|
||||
accuracyM = if (hasAccuracy()) accuracy.toDouble() else null,
|
||||
provider = provider,
|
||||
outcome = FixOutcome.CACHED_FRESH,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** Longest a pass will wait for a fresh fix before falling back. */
|
||||
const val ACQUIRE_TIMEOUT_MS = 20_000L
|
||||
|
||||
/**
|
||||
* A fix at most this old is trusted as describing "now".
|
||||
*
|
||||
* Deliberately longer than the 15-minute enforcement cadence. A window
|
||||
* below the cadence would mark the newest available cache stale on
|
||||
* every pass of a phone sitting still, sending every pass to
|
||||
* LOCATION_UNKNOWN and blocking everything -- turning the fail-closed
|
||||
* default from a safety net into the normal case.
|
||||
*/
|
||||
const val FRESH_WINDOW_MS = 30L * 60L * 1000L
|
||||
|
||||
/**
|
||||
* Providers tried in order when requesting a fix.
|
||||
*
|
||||
* GPS first because it is the only one accurate enough for a 150 m
|
||||
* fence; fused and network are fallbacks that at least distinguish
|
||||
* "same city" from "10 km away".
|
||||
*/
|
||||
private val PROVIDER_PREFERENCE = listOf(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.FUSED_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Re-pins the network-lockdown and self-uninstall-block policy onto the
|
||||
* device every pass.
|
||||
*
|
||||
* Split out of [EnforcementRunner.apply] to keep that file under the 250-line
|
||||
* cap. Called with the freshly-loaded [policy] (which may be null if it
|
||||
* failed to load -- the VPN/DNS pins are skipped in that case, but the
|
||||
* self-uninstall-block re-assertion still runs since it depends only on
|
||||
* [decision], not on the policy).
|
||||
*/
|
||||
internal fun pinPolicyToDevice(
|
||||
policy: FocusPolicy?,
|
||||
decision: EnforcementDecision,
|
||||
bridge: DevicePolicyBridge,
|
||||
) {
|
||||
// Re-pinned every pass, unconditionally rather than tracking the
|
||||
// decision: the network filter is the layer that reaches youtube.com
|
||||
// in Firefox, in a webview, and in clients nobody has enumerated, so
|
||||
// it must not lift when the geofence says AWAY. DISALLOW_CONFIG_VPN is
|
||||
// deliberately NOT applied here -- it is a separate, later step, so a
|
||||
// misconfigured VPN stays repairable from the device.
|
||||
policy?.alwaysOnVpnPackage?.let { vpn ->
|
||||
bridge.setAlwaysOnVpn(vpn, lockdown = policy.vpnLockdown)?.let { failure ->
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "always-on VPN not pinned: $failure")
|
||||
}
|
||||
// Measured hole: `pm uninstall --user 0` removed the provider
|
||||
// while it was pinned, taking the network filter with it and
|
||||
// leaving the pin aimed at a package that no longer existed.
|
||||
bridge.setPackageUninstallBlocked(vpn, true)
|
||||
}
|
||||
|
||||
// Pinned every pass for the same reason as the VPN: this is where the
|
||||
// domain rules actually live now, so it must not depend on anyone
|
||||
// remembering to set it. DISALLOW_CONFIG_PRIVATE_DNS is applied
|
||||
// separately, only once the host is confirmed answering.
|
||||
policy?.privateDnsHost?.let { host ->
|
||||
bridge.setPrivateDns(host)?.let { failure ->
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "private DNS not pinned: $failure")
|
||||
}
|
||||
}
|
||||
|
||||
// Re-asserted every pass rather than set once at provisioning time, so
|
||||
// the protection self-heals: `adb uninstall` while enforcing is the one
|
||||
// route that strands device ownership with no holder, and a factory
|
||||
// reset is then the only exit. Tracking the decision means going away
|
||||
// from home lifts it, which keeps the app removable in exactly the
|
||||
// state where enforcement is already off.
|
||||
bridge.setSelfUninstallBlocked(decision.isEnforcing)
|
||||
}
|
||||
|
||||
/** The packages actually changed by [applyVisibilitySweep], named not counted. */
|
||||
internal data class VisibilitySweepResult(
|
||||
val hiddenNow: List<String>,
|
||||
val restoredNow: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Applies the decision's show/hide deltas to the device.
|
||||
*
|
||||
* Split out of [EnforcementRunner.apply] to keep that file under the 250-line
|
||||
* cap. Show is applied before hide by the caller's ordering of these two
|
||||
* loops -- if a pass is interrupted part-way, the half that has run is the
|
||||
* half that restores access rather than the half that removes it.
|
||||
*/
|
||||
internal fun applyVisibilitySweep(
|
||||
decision: EnforcementDecision,
|
||||
bridge: DevicePolicyBridge,
|
||||
selfPackage: String,
|
||||
): VisibilitySweepResult {
|
||||
val restoredNow = mutableListOf<String>()
|
||||
val hiddenNow = mutableListOf<String>()
|
||||
for (pkg in decision.packagesToShow) {
|
||||
if (bridge.isApplicationHidden(pkg)) {
|
||||
if (bridge.setApplicationHidden(pkg, false)) restoredNow.add(pkg)
|
||||
}
|
||||
}
|
||||
for (pkg in decision.packagesToHide) {
|
||||
// The exporter injects this package into both allowlists, so the
|
||||
// decision layer should never propose it. Kept as a belt-and-braces
|
||||
// check because it guards the unrecoverable case: hiding the
|
||||
// enforcer takes the escape hatch and the trigger with it, and a
|
||||
// policy asset rendered before that fix -- or hand-edited -- would
|
||||
// otherwise hide the app on the first enforcing pass.
|
||||
if (pkg == selfPackage) continue
|
||||
if (!bridge.isApplicationHidden(pkg)) {
|
||||
if (bridge.setApplicationHidden(pkg, true)) hiddenNow.add(pkg)
|
||||
}
|
||||
}
|
||||
return VisibilitySweepResult(hiddenNow = hiddenNow, restoredNow = restoredNow)
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
/**
|
||||
* Packages the allowlist sweep considers: every third-party app, plus the
|
||||
* system apps the policy names in `blockable_system_packages`.
|
||||
*
|
||||
* Split out of [EnforcementRunner.decide] to keep that file under the
|
||||
* 250-line cap.
|
||||
*
|
||||
* System apps are default-deny rather than filtered out entirely, because
|
||||
* the apps most worth blocking are preinstalled — YouTube ships at
|
||||
* `/product/app/YouTube` with `FLAG_SYSTEM`, and so does Chrome — while
|
||||
* most of the rest are platform components. Measured on this device: 320
|
||||
* system packages, of which 243 match no allowlist entry and no
|
||||
* `never_disable_prefix`, including `com.android.cellbroadcastreceiver`
|
||||
* (emergency alerts) and `com.android.devicelockcontroller`. Sweeping
|
||||
* those under Device Owner risks an unrecoverable phone, so eligibility is
|
||||
* opt-in per package.
|
||||
*
|
||||
* [PackageManager.MATCH_UNINSTALLED_PACKAGES] is required, not optional: a
|
||||
* package hidden by `setApplicationHidden` is dropped from the default
|
||||
* enumeration entirely, even though it remains `installed=true`. Querying
|
||||
* with flags `0` therefore returns a set that can never contain anything
|
||||
* this app has already hidden — so [EnforcementDecision.packagesToShow]
|
||||
* would come back empty and nothing could ever be unhidden again. Measured
|
||||
* on device: after a pass hid three apps, the following pass reported
|
||||
* `hid 0, restored 0` and left them hidden permanently.
|
||||
*/
|
||||
internal fun sweepablePackages(context: Context, policy: FocusPolicy): Set<String> {
|
||||
val pm = context.packageManager
|
||||
return pm.getInstalledApplications(PackageManager.MATCH_UNINSTALLED_PACKAGES)
|
||||
.filter { info ->
|
||||
val isSystem =
|
||||
(info.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0
|
||||
!isSystem || info.packageName in policy.blockableSystemPackages
|
||||
}
|
||||
.map { it.packageName }
|
||||
.toSet()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user