refactor(app): debloat 3.3.1 and fix startup restoration
Maintainer changes on top of PR #2 (Naveax). Reduces the release to two deliberate connection modes and resolves the issues found in review: - Lock-screen privacy: move VISIBILITY_PRIVATE and a generic publicVersion into MainActivity.updateVpnNotification. The PR's fix lived in an unreferenced class and never reached the live notification. - Haptics: restore the default to off, and reduce HapticSettings to a single success() on connect/disconnect via the native channel. - SOCKS5: make it a real proxyOnly local-proxy mode rather than a TUN connection under a different label. TUN stays the default. - Health probe: restore the idle-only guard so /health is no longer polled every 30s while connected. - Recovery: add a _connectionEpoch counter so connect() aborts an in-flight Extreme recovery. - Startup restoration: rebuild the recovery snapshot from the still-active session after an app-process restart (TUN), and surface an honest orphaned state with a Reconnect action in proxy mode. Drops per-app routing entirely. It only ever functioned in TUN -- addDisallowedApplication lives inside the VPN-interface setup that proxyOnly skips -- and its allowlist half had no Dart caller. Also removes flutter_local_notifications, the global tap-haptic surface, and unused runtime telemetry. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
a71161e7e5
commit
6fcde9dd7b
18 files changed
+271
-1027
No files matched your search
@@ -1,73 +1,40 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum ConnectionMode { tun, proxy }
|
||||
enum AppRoutingMode { all, exclude, selected }
|
||||
enum ResilienceMode { standard, extreme }
|
||||
|
||||
abstract final class ConnectionSettings {
|
||||
ConnectionSettings._();
|
||||
|
||||
static const String _modeKey = 'connection_mode';
|
||||
static const String _routingModeKey = 'app_routing_mode';
|
||||
static const String _appPackagesKey = 'app_routing_packages';
|
||||
static const String _legacyBlockedAppsKey = 'blocked_apps';
|
||||
static const String _resilienceModeKey = 'resilience_mode';
|
||||
|
||||
static ConnectionMode _mode = ConnectionMode.tun;
|
||||
static AppRoutingMode _routingMode = AppRoutingMode.all;
|
||||
static ResilienceMode _resilienceMode = ResilienceMode.standard;
|
||||
static List<String> _appPackages = const <String>[];
|
||||
static bool _initialized = false;
|
||||
|
||||
static ConnectionMode get mode => _mode;
|
||||
static AppRoutingMode get routingMode => _routingMode;
|
||||
static ResilienceMode get resilienceMode => _resilienceMode;
|
||||
static List<String> get appPackages => List.unmodifiable(_appPackages);
|
||||
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storedMode = prefs.getString(_modeKey);
|
||||
final migrateLegacyAss = storedMode == 'ass';
|
||||
final migrateLegacyAuto = storedMode == 'auto' || storedMode == null;
|
||||
|
||||
if (storedMode == ConnectionMode.proxy.name || migrateLegacyAss) {
|
||||
_mode = ConnectionMode.proxy;
|
||||
} else {
|
||||
_mode = ConnectionMode.tun;
|
||||
}
|
||||
|
||||
final legacyBlocked =
|
||||
prefs.getStringList(_legacyBlockedAppsKey) ?? const <String>[];
|
||||
final storedPackages = prefs.getStringList(_appPackagesKey);
|
||||
_appPackages = _cleanPackages(storedPackages ?? legacyBlocked);
|
||||
|
||||
final storedRoutingMode = prefs.getString(_routingModeKey);
|
||||
if (migrateLegacyAss) {
|
||||
_routingMode = _appPackages.isEmpty
|
||||
? AppRoutingMode.all
|
||||
: AppRoutingMode.selected;
|
||||
} else if (storedRoutingMode == AppRoutingMode.exclude.name) {
|
||||
_routingMode = AppRoutingMode.exclude;
|
||||
} else if (storedRoutingMode == AppRoutingMode.selected.name) {
|
||||
_routingMode = AppRoutingMode.selected;
|
||||
} else if (storedRoutingMode == null && legacyBlocked.isNotEmpty) {
|
||||
_routingMode = AppRoutingMode.exclude;
|
||||
} else {
|
||||
_routingMode = AppRoutingMode.all;
|
||||
}
|
||||
// Legacy 'ass' was the pre-V3 SOCKS5 label; map it to proxy. Everything
|
||||
// else (including the old 'auto') is TUN — there is no runtime auto mode.
|
||||
_mode = storedMode == ConnectionMode.proxy.name || storedMode == 'ass'
|
||||
? ConnectionMode.proxy
|
||||
: ConnectionMode.tun;
|
||||
|
||||
_resilienceMode =
|
||||
prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name
|
||||
? ResilienceMode.extreme
|
||||
: ResilienceMode.standard;
|
||||
|
||||
if (migrateLegacyAss) {
|
||||
await prefs.setString(_modeKey, ConnectionMode.proxy.name);
|
||||
await prefs.setString(_routingModeKey, _routingMode.name);
|
||||
} else if (migrateLegacyAuto) {
|
||||
await prefs.setString(_modeKey, ConnectionMode.tun.name);
|
||||
if (storedMode != _mode.name) {
|
||||
await prefs.setString(_modeKey, _mode.name);
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
@@ -80,36 +47,10 @@ abstract final class ConnectionSettings {
|
||||
await prefs.setString(_modeKey, mode.name);
|
||||
}
|
||||
|
||||
static Future<void> setRoutingMode(AppRoutingMode mode) async {
|
||||
_routingMode = mode;
|
||||
_initialized = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_routingModeKey, mode.name);
|
||||
}
|
||||
|
||||
static Future<void> setAppPackages(Iterable<String> packages) async {
|
||||
_appPackages = _cleanPackages(packages);
|
||||
_initialized = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_appPackagesKey, _appPackages);
|
||||
}
|
||||
|
||||
static Future<void> setResilienceMode(ResilienceMode mode) async {
|
||||
_resilienceMode = mode;
|
||||
_initialized = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_resilienceModeKey, mode.name);
|
||||
}
|
||||
|
||||
static List<String> _cleanPackages(Iterable<String> packages) {
|
||||
final seen = <String>{};
|
||||
final result = <String>[];
|
||||
for (final raw in packages) {
|
||||
final value = raw.trim();
|
||||
if (value.isEmpty || !seen.add(value)) continue;
|
||||
result.add(value);
|
||||
}
|
||||
result.sort();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum HapticKind { tap, selection, success }
|
||||
|
||||
abstract final class HapticSettings {
|
||||
HapticSettings._();
|
||||
|
||||
@@ -13,7 +11,7 @@ abstract final class HapticSettings {
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('com.revoltvpn.app/haptics');
|
||||
|
||||
static bool _enabled = true;
|
||||
static bool _enabled = false;
|
||||
static bool _initialized = false;
|
||||
|
||||
static bool get enabled => _enabled;
|
||||
@@ -23,10 +21,10 @@ abstract final class HapticSettings {
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_enabled = prefs.getBool(_prefKey) ?? true;
|
||||
_enabled = prefs.getBool(_prefKey) ?? false;
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Failed to load preference: $e');
|
||||
_enabled = true;
|
||||
_enabled = false;
|
||||
} finally {
|
||||
_initialized = true;
|
||||
}
|
||||
@@ -48,11 +46,11 @@ abstract final class HapticSettings {
|
||||
}
|
||||
}
|
||||
|
||||
static void tap() => unawaited(_impact(HapticKind.tap));
|
||||
static void selection() => unawaited(_impact(HapticKind.selection));
|
||||
static void success() => unawaited(_impact(HapticKind.success));
|
||||
/// Confirmation haptic for a successful connect/disconnect. Best-effort: a
|
||||
/// vibration failure must never block or fail the VPN action.
|
||||
static void success() => unawaited(_impact());
|
||||
|
||||
static Future<void> _impact(HapticKind kind) async {
|
||||
static Future<void> _impact() async {
|
||||
if (!_initialized) await initialize();
|
||||
if (!_enabled || kIsWeb) return;
|
||||
|
||||
@@ -60,7 +58,7 @@ abstract final class HapticSettings {
|
||||
try {
|
||||
final handled = await _channel.invokeMethod<bool>(
|
||||
'impact',
|
||||
<String, Object>{'kind': kind.name},
|
||||
<String, Object>{'kind': 'success'},
|
||||
) ??
|
||||
false;
|
||||
if (handled) return;
|
||||
@@ -69,16 +67,6 @@ abstract final class HapticSettings {
|
||||
}
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case HapticKind.tap:
|
||||
await HapticFeedback.lightImpact();
|
||||
break;
|
||||
case HapticKind.selection:
|
||||
await HapticFeedback.selectionClick();
|
||||
break;
|
||||
case HapticKind.success:
|
||||
await HapticFeedback.mediumImpact();
|
||||
break;
|
||||
}
|
||||
await HapticFeedback.mediumImpact();
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class InstalledApp {
|
||||
final String packageName;
|
||||
final String label;
|
||||
final Uint8List? iconPng;
|
||||
|
||||
const InstalledApp({
|
||||
required this.packageName,
|
||||
required this.label,
|
||||
required this.iconPng,
|
||||
});
|
||||
}
|
||||
|
||||
abstract final class InstalledAppsService {
|
||||
InstalledAppsService._();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel('com.revoltvpn.app/apps');
|
||||
|
||||
static Future<List<InstalledApp>> loadLaunchableApps() async {
|
||||
final raw = await _channel.invokeListMethod<dynamic>('getLaunchableApps') ??
|
||||
const <dynamic>[];
|
||||
final apps = <InstalledApp>[];
|
||||
|
||||
for (final item in raw) {
|
||||
if (item is! Map) continue;
|
||||
|
||||
final packageName = item['packageName'];
|
||||
final label = item['label'];
|
||||
final icon = item['icon'];
|
||||
|
||||
if (packageName is! String || packageName.isEmpty) continue;
|
||||
|
||||
apps.add(
|
||||
InstalledApp(
|
||||
packageName: packageName,
|
||||
label: label is String && label.isNotEmpty ? label : packageName,
|
||||
iconPng: icon is Uint8List ? icon : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
apps.sort(
|
||||
(a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase()),
|
||||
);
|
||||
return apps;
|
||||
}
|
||||
}
|
||||
+67
-197
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_vless/flutter_vless.dart';
|
||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:revoltvpn/logic/installed_apps_service.dart';
|
||||
import 'package:revoltvpn/logic/local_socks_tester.dart';
|
||||
import 'package:revoltvpn/logic/network_monitor.dart';
|
||||
import 'package:revoltvpn/logic/secure_socks_session.dart';
|
||||
@@ -17,18 +16,6 @@ enum VpnStatus {
|
||||
error,
|
||||
}
|
||||
|
||||
class _RoutingPlan {
|
||||
final List<String>? blockedApps;
|
||||
final bool selectedOnly;
|
||||
final bool verifyLocalSocks;
|
||||
|
||||
const _RoutingPlan({
|
||||
required this.blockedApps,
|
||||
this.selectedOnly = false,
|
||||
this.verifyLocalSocks = false,
|
||||
});
|
||||
}
|
||||
|
||||
class VpnConnection extends ChangeNotifier {
|
||||
static const int _maxExtremeRecoveryAttempts = 2;
|
||||
|
||||
@@ -54,41 +41,10 @@ class VpnConnection extends ChangeNotifier {
|
||||
ResilienceMode _activeResilienceMode = ResilienceMode.standard;
|
||||
ResilienceMode get activeResilienceMode => _activeResilienceMode;
|
||||
|
||||
bool _selectedRoutingActive = false;
|
||||
bool get selectedRoutingActive => _selectedRoutingActive;
|
||||
|
||||
String _networkTransport = 'unknown';
|
||||
String get networkTransport => _networkTransport;
|
||||
|
||||
bool _networkValidated = false;
|
||||
bool get networkValidated => _networkValidated;
|
||||
|
||||
int? _lastHealthLatencyMs;
|
||||
int? get lastHealthLatencyMs => _lastHealthLatencyMs;
|
||||
|
||||
int _reconnectCount = 0;
|
||||
int get reconnectCount => _reconnectCount;
|
||||
|
||||
int get fallbackCount => 0;
|
||||
|
||||
String? _lastRecoveryReason;
|
||||
String? get lastRecoveryReason => _lastRecoveryReason;
|
||||
|
||||
String get activeRoutingDescription {
|
||||
if (_activeMode == ConnectionMode.proxy) {
|
||||
if (_selectedRoutingActive) return 'SOCKS5 · selected apps';
|
||||
if (_lastBlockedApps?.isNotEmpty == true) return 'SOCKS5 · exclude apps';
|
||||
return 'SOCKS5 · all apps';
|
||||
}
|
||||
if (_selectedRoutingActive) return 'Selected apps';
|
||||
if (_lastBlockedApps?.isNotEmpty == true) return 'Exclude apps';
|
||||
return 'All apps';
|
||||
}
|
||||
|
||||
String get activeTransportProfile =>
|
||||
_status == VpnStatus.connected || _status == VpnStatus.connecting
|
||||
? 'server'
|
||||
: 'inactive';
|
||||
SecureSocksSession? get activeSocksSession => _lastSecureSocks;
|
||||
|
||||
bool get canTestActiveLocalSocks =>
|
||||
_status == VpnStatus.connected &&
|
||||
@@ -119,12 +75,11 @@ class VpnConnection extends ChangeNotifier {
|
||||
|
||||
String? _lastBaseConfig;
|
||||
String _lastRemark = 'Revolt VPN';
|
||||
List<String>? _lastBlockedApps;
|
||||
bool _lastSelectedOnly = false;
|
||||
bool _lastVerifyLocalSocks = false;
|
||||
SecureSocksSession? _lastSecureSocks;
|
||||
bool _restartInProgress = false;
|
||||
bool _userDisconnecting = false;
|
||||
int _connectionEpoch = 0;
|
||||
|
||||
late final FlutterVless _vless;
|
||||
bool _initialized = false;
|
||||
@@ -180,7 +135,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
// ConnectivityManager is informational only. Restarting from every
|
||||
// callback caused the old VPN-created-network reconnect loop.
|
||||
_networkTransport = snapshot.transport;
|
||||
_networkValidated = snapshot.validated;
|
||||
notifyListeners();
|
||||
},
|
||||
onError: (Object error) {
|
||||
@@ -199,42 +153,65 @@ class VpnConnection extends ChangeNotifier {
|
||||
|
||||
final delay = await _vless.getConnectedServerDelay();
|
||||
if (delay > 0) {
|
||||
_lastHealthLatencyMs = delay;
|
||||
_isStartupRestoration = true;
|
||||
_restoreRoutingPresentationFromSettings();
|
||||
_lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
unawaited(_restoreRecoverySnapshot());
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _restoreRoutingPresentationFromSettings() {
|
||||
final routingMode = ConnectionSettings.routingMode;
|
||||
final packages = ConnectionSettings.appPackages;
|
||||
/// Android can kill the Dart process while the VPN foreground service keeps
|
||||
/// running, which leaves a fresh [VpnConnection] attached to a live tunnel it
|
||||
/// holds no config snapshot for — so Extreme recovery has nothing to restart
|
||||
/// from. The Hivemind session is still active, so `/session/status` returns
|
||||
/// the same VLESS parameters and the snapshot can be rebuilt without opening
|
||||
/// a new session or running the ad bypass.
|
||||
///
|
||||
/// TUN only. This mints *fresh* SOCKS credentials, which is invisible in TUN
|
||||
/// because only tun2socks consumes them. In proxy mode those credentials are
|
||||
/// the user's own client configuration, so rotating them behind their back
|
||||
/// would silently break it — that case is surfaced in the UI instead.
|
||||
Future<void> _restoreRecoverySnapshot() async {
|
||||
if (kIsWeb || _activeMode != ConnectionMode.tun) return;
|
||||
|
||||
_lastBlockedApps = null;
|
||||
_lastSelectedOnly = false;
|
||||
_lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
||||
_selectedRoutingActive = false;
|
||||
final epoch = _connectionEpoch;
|
||||
bool stale() =>
|
||||
_connectionEpoch != epoch ||
|
||||
_userDisconnecting ||
|
||||
_status != VpnStatus.connected;
|
||||
|
||||
if (routingMode == AppRoutingMode.selected) {
|
||||
_lastSelectedOnly = packages.isNotEmpty;
|
||||
_selectedRoutingActive = packages.isNotEmpty;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final realUrl = await HivemindService.fetchConfigDirectly(
|
||||
skipAdBypass: true,
|
||||
);
|
||||
// A real connect() may have landed while the fetch was in flight; never
|
||||
// overwrite a snapshot that belongs to a live connection.
|
||||
if (stale() || _lastBaseConfig != null) return;
|
||||
|
||||
if (routingMode == AppRoutingMode.exclude && packages.isNotEmpty) {
|
||||
_lastBlockedApps = List.of(packages);
|
||||
final parsed = FlutterVless.parse(realUrl);
|
||||
final secureSocks = await SecureSocksSession.create(
|
||||
parsed.getFullConfiguration(),
|
||||
);
|
||||
if (stale() || _lastBaseConfig != null) return;
|
||||
|
||||
_lastBaseConfig = secureSocks.configJson;
|
||||
_lastRemark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
|
||||
_lastVerifyLocalSocks = false;
|
||||
debugPrint('[VPN] Recovery snapshot rebuilt after process restart');
|
||||
} catch (e) {
|
||||
// Best-effort: without it the tunnel still works, only Extreme recovery
|
||||
// stays unavailable until the next manual connect.
|
||||
debugPrint('[VPN] Could not rebuild recovery snapshot: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String get _connectedLabel {
|
||||
switch (_activeMode) {
|
||||
case ConnectionMode.proxy:
|
||||
return _selectedRoutingActive
|
||||
? 'SOCKS5 · selected apps secured'
|
||||
: 'SOCKS5 gateway active';
|
||||
return 'SOCKS5 gateway active';
|
||||
case ConnectionMode.tun:
|
||||
return _selectedRoutingActive ? 'Selected apps secured' : 'Secured';
|
||||
return 'Secured';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,33 +270,21 @@ class VpnConnection extends ChangeNotifier {
|
||||
_cancelled = false;
|
||||
_userDisconnecting = false;
|
||||
_restartTimer?.cancel();
|
||||
_reconnectCount = 0;
|
||||
_lastRecoveryReason = null;
|
||||
_lastHealthLatencyMs = null;
|
||||
_selectedRoutingActive = false;
|
||||
_connectionEpoch++;
|
||||
|
||||
await ConnectionSettings.initialize();
|
||||
_activeMode = ConnectionSettings.mode;
|
||||
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
||||
|
||||
late final _RoutingPlan routingPlan;
|
||||
try {
|
||||
routingPlan = await _resolveRoutingPlan();
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString().replaceAll('Bad state: ', '');
|
||||
_setStatus(VpnStatus.error, 'App routing error');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!kIsWeb && !_initialized) {
|
||||
_errorMessage = 'VPN service unavailable.';
|
||||
_setStatus(VpnStatus.error, 'Service unavailable');
|
||||
return false;
|
||||
}
|
||||
|
||||
// TUN and SOCKS5 share the stable Android VpnService. SOCKS5 uses
|
||||
// an authenticated per-session local ingress instead of a fixed port.
|
||||
if (!kIsWeb) {
|
||||
// TUN needs the Android VPN permission; SOCKS5 proxy mode runs a local
|
||||
// authenticated proxy without a VpnService, so no VPN permission is needed.
|
||||
if (!kIsWeb && _activeMode == ConnectionMode.tun) {
|
||||
final ok = await _vless.requestPermission();
|
||||
if (!ok) {
|
||||
_errorMessage = 'VPN permission denied.';
|
||||
@@ -329,12 +294,8 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
|
||||
final startingMessage = switch (_activeMode) {
|
||||
ConnectionMode.tun => routingPlan.selectedOnly
|
||||
? 'Starting selected-app tunnel…'
|
||||
: 'Establishing secure channel…',
|
||||
ConnectionMode.proxy => routingPlan.selectedOnly
|
||||
? 'SOCKS5 · starting selected-app gateway…'
|
||||
: 'Starting transparent SOCKS5 gateway…',
|
||||
ConnectionMode.tun => 'Establishing secure channel…',
|
||||
ConnectionMode.proxy => 'Starting local SOCKS5 proxy…',
|
||||
};
|
||||
_setStatus(VpnStatus.connecting, startingMessage);
|
||||
_errorMessage = null;
|
||||
@@ -385,23 +346,23 @@ class VpnConnection extends ChangeNotifier {
|
||||
final baseConfig = parsed.getFullConfiguration();
|
||||
final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
|
||||
final secureSocks = await SecureSocksSession.create(baseConfig);
|
||||
|
||||
// Set this before the native callback can report CONNECTED so the first
|
||||
// visible label already reflects Selected only accurately.
|
||||
_selectedRoutingActive = routingPlan.selectedOnly;
|
||||
final verifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
||||
|
||||
await _startRuntime(
|
||||
config: secureSocks.configJson,
|
||||
remark: remark,
|
||||
blockedApps: routingPlan.blockedApps,
|
||||
proxyOnly: verifyLocalSocks,
|
||||
);
|
||||
|
||||
_setStatus(VpnStatus.connecting, 'Waiting for VPN interface…');
|
||||
_setStatus(
|
||||
VpnStatus.connecting,
|
||||
verifyLocalSocks ? 'Waiting for local SOCKS5…' : 'Waiting for VPN interface…',
|
||||
);
|
||||
if (!await _waitForNativeConnected()) {
|
||||
throw StateError('VPN runtime did not report CONNECTED');
|
||||
}
|
||||
|
||||
if (routingPlan.verifyLocalSocks) {
|
||||
if (verifyLocalSocks) {
|
||||
_setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…');
|
||||
if (!await _waitForLocalSocksListener(secureSocks)) {
|
||||
throw StateError('Local SOCKS5 listener did not become ready');
|
||||
@@ -411,12 +372,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
_lastBaseConfig = secureSocks.configJson;
|
||||
_lastSecureSocks = secureSocks;
|
||||
_lastRemark = remark;
|
||||
_lastBlockedApps = routingPlan.blockedApps == null
|
||||
? null
|
||||
: List.of(routingPlan.blockedApps!);
|
||||
_lastSelectedOnly = routingPlan.selectedOnly;
|
||||
_lastVerifyLocalSocks = routingPlan.verifyLocalSocks;
|
||||
_selectedRoutingActive = routingPlan.selectedOnly;
|
||||
_lastVerifyLocalSocks = verifyLocalSocks;
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Tunnel start error: $e');
|
||||
await _stopRuntime();
|
||||
@@ -432,95 +388,15 @@ class VpnConnection extends ChangeNotifier {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<_RoutingPlan> _resolveRoutingPlan() async {
|
||||
final routingMode = ConnectionSettings.routingMode;
|
||||
final selected = ConnectionSettings.appPackages;
|
||||
|
||||
switch (_activeMode) {
|
||||
case ConnectionMode.tun:
|
||||
if (routingMode == AppRoutingMode.selected) {
|
||||
return _selectedCompatibilityPlan();
|
||||
}
|
||||
return _tunPlan(routingMode, selected);
|
||||
|
||||
case ConnectionMode.proxy:
|
||||
// SOCKS5 is a transparent gateway: Android captures ordinary app
|
||||
// traffic into the same local SOCKS ingress used by Xray/tun2socks.
|
||||
if (routingMode == AppRoutingMode.selected) {
|
||||
return _selectedCompatibilityPlan(verifyLocalSocks: true);
|
||||
}
|
||||
return _tunPlan(
|
||||
routingMode,
|
||||
selected,
|
||||
verifyLocalSocks: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_RoutingPlan _tunPlan(
|
||||
AppRoutingMode routingMode,
|
||||
List<String> selected, {
|
||||
bool verifyLocalSocks = false,
|
||||
}) {
|
||||
if (routingMode == AppRoutingMode.exclude) {
|
||||
return _RoutingPlan(
|
||||
blockedApps: selected.isEmpty ? null : selected,
|
||||
verifyLocalSocks: verifyLocalSocks,
|
||||
);
|
||||
}
|
||||
|
||||
return _RoutingPlan(
|
||||
blockedApps: null,
|
||||
verifyLocalSocks: verifyLocalSocks,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_RoutingPlan> _selectedCompatibilityPlan({
|
||||
bool verifyLocalSocks = false,
|
||||
}) async {
|
||||
final selected = ConnectionSettings.appPackages.toSet();
|
||||
if (selected.isEmpty) {
|
||||
throw StateError('Select at least one app.');
|
||||
}
|
||||
|
||||
final apps = await InstalledAppsService.loadLaunchableApps();
|
||||
final availableSelected = apps
|
||||
.where((app) => selected.contains(app.packageName))
|
||||
.map((app) => app.packageName)
|
||||
.toSet();
|
||||
|
||||
if (availableSelected.isEmpty) {
|
||||
throw StateError('None of the selected apps are installed.');
|
||||
}
|
||||
|
||||
// Compatibility Selected only uses the plugin's proven blocklist path:
|
||||
// every other launchable user app bypasses the VPN, while Android system
|
||||
// helpers (DNS resolver, Play Services, network stack, etc.) remain able to
|
||||
// support selected apps such as Discord.
|
||||
final blocked = apps
|
||||
.map((app) => app.packageName)
|
||||
.where((packageName) => !availableSelected.contains(packageName))
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
return _RoutingPlan(
|
||||
blockedApps: blocked.isEmpty ? null : blocked,
|
||||
selectedOnly: true,
|
||||
verifyLocalSocks: verifyLocalSocks,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startRuntime({
|
||||
required String config,
|
||||
required String remark,
|
||||
required List<String>? blockedApps,
|
||||
required bool proxyOnly,
|
||||
}) async {
|
||||
await _vless.startVless(
|
||||
remark: remark,
|
||||
config: config,
|
||||
blockedApps: blockedApps,
|
||||
proxyOnly: false,
|
||||
proxyOnly: proxyOnly,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -538,8 +414,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
password: active.password,
|
||||
);
|
||||
if (result.ok) {
|
||||
_lastHealthLatencyMs = result.latencyMs;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
if (attempt < 3) {
|
||||
@@ -571,7 +445,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_lastRecoveryReason = reason;
|
||||
_setStatus(VpnStatus.connecting, 'Reconnecting…');
|
||||
_restartTimer = Timer(const Duration(milliseconds: 1200), () {
|
||||
unawaited(_restartActiveRuntime(reason));
|
||||
@@ -584,11 +457,12 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
|
||||
_restartInProgress = true;
|
||||
final epoch = _connectionEpoch;
|
||||
Object? lastError;
|
||||
|
||||
try {
|
||||
for (var attempt = 1; attempt <= _maxExtremeRecoveryAttempts; attempt++) {
|
||||
if (_userDisconnecting || _lastBaseConfig == null) return;
|
||||
if (_connectionEpoch != epoch || _userDisconnecting || _lastBaseConfig == null) return;
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
@@ -607,7 +481,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
await _startRuntime(
|
||||
config: _lastBaseConfig!,
|
||||
remark: _lastRemark,
|
||||
blockedApps: _lastBlockedApps,
|
||||
proxyOnly: _activeMode == ConnectionMode.proxy,
|
||||
);
|
||||
|
||||
if (!await _waitForNativeConnected()) {
|
||||
@@ -619,9 +493,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
|
||||
if (_userDisconnecting) return;
|
||||
|
||||
_reconnectCount++;
|
||||
_lastRecoveryReason = reason;
|
||||
_selectedRoutingActive = _lastSelectedOnly;
|
||||
_errorMessage = null;
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
return;
|
||||
@@ -716,12 +587,8 @@ class VpnConnection extends ChangeNotifier {
|
||||
|
||||
void _clearRuntimeSnapshot() {
|
||||
_lastBaseConfig = null;
|
||||
_lastBlockedApps = null;
|
||||
_lastSelectedOnly = false;
|
||||
_lastVerifyLocalSocks = false;
|
||||
_lastSecureSocks = null;
|
||||
_selectedRoutingActive = false;
|
||||
_lastHealthLatencyMs = null;
|
||||
_isStartupRestoration = false;
|
||||
}
|
||||
|
||||
@@ -732,6 +599,9 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _checkHealth() async {
|
||||
// A live tunnel already proves reachability; only probe while disconnected
|
||||
// so we don't add direct /health traffic while the VPN is active.
|
||||
if (_status == VpnStatus.connected) return;
|
||||
_serverReachable = await HivemindService.checkHealth();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user