diff --git a/docs/superpowers/contracts/focus-owner-enforcement-runner-split-2026-08-18.json b/docs/superpowers/contracts/focus-owner-enforcement-runner-split-2026-08-18.json new file mode 100644 index 00000000..b8db00cf --- /dev/null +++ b/docs/superpowers/contracts/focus-owner-enforcement-runner-split-2026-08-18.json @@ -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." +} diff --git a/docs/superpowers/evidence/focus-owner-enforcement-runner-split-2026-08-18.json b/docs/superpowers/evidence/focus-owner-enforcement-runner-split-2026-08-18.json new file mode 100644 index 00000000..5d33cf78 --- /dev/null +++ b/docs/superpowers/evidence/focus-owner-enforcement-runner-split-2026-08-18.json @@ -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" + ] +} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt index 88aa9742..62799ee4 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt @@ -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() - val hiddenNow = mutableListOf() - 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 { - 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? { - 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() - 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, - ) } } diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt new file mode 100644 index 00000000..b74f0176 --- /dev/null +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/HomeLocationStore.kt @@ -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? { + 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 + } +} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt new file mode 100644 index 00000000..7710cebd --- /dev/null +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/LocationAcquisition.kt @@ -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() + 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, + ) + } +} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt new file mode 100644 index 00000000..3186f47c --- /dev/null +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/PolicyPinning.kt @@ -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, + val restoredNow: List, +) + +/** + * 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() + val hiddenNow = mutableListOf() + 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) +} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt new file mode 100644 index 00000000..7ccf2966 --- /dev/null +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/SweepablePackages.kt @@ -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 { + 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() +}