mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
Let the app set its own home, and lock VPN configuration
Two gaps left from the device owner work. The geofence had no way to be provisioned. 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 hand anyone with adb the ability to edit this app's state -- the bypass the whole design exists to close. So the app writes its own filesDir instead, from a button. Deliberately not an exported intent: anything that can set home from outside the app can set it somewhere else, and the geofence would then report AWAY forever with enforcement off. The coordinates are never logged either, since that is a home address and logcat is readable by anyone with adb. DISALLOW_CONFIG_VPN is now applied from the app, after the filter was confirmed working rather than before -- locking VPN configuration over a broken setup would pin the broken state in place. The open question was whether it also freezes RethinkDNS's own settings; it does not. Its blocklist selection was changed under the restriction without trouble, which is the answer that matters, because the alternative meant every domain rule had to be final before locking. The status page gained a Geofence row. "no home set - enforcing everywhere" is a large behavioural difference from a working geofence and was previously visible only by inspecting logcat. The widget tests caught a real bug rather than a test-only one: the two new buttons overflowed the column by 4px, and the control that must never be clipped off the bottom of the screen is "Release device owner". The body scrolls now. Verified after a reboot: youtube hidden, all 14 youtube domains at 127.0.0.1, google/gmail/play resolving normally, no_config_vpn still enforced, uninstall still refused. The geofence itself stays untested on hardware until the phone has a GPS fix to record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
This commit is contained in:
parent
db1d8f8247
commit
6836a32b43
@ -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."
|
||||
]
|
||||
}
|
||||
@ -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<Double, Double>? {
|
||||
val file = File(context.filesDir, "home_location.json")
|
||||
|
||||
@ -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<String>("package")
|
||||
if (pkg == null) {
|
||||
|
||||
@ -70,4 +70,30 @@ class DevicePolicy {
|
||||
final released = await channel.invokeMethod<bool>('releaseDeviceOwner');
|
||||
return released ?? false;
|
||||
}
|
||||
|
||||
/// Records the current location as home, enabling the geofence.
|
||||
///
|
||||
/// Returns null on success, or a message explaining the failure. Lives in
|
||||
/// the app rather than a script because `run-as` does not work on the
|
||||
/// release build that device owner requires.
|
||||
Future<String?> setHomeToCurrentLocation() async =>
|
||||
channel.invokeMethod<String>('setHomeToCurrentLocation');
|
||||
|
||||
/// Whether home coordinates are provisioned.
|
||||
///
|
||||
/// Without them every pass decides LOCATION_UNKNOWN and fails closed, so
|
||||
/// enforcement applies everywhere rather than only at home.
|
||||
Future<bool> hasHomeLocation() async =>
|
||||
await channel.invokeMethod<bool>('hasHomeLocation') ?? false;
|
||||
|
||||
/// Prevents the user from turning the always-on VPN off in Settings.
|
||||
///
|
||||
/// Applied only after the VPN filter is confirmed working: pinning it over
|
||||
/// a broken configuration would lock the broken state in.
|
||||
Future<bool> setVpnConfigBlocked({required bool blocked}) async =>
|
||||
await channel.invokeMethod<bool>(
|
||||
'setVpnConfigBlocked',
|
||||
{'blocked': blocked},
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@ -65,6 +65,7 @@ class _StatusPageState extends State<StatusPage> {
|
||||
String? _policyError;
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
bool _hasHome = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -81,9 +82,11 @@ class _StatusPageState extends State<StatusPage> {
|
||||
Future<void> _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<StatusPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _lockVpn() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _release() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@ -181,6 +236,9 @@ class _StatusPageState extends State<StatusPage> {
|
||||
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<StatusPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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<void> Function() onRelease;
|
||||
final Future<void> Function() onRunNow;
|
||||
final Future<void> Function() onSetHome;
|
||||
final Future<void> 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,
|
||||
|
||||
@ -11,13 +11,17 @@ import 'package:focus_owner/policy.dart';
|
||||
) {
|
||||
const channel = MethodChannel('test/device_policy');
|
||||
final calls = <MethodCall>[];
|
||||
// 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'));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user