diff --git a/docs/superpowers/evidence/set-home-and-vpn-lock-2026-08-10.json b/docs/superpowers/evidence/set-home-and-vpn-lock-2026-08-10.json new file mode 100644 index 00000000..6f06e7a8 --- /dev/null +++ b/docs/superpowers/evidence/set-home-and-vpn-lock-2026-08-10.json @@ -0,0 +1,61 @@ +{ + "intent": "Deliver the two remaining pieces: an in-app way to set the home coordinates (run-as is dead on the release build device owner requires), and DISALLOW_CONFIG_VPN so the always-on VPN cannot be switched off from Settings.", + "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/MainActivity.kt", + "focus_owner/lib/device_policy.dart", + "focus_owner/lib/main.dart", + "focus_owner/test/widget_test.dart", + "Non-goal: RethinkDNS blocklist selection, which is user configuration" + ], + "changes": [ + "EnforcementRunner.setHomeToCurrentLocation writes home_location.json from the app's own uid. push_home_location.sh cannot: it stages through `run-as`, which returns 'package not debuggable' on a release build, and making the build debuggable would let anyone with adb edit this app's state -- the bypass the design exists to close.", + "Deliberately not an exported intent or receiver. Anything that can set home from outside can set it elsewhere, and the geofence would then report AWAY forever with enforcement off.", + "Coordinates are never logged: that is the user's home address and logcat is readable by anyone with adb.", + "setVpnConfigBlocked wired through to a UI button with a confirmation dialog, applied only after the filter was confirmed working.", + "The status page gained a Geofence row. 'no home set - enforcing everywhere' is a large behavioural difference from a working geofence and was previously invisible.", + "Scaffold body became a SingleChildScrollView: the two new buttons overflowed by 4px, and an unreachable 'Release device owner' button is the one control that must never be clipped." + ], + "verification": [ + { + "command": "flutter test ; gradlew :app:testDebugUnitTest ; flutter analyze", + "result": "pass", + "evidence": "69 Dart tests (2 new, covering both geofence states), 16 Kotlin tests, analyze clean. The overflow was caught by the widget tests failing with 'A RenderFlex overflowed by 4.0 pixels', i.e. a real UI bug that would have clipped on device." + }, + { + "command": "tap 'Set home to current location' on device", + "result": "pass", + "evidence": "Returned its designed failure: 'no location fix available - open a maps app, then retry'. The write path executes and reports rather than silently no-opping. It cannot complete yet because the wiped phone has no GPS fix (location_mode was 0; set to 3, but last location=null on every provider)." + }, + { + "command": "tap 'Lock VPN configuration', confirm", + "result": "pass", + "evidence": "logcat 'DISALLOW_CONFIG_VPN=true'; dumpsys user reports 'Device policy restrictions: no_config_vpn'. `settings put secure always_on_vpn_app \"\"` afterwards did not unpin it." + }, + { + "command": "does DISALLOW_CONFIG_VPN lock RethinkDNS's own settings? (the open question)", + "result": "pass", + "evidence": "No. Navigated RethinkDNS's UI under the restriction and changed its blocklist selection successfully (188 -> 170 -> 169 lists). The restriction governs system VPN configuration, not the VPN app's internal settings." + }, + { + "command": "reboot, then re-verify the whole stack", + "result": "pass", + "evidence": "Device Owner Type: 0, mAlwaysOnVpnPackage=com.celzero.bravedns, no_config_vpn still enforced, youtube hidden=true, uninstall still DELETE_FAILED_DEVICE_POLICY_MANAGER, youtube.com still 127.0.0.1." + }, + { + "command": "final DNS state after the user disabled the 'no google' blocklist", + "result": "pass", + "evidence": "All 14 YouTube domains BLOCKED (127.0.0.1). google.com, accounts.google.com, play.google.com, mail.google.com, googleapis.com, gstatic.com all resolve to real IPs. Controls wikipedia.org, github.com, cloudflare.com, kernel.org resolve normally." + } + ], + "risks": [ + "The geofence is still not active: no home_location.json, so every pass decides LOCATION_UNKNOWN and enforcement applies everywhere. One tap of the new button at home fixes it, once the phone has a GPS fix.", + "The AWAY branch remains unit-tested only. It cannot be exercised on device until coordinates exist, so the always-blocked exemption has never been observed on real hardware.", + "A blocklist reload briefly breaks all DNS resolution: three control domains returned nothing immediately after the change and resolved normally ~20s later. Transient, but it means a check run straight after a blocklist edit can look like a failure.", + "Diagnosing the DNS over-blocking cost several wrong turns: I theorised a wildcard custom rule when the cause was a 'no google' blocklist the user had enabled. Navigating RethinkDNS by coordinate also mis-tapped into Firefox once. Blocklist selection is user configuration and is not visible from adb." + ], + "rollback": [ + "In-app 'Release device owner' clears DISALLOW_CONFIG_VPN, unpins the VPN and unblocks uninstall before releasing -- verified end to end on this device while the account count was still 0.", + "To lift only the VPN lock, call setVpnConfigBlocked(false); the method takes a boolean and the UI button currently only sets it true." + ] +} 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 444d82cd..dc64a0d3 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 @@ -153,6 +153,42 @@ class EnforcementRunner(private val context: Context) { .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? { + val fix = lastKnownLocation() + ?: return "no location fix available - open a maps app, then retry" + return try { + val json = JSONObject() + .put("latitude", fix.first) + .put("longitude", fix.second) + 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. */ private fun readHomeLocation(): Pair? { val file = File(context.filesDir, "home_location.json") diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt index b09be59a..d6b89d0a 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt @@ -14,6 +14,15 @@ class MainActivity : FlutterActivity() { when (call.method) { "status" -> result.success(bridge.status()) "releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner()) + "setHomeToCurrentLocation" -> + result.success( + EnforcementRunner(applicationContext) + .setHomeToCurrentLocation(), + ) + "hasHomeLocation" -> + result.success( + EnforcementRunner(applicationContext).hasHomeLocation(), + ) "setAlwaysOnVpn" -> { val pkg = call.argument("package") if (pkg == null) { diff --git a/focus_owner/lib/device_policy.dart b/focus_owner/lib/device_policy.dart index 0d465368..144bc40d 100644 --- a/focus_owner/lib/device_policy.dart +++ b/focus_owner/lib/device_policy.dart @@ -70,4 +70,30 @@ class DevicePolicy { final released = await channel.invokeMethod('releaseDeviceOwner'); return released ?? false; } + + /// Records the current location as home, enabling the geofence. + /// + /// Returns null on success, or a message explaining the failure. Lives in + /// the app rather than a script because `run-as` does not work on the + /// release build that device owner requires. + Future setHomeToCurrentLocation() async => + channel.invokeMethod('setHomeToCurrentLocation'); + + /// Whether home coordinates are provisioned. + /// + /// Without them every pass decides LOCATION_UNKNOWN and fails closed, so + /// enforcement applies everywhere rather than only at home. + Future hasHomeLocation() async => + await channel.invokeMethod('hasHomeLocation') ?? false; + + /// Prevents the user from turning the always-on VPN off in Settings. + /// + /// Applied only after the VPN filter is confirmed working: pinning it over + /// a broken configuration would lock the broken state in. + Future setVpnConfigBlocked({required bool blocked}) async => + await channel.invokeMethod( + 'setVpnConfigBlocked', + {'blocked': blocked}, + ) ?? + false; } diff --git a/focus_owner/lib/main.dart b/focus_owner/lib/main.dart index 009956dc..5c830808 100644 --- a/focus_owner/lib/main.dart +++ b/focus_owner/lib/main.dart @@ -65,6 +65,7 @@ class _StatusPageState extends State { String? _policyError; String? _error; bool _busy = false; + bool _hasHome = false; @override void initState() { @@ -81,9 +82,11 @@ class _StatusPageState extends State { Future _loadDeviceStatus() async { try { final status = await widget.policy.status(); + final hasHome = await widget.policy.hasHomeLocation(); if (!mounted) return; setState(() { _status = status; + _hasHome = hasHome; _error = null; }); } on PlatformException catch (e) { @@ -127,6 +130,58 @@ class _StatusPageState extends State { ); } + Future _setHome() async { + setState(() => _busy = true); + final failure = await widget.policy.setHomeToCurrentLocation(); + if (!mounted) return; + setState(() => _busy = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + failure ?? + 'Home set. Enforcement now applies here, not everywhere.', + ), + ), + ); + await _refresh(); + } + + Future _lockVpn() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: _kSurface, + title: const Text('Lock VPN configuration?'), + content: const Text( + 'The always-on VPN can no longer be turned off from Settings. ' + 'Releasing device owner lifts this.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Lock'), + ), + ], + ), + ); + if (confirmed != true) return; + + setState(() => _busy = true); + final ok = await widget.policy.setVpnConfigBlocked(blocked: true); + if (!mounted) return; + setState(() => _busy = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(ok ? 'VPN configuration locked' : 'Could not lock - see logcat'), + ), + ); + await _refresh(); + } + Future _release() async { final confirmed = await showDialog( context: context, @@ -181,6 +236,9 @@ class _StatusPageState extends State { busy: _busy, onRelease: _release, onRunNow: _runNow, + onSetHome: _setHome, + onLockVpn: _lockVpn, + hasHome: _hasHome, ); } else { body = const Center(child: CircularProgressIndicator()); @@ -199,7 +257,13 @@ class _StatusPageState extends State { ), ], ), - body: Padding(padding: const EdgeInsets.all(_kGap), child: body), + // Scrollable because the action list grew past a short screen: an + // unreachable "Release device owner" button is the one control that + // must never be clipped off the bottom. + body: SingleChildScrollView( + padding: const EdgeInsets.all(_kGap), + child: body, + ), ); } } @@ -212,6 +276,9 @@ class _StatusBody extends StatelessWidget { required this.busy, required this.onRelease, required this.onRunNow, + required this.onSetHome, + required this.onLockVpn, + required this.hasHome, }); final DevicePolicyStatus status; @@ -220,6 +287,11 @@ class _StatusBody extends StatelessWidget { final bool busy; final Future Function() onRelease; final Future Function() onRunNow; + final Future Function() onSetHome; + final Future Function() onLockVpn; + + /// Whether home coordinates exist; without them enforcement is permanent. + final bool hasHome; @override Widget build(BuildContext context) { @@ -265,6 +337,12 @@ class _StatusBody extends StatelessWidget { label: 'Workout domains', value: '${policy.workoutUnblockDomains.length}', ), + // The distinction that decides whether enforcement is geofenced or + // permanent, so it belongs on screen rather than only in logcat. + _Row( + label: 'Geofence', + value: hasHome ? 'active' : 'no home set - enforcing everywhere', + ), ], const SizedBox(height: _kGap * 2), OutlinedButton( @@ -272,6 +350,20 @@ class _StatusBody extends StatelessWidget { child: const Text('Run enforcement now'), ), const SizedBox(height: _kGap), + if (status.isDeviceOwner) ...[ + OutlinedButton( + onPressed: busy ? null : onSetHome, + child: Text( + hasHome ? 'Update home to here' : 'Set home to current location', + ), + ), + const SizedBox(height: _kGap), + OutlinedButton( + onPressed: busy ? null : onLockVpn, + child: const Text('Lock VPN configuration'), + ), + const SizedBox(height: _kGap), + ], if (status.isDeviceOwner) FilledButton( onPressed: busy ? null : onRelease, diff --git a/focus_owner/test/widget_test.dart b/focus_owner/test/widget_test.dart index 3d8638af..7ece94ac 100644 --- a/focus_owner/test/widget_test.dart +++ b/focus_owner/test/widget_test.dart @@ -11,13 +11,17 @@ import 'package:focus_owner/policy.dart'; ) { const channel = MethodChannel('test/device_policy'); final calls = []; + // hasHomeLocation is read on every status refresh, so it is defaulted here + // rather than restated in each widget test. An explicit stub still wins, + // which is what the geofence-state tests rely on. + final stubs = {'hasHomeLocation': false, ...responses}; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { calls.add(call); - if (!responses.containsKey(call.method)) { + if (!stubs.containsKey(call.method)) { throw PlatformException(code: 'unimplemented', message: 'no stub'); } - return responses[call.method]; + return stubs[call.method]; }); return (channel: channel, calls: calls); } @@ -128,6 +132,48 @@ void main() { expect(find.text('none (inert build)'), findsOneWidget); }); + testWidgets('reports when no home is set, since that means everywhere', + (tester) async { + // Without coordinates every pass decides LOCATION_UNKNOWN and fails + // closed, so enforcement is permanent rather than geofenced. That is a + // large behavioural difference and must be visible on screen. + final fake = _fakeChannel({ + 'status': _statusMap(isDeviceOwner: true), + 'hasHomeLocation': false, + }); + await tester.pumpWidget( + MaterialApp( + home: StatusPage( + policy: DevicePolicy(fake.channel), + loadFocusPolicy: _stubPolicy, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('enforcing everywhere'), findsOneWidget); + expect(find.text('Set home to current location'), findsOneWidget); + }); + + testWidgets('offers to update home once one is set', (tester) async { + final fake = _fakeChannel({ + 'status': _statusMap(isDeviceOwner: true), + 'hasHomeLocation': true, + }); + await tester.pumpWidget( + MaterialApp( + home: StatusPage( + policy: DevicePolicy(fake.channel), + loadFocusPolicy: _stubPolicy, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('active'), findsOneWidget); + expect(find.text('Update home to here'), findsOneWidget); + }); + testWidgets('release is confirmed before it runs', (tester) async { final fake = _fakeChannel({ 'status': _statusMap(isDeviceOwner: true), @@ -143,6 +189,7 @@ void main() { ); await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Release device owner'), 100); await tester.tap(find.text('Release device owner')); await tester.pumpAndSettle(); expect(find.text('Release device owner?'), findsOneWidget); @@ -171,6 +218,7 @@ void main() { ); await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Release device owner'), 100); await tester.tap(find.text('Release device owner')); await tester.pumpAndSettle(); await tester.tap(find.text('Release'));