mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 19:14:02 +01:00
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
421 lines
12 KiB
Dart
421 lines
12 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:focus_owner/device_policy.dart';
|
|
import 'package:focus_owner/policy.dart';
|
|
|
|
void main() => runApp(const FocusOwnerApp());
|
|
|
|
/// Shared palette: charcoal field with a single accent, matching the other
|
|
/// com.kuhy.* apps.
|
|
const Color _kField = Color(0xFF1B1D21);
|
|
const Color _kSurface = Color(0xFF24272C);
|
|
const Color _kAccent = Color(0xFF5B9DD9);
|
|
const Color _kText = Color(0xFFE8EAED);
|
|
const Color _kMuted = Color(0xFF9AA0A6);
|
|
const Color _kDanger = Color(0xFFD9776B);
|
|
|
|
const double _kGap = 16;
|
|
|
|
class FocusOwnerApp extends StatelessWidget {
|
|
const FocusOwnerApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: 'Focus Owner',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: ThemeData(
|
|
useMaterial3: true,
|
|
scaffoldBackgroundColor: _kField,
|
|
colorScheme: const ColorScheme.dark(
|
|
primary: _kAccent,
|
|
surface: _kSurface,
|
|
error: _kDanger,
|
|
),
|
|
),
|
|
home: const StatusPage(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shows provisioning state and offers the one action this build supports:
|
|
/// giving up device ownership.
|
|
class StatusPage extends StatefulWidget {
|
|
const StatusPage({
|
|
super.key,
|
|
this.policy = const DevicePolicy(),
|
|
this.loadFocusPolicy = FocusPolicy.load,
|
|
});
|
|
|
|
final DevicePolicy policy;
|
|
|
|
/// Injectable so tests can supply a policy without a rootBundle asset.
|
|
final Future<FocusPolicy> Function() loadFocusPolicy;
|
|
|
|
@override
|
|
State<StatusPage> createState() => _StatusPageState();
|
|
}
|
|
|
|
class _StatusPageState extends State<StatusPage> {
|
|
DevicePolicyStatus? _status;
|
|
FocusPolicy? _focusPolicy;
|
|
String? _policyError;
|
|
String? _error;
|
|
bool _busy = false;
|
|
bool _hasHome = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_refresh());
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
setState(() => _busy = true);
|
|
await Future.wait([_loadDeviceStatus(), _loadPolicy()]);
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
|
|
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) {
|
|
if (!mounted) return;
|
|
setState(() => _error = e.message ?? 'Could not read device policy state');
|
|
}
|
|
}
|
|
|
|
Future<void> _loadPolicy() async {
|
|
try {
|
|
final policy = await widget.loadFocusPolicy();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_focusPolicy = policy;
|
|
_policyError = null;
|
|
});
|
|
} on PolicyFormatException catch (e) {
|
|
// A policy that cannot be parsed must be reported, never silently
|
|
// treated as an empty allowlist - that would block everything.
|
|
if (!mounted) return;
|
|
setState(() => _policyError = e.message);
|
|
} on Exception catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _policyError = '$e');
|
|
}
|
|
}
|
|
|
|
Future<void> _runNow() async {
|
|
setState(() => _busy = true);
|
|
final started = await widget.policy.runEnforcementNow();
|
|
if (!mounted) return;
|
|
setState(() => _busy = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
started
|
|
? 'Enforcement run started; schedule armed'
|
|
: 'Could not start enforcement',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
builder: (context) => AlertDialog(
|
|
backgroundColor: _kSurface,
|
|
title: const Text('Release device owner?'),
|
|
content: const Text(
|
|
'The app will give up device ownership. Enforcement stops and '
|
|
'ownership can only be restored by a factory reset.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: TextButton.styleFrom(foregroundColor: _kDanger),
|
|
child: const Text('Release'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
|
|
setState(() => _busy = true);
|
|
final released = await widget.policy.releaseDeviceOwner();
|
|
if (!mounted) return;
|
|
setState(() => _busy = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
released ? 'Device owner released' : 'Release failed - see logcat',
|
|
),
|
|
),
|
|
);
|
|
await _refresh();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final status = _status;
|
|
final error = _error;
|
|
final Widget body;
|
|
if (error != null) {
|
|
body = const _Message(text: 'Could not read state', color: _kDanger);
|
|
} else if (status != null) {
|
|
body = _StatusBody(
|
|
status: status,
|
|
focusPolicy: _focusPolicy,
|
|
policyError: _policyError,
|
|
busy: _busy,
|
|
onRelease: _release,
|
|
onRunNow: _runNow,
|
|
onSetHome: _setHome,
|
|
onLockVpn: _lockVpn,
|
|
hasHome: _hasHome,
|
|
);
|
|
} else {
|
|
body = const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: _kField,
|
|
foregroundColor: _kText,
|
|
title: const Text('Focus Owner'),
|
|
actions: [
|
|
IconButton(
|
|
onPressed: _busy ? null : _refresh,
|
|
icon: const Icon(Icons.refresh),
|
|
tooltip: 'Refresh',
|
|
),
|
|
],
|
|
),
|
|
// 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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusBody extends StatelessWidget {
|
|
const _StatusBody({
|
|
required this.status,
|
|
required this.focusPolicy,
|
|
required this.policyError,
|
|
required this.busy,
|
|
required this.onRelease,
|
|
required this.onRunNow,
|
|
required this.onSetHome,
|
|
required this.onLockVpn,
|
|
required this.hasHome,
|
|
});
|
|
|
|
final DevicePolicyStatus status;
|
|
final FocusPolicy? focusPolicy;
|
|
final String? policyError;
|
|
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) {
|
|
final policy = focusPolicy;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_Row(label: 'Package', value: status.packageName),
|
|
_Row(label: 'Device owner', value: status.isDeviceOwner ? 'yes' : 'no'),
|
|
_Row(label: 'Admin active', value: status.isAdminActive ? 'yes' : 'no'),
|
|
_Row(label: 'Android SDK', value: '${status.sdkInt}'),
|
|
_Row(
|
|
label: 'Restrictions',
|
|
value: status.restrictionsApplied ? 'applied' : 'none (inert build)',
|
|
),
|
|
const SizedBox(height: _kGap),
|
|
const Divider(color: _kMuted),
|
|
const SizedBox(height: _kGap / 2),
|
|
if (policyError != null)
|
|
_Message(text: 'Policy: $policyError', color: _kDanger)
|
|
else if (policy == null)
|
|
const _Message(text: 'Policy: loading...', color: _kMuted)
|
|
else ...[
|
|
_Row(
|
|
label: 'Allowed apps',
|
|
value: '${policy.allowedPackages.length}'
|
|
' (${policy.nightAllowedPackages.length} at night)',
|
|
),
|
|
_Row(
|
|
label: 'Curfew',
|
|
value: policy.curfew == null
|
|
? 'disabled'
|
|
: '${_hhmm(policy.curfew!.startMinutes)}'
|
|
'-${_hhmm(policy.curfew!.endMinutes)}',
|
|
),
|
|
_Row(
|
|
label: 'Home radius',
|
|
value: policy.home.hasCoordinates
|
|
? '${policy.home.radiusM.round()} m'
|
|
: '${policy.home.radiusM.round()} m (coords redacted)',
|
|
),
|
|
_Row(
|
|
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(
|
|
onPressed: busy ? null : onRunNow,
|
|
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,
|
|
style: FilledButton.styleFrom(backgroundColor: _kDanger),
|
|
child: const Text('Release device owner'),
|
|
)
|
|
else
|
|
const _Message(
|
|
text: 'Not provisioned as device owner. Nothing is enforced.',
|
|
color: _kMuted,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Formats minutes-since-midnight as HH:MM.
|
|
String _hhmm(int minutes) {
|
|
final h = (minutes ~/ 60).toString().padLeft(2, '0');
|
|
final m = (minutes % 60).toString().padLeft(2, '0');
|
|
return '$h:$m';
|
|
}
|
|
|
|
class _Row extends StatelessWidget {
|
|
const _Row({required this.label, required this.value});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: _kGap / 2),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(label, style: const TextStyle(color: _kMuted)),
|
|
Text(value, style: const TextStyle(color: _kText)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Message extends StatelessWidget {
|
|
const _Message({required this.text, required this.color});
|
|
|
|
final String text;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
Text(text, style: TextStyle(color: color));
|
|
}
|