feat(app): add secure routing and runtime resilience

Add explicit TUN and SOCKS5 connection modes, app routing controls, authenticated per-session local SOCKS5, Android network monitoring, persistent haptics, resilient session recovery, notification hardening and client-side Hivemind cleanup.

Keep the hosted flutter_vless_android 1.1.5 runtime as the base and apply the Android compatibility/security patches at build time instead of restoring the unstable vendored runtime.
This commit is contained in:
Naveax committed 2026-08-31 19:31:12 +03:00
1 parent 371db8da9f
commit 8ead3ff249
30 files changed
+3959 -484

No files matched your search

@@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/installed_apps_service.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
class AppRoutingTile extends StatefulWidget {
final ConnectionMode connectionMode;
const AppRoutingTile({super.key, required this.connectionMode});
@override
State<AppRoutingTile> createState() => _AppRoutingTileState();
}
class _AppRoutingTileState extends State<AppRoutingTile> {
AppRoutingMode _mode = ConnectionSettings.routingMode;
int _selectedCount = ConnectionSettings.appPackages.length;
bool get _proxyMode => widget.connectionMode == ConnectionMode.proxy;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ConnectionSettings.initialize();
if (!mounted) return;
setState(() {
_mode = ConnectionSettings.routingMode;
_selectedCount = ConnectionSettings.appPackages.length;
});
}
bool _vpnBusy() {
final status = context.read<VpnConnection>().status;
return status == VpnStatus.connected ||
status == VpnStatus.connecting ||
status == VpnStatus.disconnecting;
}
void _showDisconnectFirst() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Disconnect before changing app routing.')),
);
}
Future<void> _changeMode(AppRoutingMode? next) async {
if (next == null || next == _mode) return;
if (_vpnBusy()) {
_showDisconnectFirst();
return;
}
await ConnectionSettings.setRoutingMode(next);
if (mounted) setState(() => _mode = next);
}
Future<void> _openPicker() async {
if (_mode == AppRoutingMode.all) return;
if (_vpnBusy()) {
_showDisconnectFirst();
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _AppPicker(mode: _mode),
),
);
if (mounted) {
setState(() => _selectedCount = ConnectionSettings.appPackages.length);
}
}
String get _subtitle {
if (_proxyMode) {
switch (_mode) {
case AppRoutingMode.all:
return 'Transparent SOCKS5 routes all apps automatically.';
case AppRoutingMode.exclude:
return _selectedCount == 0
? 'Transparent SOCKS5 routes all apps. No apps are excluded.'
: 'SOCKS5 routes all apps except $_selectedCount selected bypass app${_selectedCount == 1 ? '' : 's'}.';
case AppRoutingMode.selected:
return _selectedCount == 0
? 'Choose apps for compatibility selected-app SOCKS routing.'
: 'SOCKS5 routes $_selectedCount selected app${_selectedCount == 1 ? '' : 's'} while other launcher apps bypass it.';
}
}
switch (_mode) {
case AppRoutingMode.all:
return 'All apps use the TUN VPN.';
case AppRoutingMode.exclude:
return _selectedCount == 0
? 'No apps excluded from TUN.'
: '$_selectedCount app${_selectedCount == 1 ? '' : 's'} bypass the TUN VPN.';
case AppRoutingMode.selected:
return _selectedCount == 0
? 'Choose at least one app for compatible Selected only routing.'
: '$_selectedCount selected user app${_selectedCount == 1 ? '' : 's'} are targeted; Android network helpers stay available.';
}
}
@override
Widget build(BuildContext context) {
final showPicker = _mode != AppRoutingMode.all;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
title: const Text(
'App routing',
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
),
subtitle: Text(
_subtitle,
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
),
trailing: DropdownButtonHideUnderline(
child: DropdownButton<AppRoutingMode>(
value: _mode,
dropdownColor: AppColors.bgCard,
onChanged: _changeMode,
items: const [
DropdownMenuItem(
value: AppRoutingMode.all,
child: Text('All apps'),
),
DropdownMenuItem(
value: AppRoutingMode.exclude,
child: Text('Exclude apps'),
),
DropdownMenuItem(
value: AppRoutingMode.selected,
child: Text('Selected only'),
),
],
),
),
),
if (showPicker)
ListTile(
onTap: _openPicker,
leading: const Icon(Icons.apps),
title: Text(
_mode == AppRoutingMode.selected
? 'Choose routed apps'
: 'Choose excluded apps',
),
subtitle: Text(
_mode == AppRoutingMode.selected
? 'Selected user apps are targeted; Android network helpers remain available for compatibility.'
: 'Routing is applied to the selected Android package IDs.',
),
trailing: const Icon(Icons.chevron_right),
),
],
);
}
}
class _AppPicker extends StatefulWidget {
final AppRoutingMode mode;
const _AppPicker({required this.mode});
@override
State<_AppPicker> createState() => _AppPickerState();
}
class _AppPickerState extends State<_AppPicker> {
final Set<String> _selected = <String>{...ConnectionSettings.appPackages};
final TextEditingController _search = TextEditingController();
List<InstalledApp> _apps = const <InstalledApp>[];
bool _loading = true;
String? _error;
String _query = '';
@override
void initState() {
super.initState();
_loadApps();
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<void> _loadApps() async {
try {
final apps = await InstalledAppsService.loadLaunchableApps();
if (!mounted) return;
setState(() {
_apps = apps;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_error = 'Could not load installed apps.';
_loading = false;
});
}
}
Future<void> _save() async {
if (widget.mode == AppRoutingMode.selected && _selected.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Select at least one app.')),
);
return;
}
await ConnectionSettings.setAppPackages(_selected);
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final query = _query.trim().toLowerCase();
final visibleApps = query.isEmpty
? _apps
: _apps
.where(
(app) =>
app.label.toLowerCase().contains(query) ||
app.packageName.toLowerCase().contains(query),
)
.toList();
final title = widget.mode == AppRoutingMode.exclude
? 'Exclude apps'
: 'Selected routed apps';
return Scaffold(
backgroundColor: AppColors.bgDeep,
appBar: AppBar(
backgroundColor: AppColors.bgSurface,
title: Text(title),
actions: [
TextButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _search,
onChanged: (value) => setState(() => _query = value),
decoration: const InputDecoration(
hintText: 'Search apps',
prefixIcon: Icon(Icons.search),
),
),
),
Expanded(child: _buildList(visibleApps)),
],
),
);
}
Widget _buildList(List<InstalledApp> visibleApps) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Text(_error!, style: const TextStyle(color: AppColors.textMuted)),
);
}
if (visibleApps.isEmpty) {
return const Center(
child: Text('No apps found.', style: TextStyle(color: AppColors.textMuted)),
);
}
return ListView.builder(
itemCount: visibleApps.length,
itemBuilder: (context, index) {
final app = visibleApps[index];
final selected = _selected.contains(app.packageName);
return CheckboxListTile(
value: selected,
onChanged: (value) {
setState(() {
if (value == true) {
_selected.add(app.packageName);
} else {
_selected.remove(app.packageName);
}
});
},
secondary: app.iconPng == null
? const CircleAvatar(child: Icon(Icons.android, size: 20))
: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.memory(
app.iconPng!,
width: 40,
height: 40,
fit: BoxFit.cover,
gaplessPlayback: true,
),
),
title: Text(app.label),
subtitle: Text(
app.packageName,
style: const TextStyle(color: AppColors.textDim, fontSize: 11),
),
);
},
);
}
}
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
class ConnectionModeTile extends StatefulWidget {
final ValueChanged<ConnectionMode>? onChanged;
const ConnectionModeTile({super.key, this.onChanged});
@override
State<ConnectionModeTile> createState() => _ConnectionModeTileState();
}
class _ConnectionModeTileState extends State<ConnectionModeTile> {
ConnectionMode _mode = ConnectionSettings.mode;
bool _testingLocalSocks = false;
LocalSocksTestResult? _lastSocksTest;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ConnectionSettings.initialize();
if (mounted) setState(() => _mode = ConnectionSettings.mode);
}
bool _isTunnelBusy(VpnStatus status) {
return status == VpnStatus.connected ||
status == VpnStatus.connecting ||
status == VpnStatus.disconnecting;
}
Future<void> _changeMode(ConnectionMode? next) async {
if (next == null || next == _mode) return;
final vpn = context.read<VpnConnection>();
if (_isTunnelBusy(vpn.status)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Disconnect before changing connection mode.'),
),
);
return;
}
await ConnectionSettings.setMode(next);
if (!mounted) return;
setState(() {
_mode = next;
_lastSocksTest = null;
});
widget.onChanged?.call(next);
}
Future<void> _testLocalSocks() async {
if (_testingLocalSocks) return;
final vpn = context.read<VpnConnection>();
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connect with SOCKS5 first, then run the local test.'),
),
);
return;
}
setState(() => _testingLocalSocks = true);
final result = await vpn.testActiveLocalSocks();
if (!mounted) return;
setState(() {
_testingLocalSocks = false;
_lastSocksTest = result;
});
final latency = result.latencyMs == null ? '' : ' (${result.latencyMs} ms)';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${result.message}$latency')),
);
}
String get _subtitle {
switch (_mode) {
case ConnectionMode.tun:
return 'TUN: Android VPN routing for normal device traffic.';
case ConnectionMode.proxy:
return 'SOCKS5: authenticated per-session local gateway with transparent Android routing.';
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
title: const Text(
'Connection mode',
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
),
subtitle: Text(
_subtitle,
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
),
trailing: DropdownButtonHideUnderline(
child: DropdownButton<ConnectionMode>(
value: _mode,
dropdownColor: AppColors.bgCard,
onChanged: _changeMode,
items: const [
DropdownMenuItem(
value: ConnectionMode.tun,
child: Text('TUN'),
),
DropdownMenuItem(
value: ConnectionMode.proxy,
child: Text('SOCKS5'),
),
],
),
),
),
if (_mode == ConnectionMode.proxy) ...[
const Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(
'Secure Local SOCKS5 uses a private port and new credentials for every VPN session.',
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: OutlinedButton.icon(
onPressed: _testingLocalSocks ? null : _testLocalSocks,
icon: _testingLocalSocks
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.network_check),
label: Text(
_testingLocalSocks ? 'Testing…' : 'Test Local SOCKS',
),
),
),
if (_lastSocksTest != null)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Text(
_lastSocksTest!.ok
? 'Last test: OK${_lastSocksTest!.latencyMs == null ? '' : ' · ${_lastSocksTest!.latencyMs} ms'}'
: 'Last test: Failed · ${_lastSocksTest!.message}',
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
),
),
],
],
);
}
}
+19 -49
View File
@@ -1,43 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:revoltvpn/logic/app_colors.dart';
// A plain bool, not a ValueNotifier: this is only read at the moment of a tap,
// so nothing has to react to it changing. Default: false.
bool hapticEnabled = false;
/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below
/// only mounts once Settings is opened.
Future<void> loadHapticPref() async {
final prefs = await SharedPreferences.getInstance();
hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false;
}
/// Haptic calls are deliberately best-effort: a vibration failure must never
/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of
/// `lightImpact` — the latter is imperceptible on many Android devices.
class Haptics {
Haptics._();
static Future<void> connectionChanged() async {
if (!hapticEnabled) return;
try {
await HapticFeedback.mediumImpact();
} catch (e) {
debugPrint('[Haptics] Feedback unavailable: $e');
}
}
static Future<void> previewEnabled() async {
try {
await HapticFeedback.selectionClick();
} catch (e) {
debugPrint('[Haptics] Preview unavailable: $e');
}
}
}
import 'package:revoltvpn/logic/haptic_settings.dart';
class HapticFeedbackTile extends StatefulWidget {
const HapticFeedbackTile({super.key});
@@ -47,7 +10,8 @@ class HapticFeedbackTile extends StatefulWidget {
}
class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
bool _on = false;
bool _on = HapticSettings.enabled;
bool _saving = false;
@override
void initState() {
@@ -56,18 +20,24 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
}
Future<void> _load() async {
await loadHapticPref();
if (mounted) setState(() => _on = hapticEnabled);
await HapticSettings.initialize();
if (mounted) setState(() => _on = HapticSettings.enabled);
}
// Writes to SharedPreferences AND updates the live bool so the connect
// button sees the change immediately — no restart needed.
Future<void> _toggle(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('haptic_feedback_enabled', value);
hapticEnabled = value;
if (value) unawaited(Haptics.previewEnabled());
setState(() => _on = value);
if (_saving) return;
setState(() {
_saving = true;
_on = value;
});
try {
await HapticSettings.setEnabled(value);
} catch (_) {
if (mounted) setState(() => _on = HapticSettings.enabled);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
@@ -78,7 +48,7 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
),
value: _on,
onChanged: _toggle,
onChanged: _saving ? null : _toggle,
activeTrackColor: AppColors.accent.withAlpha(80),
activeThumbColor: AppColors.accent,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
class ResilienceModeTile extends StatefulWidget {
const ResilienceModeTile({super.key});
@override
State<ResilienceModeTile> createState() => _ResilienceModeTileState();
}
class _ResilienceModeTileState extends State<ResilienceModeTile> {
ResilienceMode _mode = ConnectionSettings.resilienceMode;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ConnectionSettings.initialize();
if (mounted) setState(() => _mode = ConnectionSettings.resilienceMode);
}
bool _vpnBusy() {
final status = context.read<VpnConnection>().status;
return status == VpnStatus.connected ||
status == VpnStatus.connecting ||
status == VpnStatus.disconnecting;
}
Future<void> _change(ResilienceMode? next) async {
if (next == null || next == _mode) return;
if (_vpnBusy()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Disconnect before changing resilience mode.'),
),
);
return;
}
await ConnectionSettings.setResilienceMode(next);
if (mounted) setState(() => _mode = next);
}
@override
Widget build(BuildContext context) {
final extreme = _mode == ResilienceMode.extreme;
return Consumer<VpnConnection>(
builder: (context, vpn, _) {
final connected = vpn.status == VpnStatus.connected ||
vpn.status == VpnStatus.connecting;
final active = connected ? ' Active: ${vpn.activeTransportProfile}.' : '';
return ListTile(
title: const Text(
'Network resilience',
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
),
subtitle: Text(
extreme
? 'Extreme: keeps the exact server transport unchanged and makes up to two controlled recovery attempts after each real Xray disconnect.$active'
: 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.$active',
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
),
trailing: DropdownButtonHideUnderline(
child: DropdownButton<ResilienceMode>(
value: _mode,
dropdownColor: AppColors.bgCard,
onChanged: _change,
items: const [
DropdownMenuItem(
value: ResilienceMode.standard,
child: Text('Standard'),
),
DropdownMenuItem(
value: ResilienceMode.extreme,
child: Text('Extreme'),
),
],
),
),
);
},
);
}
}