mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 15:34:06 +01:00
Two gaps stopped the unrooted path working end to end. The phone had no way to read the focus policy, and the blocklist was only reachable as a hosts file or over plain UDP/53 on the LAN -- while the rooted enforcer actively rejected every DoT connection, so a self-hosted resolver could not have worked even if one existed. focus_owner now loads a generated policy.json instead of hardcoding anything, so config.sh stays the single source of truth across Python, shell and Kotlin. The committed asset is rendered with --redact-home and a test fails if home coordinates ever appear in it. An unknown schema_version is rejected outright: silently accepting a newer schema means enforcing a policy the app has misread, and a misread allowlist is one that blocks the dialer. dns_enforcer.sh rejected ALL port 853 traffic and pinned Private DNS off, every 20 seconds. That is right when DoT is a bypass channel and exactly wrong when DoT is the enforcement channel, which is what an unrooted phone needs -- it has no hosts file to fall back to. With DNS_TRUSTED_DOT_HOST set it now ACCEPTs the trusted resolver before the blanket REJECT, and pins Private DNS to that hostname so the user cannot pick an unfiltered one. Rule order is load-bearing: iptables takes the first match, so an ACCEPT appended after the REJECT would never be reached. expected_rule_count() counts the new rules, because a mismatch there makes chain_intact() rebuild the chain every 20s -- a silent fork storm that also drops the connection each time. With the variable empty the behaviour is byte-identical, so the rooted phone is untouched until this is deployed. setup_dot_resolver.sh terminates DoT with stunnel rather than Caddy: DoT is a raw TLS stream and stock caddy:2.8 has no layer4 module, so using Caddy would have meant an xcaddy rebuild of a container serving five live sites. It reuses the certificate Caddy already renews, so nothing competes for port 80. It binds to the WireGuard address, not 0.0.0.0 -- a public DoT endpoint is an open resolver, and over the existing tunnel it needs no port-forward, no firewall rule and no rate limiting. Verified: example.com resolves, pornhub.com returns 0.0.0.0, TLS 1.3 against the LE cert, and both the LAN and public addresses refuse. The Device Owner checklist is planning only. Checking the device rather than the config settled the one open risk: Google Wallet is not installed and no NFC payment default is set, so the unconfirmable tap-to-pay question does not apply here. The same check found that Aegis, MS Authenticator, Oracle IDM and PKO IKO are absent -- they are in config.sh's whitelist, which describes the rooted phone, not this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
305 lines
8.6 KiB
Dart
305 lines
8.6 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;
|
|
|
|
@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();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_status = status;
|
|
_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> _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,
|
|
);
|
|
} 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',
|
|
),
|
|
],
|
|
),
|
|
body: Padding(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,
|
|
});
|
|
|
|
final DevicePolicyStatus status;
|
|
final FocusPolicy? focusPolicy;
|
|
final String? policyError;
|
|
final bool busy;
|
|
final Future<void> Function() onRelease;
|
|
|
|
@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}',
|
|
),
|
|
],
|
|
const SizedBox(height: _kGap * 2),
|
|
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));
|
|
}
|