diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1476797..0b45736 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,9 +10,6 @@ - - when (call.method) { - // Doze and App Standby stop background services of apps that - // are not exempt. A VPN has to be exempt to survive the screen - // going off, so surface the real state rather than guessing. "isIgnoringBatteryOptimizations" -> result.success(isIgnoringBatteryOptimizations()) "requestIgnoreBatteryOptimizations" -> result.success(requestIgnoreBatteryOptimizations()) - "setSessionExpiry" -> { - val expiresAtMs = call.argument("expiresAtMs")?.toLong() ?: 0L - if (expiresAtMs > 0) { - sendSessionExpiryToDaemon(expiresAtMs) - } - result.success(null) - } - // Is our VPN runtime actually alive? The service lives in - // :RunSoLibXrayDaemon, a separate process, so its statics are - // NOT visible here -- this is the only honest check. - "isVpnRuntimeAlive" -> result.success(isVpnRuntimeAlive()) else -> result.notImplemented() } } @@ -118,7 +101,6 @@ class MainActivity : FlutterActivity() { super.onDestroy() } - /** True when the OS has exempted us from Doze / App Standby. */ private fun isIgnoringBatteryOptimizations(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true return try { @@ -129,11 +111,6 @@ class MainActivity : FlutterActivity() { } } - /** - * Opens the system exemption prompt. Android shows this as a single dialog; - * it cannot be granted silently, so the caller must treat a false return as - * "ask again later" rather than a denial. - */ private fun requestIgnoreBatteryOptimizations(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true if (isIgnoringBatteryOptimizations()) return true @@ -146,7 +123,6 @@ class MainActivity : FlutterActivity() { ) true } catch (_: Exception) { - // Some OEM builds hide the direct intent; fall back to the list. try { startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) true @@ -156,58 +132,6 @@ class MainActivity : FlutterActivity() { } } - /** Pushes the session-expiry deadline to the VPN service process. */ - private fun sendSessionExpiryToDaemon(expiresAtMs: Long) { - try { - val intent = Intent(this, XrayVPNService::class.java) - intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs) - startService(intent) - } catch (_: Exception) { - // Daemon not reachable; its persisted expiry still applies. - } - } - - /** - * Whether the VPN runtime is genuinely alive. - * - * The Xray service runs in :RunSoLibXrayDaemon, a separate process, so its - * statics (AppConfigs.V2RAY_CONFIG, V2RAY_STATE) are NOT visible from here -- - * reading them yields a fresh, empty copy. getRunningAppProcesses still - * returns our *own* processes, which makes it the one reliable signal. - */ - private fun isVpnRuntimeAlive(): Map { - var processAlive = false - try { - val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - processAlive = am.runningAppProcesses - ?.any { it.processName.endsWith(VPN_PROCESS_SUFFIX) } == true - } catch (_: Exception) { - } - - var vpnTransport = false - try { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - vpnTransport = cm.allNetworks.any { network -> - cm.getNetworkCapabilities(network) - ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true - } - } catch (_: Exception) { - } - - return mapOf( - "processAlive" to processAlive, - "vpnTransport" to vpnTransport, - ) - } - - /** Removes a notification left behind by a runtime that is no longer alive. */ - private fun cancelVpnNotification() { - try { - NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID) - } catch (_: Exception) { - } - } - private fun updateVpnNotification(title: String, text: String, actionLabel: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != @@ -216,15 +140,6 @@ class MainActivity : FlutterActivity() { return } - // The foreground notification (id 1) is owned by XrayVPNService via - // startForeground. Posting to it from this process while the service is - // gone leaves a frozen countdown nothing will ever update again. - val alive = isVpnRuntimeAlive()["processAlive"] == true - if (!alive) { - cancelVpnNotification() - return - } - ensureNotificationChannel() val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -234,17 +149,28 @@ class MainActivity : FlutterActivity() { } val launchIntent = packageManager.getLaunchIntentForPackage(packageName) - launchIntent?.action = "FROM_DISCONNECT_BTN" launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK - val stopIntent = Intent(this, XrayVPNService::class.java) - stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) - val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) + // The VPN service owns the authoritative Disconnect PendingIntent and + // embeds the current runtime-generation token in it. Never recreate an + // unscoped STOP intent from the UI process: a stale notification could + // otherwise stop a newer tunnel. Reuse the live service action when the + // platform exposes it; on older Android versions omit the action rather + // than weaken generation scoping. + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val serviceStopAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + manager.activeNotifications + .firstOrNull { it.id == NOTIFICATION_ID } + ?.notification + ?.actions + ?.firstOrNull() + ?.actionIntent + } else { + null + } - // Public lock-screen preview: keep the VPN status visible but hide the - // session countdown/speed so usage metadata never reaches the lock screen. val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.notification_status_icon) .setContentTitle("Revolt VPN") @@ -255,7 +181,6 @@ class MainActivity : FlutterActivity() { .setSmallIcon(R.drawable.notification_status_icon) .setContentTitle(title) .setContentText(text) - .addAction(0, actionLabel, stopPendingIntent) .setColorized(true) .setColor(0xFF0D1117.toInt()) .setOngoing(true) @@ -267,6 +192,10 @@ class MainActivity : FlutterActivity() { .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setPublicVersion(publicVersion) + if (serviceStopAction != null) { + builder.addAction(0, actionLabel, serviceStopAction) + } + if (launchIntent != null) { builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags)) } @@ -473,7 +402,6 @@ class MainActivity : FlutterActivity() { companion object { private const val NOTIFICATION_ID = 1 - private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon" - private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL" + private const val NOTIFICATION_CHANNEL_ID = "REVOLT_VPN_SERVICE" } } diff --git a/android/app/src/main/res/drawable/notification_status_icon.xml b/android/app/src/main/res/drawable/notification_status_icon.xml index 183ed91..f768282 100644 --- a/android/app/src/main/res/drawable/notification_status_icon.xml +++ b/android/app/src/main/res/drawable/notification_status_icon.xml @@ -1,9 +1,4 @@ - - - + diff --git a/lib/logic/notification_service.dart b/lib/logic/notification_service.dart index d9e9f8c..88f46f1 100644 --- a/lib/logic/notification_service.dart +++ b/lib/logic/notification_service.dart @@ -16,16 +16,17 @@ class NotificationService { /// Sets the notification's content text to [text]. /// /// The native side rebuilds the whole notification, so skip the round-trip - /// when the text has not changed. + /// when the text has not changed. A failed native update is intentionally + /// not cached so the next tick can retry it. static Future updateTimer(String text) async { if (text == _lastText) return; - _lastText = text; try { await _channel.invokeMethod('updateNotificationText', { 'title': 'Revolt VPN', 'text': text, 'disconnectLabel': 'Disconnect', }); + _lastText = text; } catch (_) { // Cosmetic. } diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 4625647..4164fb3 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -7,13 +7,11 @@ import 'package:revoltvpn/logic/app_config.dart'; import 'package:revoltvpn/logic/crypto_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/notification_service.dart'; -import 'package:revoltvpn/logic/power_settings.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { Timer? _timer; int _tickCount = 0; - int _lastPushedExpiryAtMs = 0; bool _appBackgrounded = false; final VpnConnection vpnConnection; @@ -25,7 +23,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { bool _hasSyncedOnce = false; int _consecutiveFailures = 0; bool _isDisconnecting = false; - bool _syncInProgress = false; + int? _syncEpochInProgress; + Completer? _syncCompletion; + int _sessionEpoch = 0; + bool _supportRewardClaimed = false; bool _supportRewardStateLoaded = false; int _supportStateEpoch = 0; @@ -38,25 +39,21 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { static const int _maxOfflineSeconds = 120; int _offlineSeconds = 0; - // The countdown ticks locally every second; these only drive the slow - // server heartbeat that catches server-side changes (support-ad extension, - // 10 GB cap, session-ended). Keeping it slow avoids a chatty /session/status - // beacon (see internal/LAUNCH_READINESS.md "cut the beacon rate"). - static const int _pollIntervalSeconds = 60; - static const int _backgroundPollIntervalSeconds = 60; + static const int _pollIntervalSeconds = 5; + static const int _backgroundPollIntervalSeconds = 30; int _lastUsedBytes = 0; - DateTime? _lastSuccessfulSyncAt; + Stopwatch? _sinceLastSuccessfulSync; double _currentSpeedKBps = 0.0; SessionTimer({required this.vpnConnection}) { WidgetsBinding.instance.addObserver(this); vpnConnection.addListener(_onVpnConnectionChanged); unawaited(_loadSupportRewardState()); - // This provider is built lazily, so the tunnel may already be up and - // VpnConnection may already have fired its change before we attached. - // A listener does not replay history -- evaluate the current state once. - // Deferred so we never notify during the build that created us. + + // VpnConnection is eager and can report an already-running native tunnel + // before this provider is created. ChangeNotifier listeners do not replay + // old events, so always evaluate the current state once after construction. unawaited(Future.microtask(_onVpnConnectionChanged)); } @@ -113,11 +110,8 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } void _onVpnConnectionChanged() { - // A live tunnel with a stopped clock is always wrong, however we got here: - // adopting a tunnel that outlived the UI process, recovering from a blip, or - // attaching late to one that was already up. Previously this required either - // adoptedRunningRuntime or _hasSyncedOnce, both false on a fresh process, so - // the clock stayed at 00:00:00 and the notification countdown froze with it. + // A live tunnel with a stopped clock is always wrong, whether this process + // adopted an existing runtime, attached late, or recovered from a blip. if (vpnConnection.status == VpnStatus.connected && !isRunning && !_isDisconnecting) { @@ -130,7 +124,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { if (!hadActiveSession || _isDisconnecting) return; if (vpnConnection.status == VpnStatus.error) { - _stopForVpnFailure('VPN entered error state'); + unawaited(_doDisconnect('VPN entered error state')); return; } @@ -139,33 +133,16 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } } - void _stopForVpnFailure(String reason) { - if (_isDisconnecting) return; - _isDisconnecting = true; - debugPrint('[Timer] Stopping after VPN failure: $reason'); - - _timer?.cancel(); - _timer = null; - _currentSpeedKBps = 0.0; - _remainingSeconds = 0; - _remainingAtLastSync = 0; - _hasSyncedOnce = false; - _lastSuccessfulSyncAt = null; - _consecutiveFailures = 0; - _offlineSeconds = 0; - NotificationService.reset(); - notifyListeners(); - } - Future start() async { + _sessionEpoch++; _remainingSeconds = 0; _remainingAtLastSync = 0; _usedBytes = 0; _lastUsedBytes = 0; - _lastSuccessfulSyncAt = null; + _sinceLastSuccessfulSync?.stop(); + _sinceLastSuccessfulSync = null; _currentSpeedKBps = 0.0; _tickCount = 0; - _lastPushedExpiryAtMs = 0; _hasSyncedOnce = false; _consecutiveFailures = 0; _offlineSeconds = 0; @@ -188,7 +165,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { void _tick(Timer t) { if (_isDisconnecting) return; - if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) { + if (_hasSyncedOnce) { _reconcileElapsedTime(); } @@ -220,9 +197,23 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } } - /// Force an immediate server sync (e.g. after a support-ad extension so the - /// countdown reflects the new expiry without waiting for the next heartbeat). - Future syncNow() => _syncWithHivemind(); + /// Force a control-plane refresh after an event that changes the session + /// server-side. If a periodic sync is already in flight, wait for it and + /// issue a fresh request so the event cannot be hidden behind the busy guard. + Future syncNow() async { + final requestedEpoch = _sessionEpoch; + while (requestedEpoch == _sessionEpoch && !_isDisconnecting) { + final active = _syncEpochInProgress == requestedEpoch + ? _syncCompletion + : null; + if (active != null) { + await active.future; + continue; + } + await _syncWithHivemind(); + return; + } + } Future disconnect({String reason = 'User requested'}) async { await _doDisconnect(reason); @@ -231,6 +222,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { Future _doDisconnect(String reason) async { if (_isDisconnecting) return; _isDisconnecting = true; + _sessionEpoch++; debugPrint('[Timer] Disconnecting: $reason'); _timer?.cancel(); @@ -239,13 +231,13 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { _remainingSeconds = 0; _remainingAtLastSync = 0; _hasSyncedOnce = false; - _lastSuccessfulSyncAt = null; + _sinceLastSuccessfulSync?.stop(); + _sinceLastSuccessfulSync = null; NotificationService.reset(); notifyListeners(); await vpnConnection.disconnect(); - if (!_isDisconnecting) return; _isDisconnecting = false; notifyListeners(); } @@ -257,20 +249,28 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } Future _syncWithHivemind() async { - if (_isDisconnecting || _syncInProgress) return; - _syncInProgress = true; + if (_isDisconnecting) return; + final epoch = _sessionEpoch; + if (_syncEpochInProgress == epoch) return; + + final completion = Completer(); + _syncCompletion = completion; + _syncEpochInProgress = epoch; + try { final deviceId = await CryptoService.getDeviceId(); + if (epoch != _sessionEpoch || _isDisconnecting) return; + final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status'); final url = base.replace(queryParameters: {'device_id': deviceId}); - final response = await HivemindService.directGet(url); - if (_isDisconnecting) return; + final response = await HivemindService.controlGet(url); + if (epoch != _sessionEpoch || _isDisconnecting) return; if (response.statusCode == 200) { final decoded = jsonDecode(response.body); if (decoded is! Map) { debugPrint('[Timer] Invalid session payload type.'); - _markSyncFailure(); + _markSyncFailure(epoch); return; } final data = decoded; @@ -278,7 +278,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { final activeValue = data['active']; if (activeValue is! bool) { debugPrint('[Timer] Session payload missing boolean active state.'); - _markSyncFailure(); + _markSyncFailure(epoch); return; } if (!activeValue) { @@ -291,61 +291,66 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { !expiresValue.isFinite || expiresValue < 0) { debugPrint('[Timer] Session payload has invalid expiry.'); - _markSyncFailure(); + _markSyncFailure(epoch); return; } + final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds; _remainingAtLastSync = expiresValue.toInt(); _remainingSeconds = _remainingAtLastSync; - // Push the absolute deadline to the VPN service so it can tear the - // tunnel down on its own clock, even if this UI process is gone. - final expiresAtMs = - DateTime.now().millisecondsSinceEpoch + _remainingSeconds * 1000; - if ((expiresAtMs - _lastPushedExpiryAtMs).abs() > 5000) { - _lastPushedExpiryAtMs = expiresAtMs; - unawaited(PowerSettings.setSessionExpiry(expiresAtMs)); - } _usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes); - final now = DateTime.now(); - final previousSyncAt = _lastSuccessfulSyncAt; final deltaBytes = _usedBytes - _lastUsedBytes; - if (_hasSyncedOnce && previousSyncAt != null) { - final elapsedMs = now.difference(previousSyncAt).inMilliseconds; - if (deltaBytes > 0 && elapsedMs > 0) { - _currentSpeedKBps = (deltaBytes / (elapsedMs / 1000.0)) / 1000.0; - } else { - _currentSpeedKBps = 0.0; - } + if (_hasSyncedOnce && elapsedMs != null && elapsedMs > 0) { + _currentSpeedKBps = deltaBytes > 0 + ? (deltaBytes / (elapsedMs / 1000.0)) / 1000.0 + : 0.0; } else { _currentSpeedKBps = 0.0; } _lastUsedBytes = _usedBytes; - _lastSuccessfulSyncAt = now; + _sinceLastSuccessfulSync?.stop(); + _sinceLastSuccessfulSync = Stopwatch()..start(); if (data['cap_exhausted'] == true) { await _doDisconnect('Data cap reached'); return; } + if (epoch != _sessionEpoch || _isDisconnecting) return; + try { + await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync); + } catch (e) { + debugPrint('[Timer] Failed to arm native session deadline: $e'); + await _doDisconnect('Native session deadline unavailable'); + return; + } + if (epoch != _sessionEpoch || _isDisconnecting) return; _consecutiveFailures = 0; _offlineSeconds = 0; _hasSyncedOnce = true; notifyListeners(); } else { - _markSyncFailure(); + _markSyncFailure(epoch); } } catch (e) { - if (!_isDisconnecting) { + if (epoch == _sessionEpoch && !_isDisconnecting) { debugPrint('Hivemind sync error: $e'); - _markSyncFailure(); + _markSyncFailure(epoch); } } finally { - _syncInProgress = false; + if (_syncEpochInProgress == epoch) { + _syncEpochInProgress = null; + } + if (identical(_syncCompletion, completion)) { + _syncCompletion = null; + } + if (!completion.isCompleted) completion.complete(); } } - void _markSyncFailure() { + void _markSyncFailure(int epoch) { + if (epoch != _sessionEpoch || _isDisconnecting) return; _consecutiveFailures++; if (_consecutiveFailures >= _maxConsecutiveFailures) { notifyListeners(); @@ -353,10 +358,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { } void _reconcileElapsedTime() { - final syncedAt = _lastSuccessfulSyncAt; - if (!_hasSyncedOnce || syncedAt == null) return; + final clock = _sinceLastSuccessfulSync; + if (!_hasSyncedOnce || clock == null) return; - final elapsed = DateTime.now().difference(syncedAt).inSeconds; + final elapsed = clock.elapsed.inSeconds; _remainingSeconds = (_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt(); } @@ -396,9 +401,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { @override void dispose() { + // Invalidate every asynchronous generation before removing listeners so + // late storage/network completions cannot notify a disposed notifier. + _sessionEpoch++; + _supportStateEpoch++; + _isDisconnecting = true; WidgetsBinding.instance.removeObserver(this); vpnConnection.removeListener(_onVpnConnectionChanged); _timer?.cancel(); + _sinceLastSuccessfulSync?.stop(); super.dispose(); } } diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 80d7c83..4147fe6 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.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/local_socks_tester.dart'; import 'package:revoltvpn/logic/network_monitor.dart'; -import 'package:revoltvpn/logic/power_settings.dart'; import 'package:revoltvpn/logic/secure_socks_session.dart'; enum VpnStatus { @@ -18,7 +18,12 @@ enum VpnStatus { } class VpnConnection extends ChangeNotifier { - bool _cancelled = false; + static const MethodChannel _nativeControl = MethodChannel('flutter_vless'); + + int _connectEpoch = 0; + bool _suppressNativeConnect = false; + bool _disposed = false; + VpnStatus _status = VpnStatus.disconnected; VpnStatus get status => _status; @@ -45,6 +50,7 @@ class VpnConnection extends ChangeNotifier { SecureSocksSession? get activeSocksSession => _lastSecureSocks; bool get canTestActiveLocalSocks => + !_disposed && _status == VpnStatus.connected && _activeMode == ConnectionMode.proxy && _lastSecureSocks != null; @@ -94,9 +100,10 @@ class VpnConnection extends ChangeNotifier { } Future _startEngine() async { - if (kIsWeb) return; + if (kIsWeb || _disposed) return; await ConnectionSettings.initialize(); + if (_disposed) return; _activeMode = ConnectionSettings.mode; _vless = FlutterVless( @@ -115,13 +122,16 @@ class VpnConnection extends ChangeNotifier { notificationIconResourceType: 'drawable', notificationIconResourceName: 'notification_status_icon', ); + if (_disposed) return; _initialized = true; } catch (e) { debugPrint('[VPN] VLESS init error (expected on emulator): $e'); } + if (_disposed) return; _networkSubscription = NetworkMonitor.changes.listen( (snapshot) { + if (_disposed) return; // ConnectivityManager is informational only. Restarting from every // callback caused the old VPN-created-network reconnect loop. _networkTransport = snapshot.transport; @@ -139,32 +149,10 @@ class VpnConnection extends ChangeNotifier { try { final coreVersion = await _vless.getCoreVersion(); - debugPrint('[VPN] Xray core version: $coreVersion'); - } catch (_) {} - - await _adoptRunningRuntime(); - } - - /// Reflect a tunnel that is already up when the UI starts. - /// - /// This asks the OS whether our VPN service process is alive, rather than - /// probing a local SOCKS port. The old probe called getConnectedServerDelay(), - /// which reads AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807 — but - /// AppConfigs lives in the VPN's own process, so from here it is always null - /// and the port always fell back to 10807. That was correct only while the - /// SOCKS port was fixed; per-session ephemeral ports made it permanently - /// wrong, so the app reported "connected" against a dead tunnel. - Future _adoptRunningRuntime() async { - if (kIsWeb) return; - try { - final state = await PowerSettings.runtimeState(); - if (state.aliveFor(tunMode: _activeMode == ConnectionMode.tun)) { - _adoptedRunningRuntime = true; - _setStatus(VpnStatus.connected, _connectedLabel); + if (!_disposed) { + debugPrint('[VPN] Xray core version: $coreVersion'); } - } catch (e) { - debugPrint('[VPN] Runtime liveness check failed: $e'); - } + } catch (_) {} } String get _connectedLabel { @@ -176,9 +164,15 @@ class VpnConnection extends ChangeNotifier { } } + bool _isCurrentConnect(int epoch) => + !_disposed && epoch == _connectEpoch && !_userDisconnecting; + void _mapStatus(VlessStatus status) { + if (_disposed) return; switch (status.connectionState) { case VlessConnectionState.connected: + if (_suppressNativeConnect || _userDisconnecting) return; + if (_connectEpoch == 0) _adoptedRunningRuntime = true; _setStatus(VpnStatus.connected, _connectedLabel); break; @@ -188,6 +182,7 @@ class VpnConnection extends ChangeNotifier { break; case VlessConnectionState.connecting: + if (_suppressNativeConnect || _userDisconnecting) return; _setStatus(VpnStatus.connecting, 'Establishing tunnel…'); break; @@ -196,6 +191,7 @@ class VpnConnection extends ChangeNotifier { break; case VlessConnectionState.unknown: + if (_suppressNativeConnect || _userDisconnecting) return; if (_status != VpnStatus.connected && _status != VpnStatus.disconnected) { _setStatus(VpnStatus.error, 'Connection failed'); @@ -205,27 +201,34 @@ class VpnConnection extends ChangeNotifier { } Future connect({bool skipAdBypass = false}) async { - if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { + if (_disposed || + _userDisconnecting || + _status == VpnStatus.connected || + _status == VpnStatus.connecting || + _status == VpnStatus.disconnecting) { return false; } - _cancelled = false; + final connectEpoch = ++_connectEpoch; + _suppressNativeConnect = false; _userDisconnecting = false; await ConnectionSettings.initialize(); + if (!_isCurrentConnect(connectEpoch)) return false; _activeMode = ConnectionSettings.mode; if (!kIsWeb && !_initialized) { + _suppressNativeConnect = true; _errorMessage = 'VPN service unavailable.'; _setStatus(VpnStatus.error, 'Service unavailable'); return false; } - // 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 (!_isCurrentConnect(connectEpoch)) return false; if (!ok) { + _suppressNativeConnect = true; _errorMessage = 'VPN permission denied.'; _setStatus(VpnStatus.error, 'Permission required'); return false; @@ -241,6 +244,7 @@ class VpnConnection extends ChangeNotifier { if (kIsWeb) { await Future.delayed(const Duration(seconds: 1)); + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus(VpnStatus.connected, 'Secured (dev mode)'); return true; } @@ -252,6 +256,7 @@ class VpnConnection extends ChangeNotifier { realUrl = await HivemindService.fetchConfigDirectly( skipAdBypass: skipAdBypass, onAttempt: (attempt, total) { + if (!_isCurrentConnect(connectEpoch)) return; _setStatus( VpnStatus.connecting, 'Contacting server ($attempt/$total)…', @@ -259,9 +264,11 @@ class VpnConnection extends ChangeNotifier { }, ); } catch (e) { + if (!_isCurrentConnect(connectEpoch)) return false; debugPrint('[VPN] Config fetch error: $e'); final raw = e.toString().replaceAll('Exception: ', ''); if (raw.contains('Cancelled')) return false; + _suppressNativeConnect = true; if (raw.contains('timed out') || raw.contains('Session not activated')) { _errorMessage = 'The server did not respond in time.\nCheck your connection and try again.'; @@ -273,11 +280,7 @@ class VpnConnection extends ChangeNotifier { return false; } - if (_cancelled) { - _setStatus(VpnStatus.disconnected, 'Tap to connect'); - return false; - } - + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus(VpnStatus.connecting, 'Starting secure route…'); try { @@ -285,6 +288,7 @@ class VpnConnection extends ChangeNotifier { final baseConfig = parsed.getFullConfiguration(); final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN'; final secureSocks = await SecureSocksSession.create(baseConfig); + if (!_isCurrentConnect(connectEpoch)) return false; final verifyLocalSocks = _activeMode == ConnectionMode.proxy; await _startRuntime( @@ -292,26 +296,45 @@ class VpnConnection extends ChangeNotifier { remark: remark, proxyOnly: verifyLocalSocks, ); + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus( VpnStatus.connecting, - verifyLocalSocks ? 'Waiting for local SOCKS5…' : 'Waiting for VPN interface…', + verifyLocalSocks + ? 'Waiting for local SOCKS5…' + : 'Waiting for VPN interface…', ); - if (!await _waitForNativeConnected()) { + if (!await _waitForNativeConnected(connectEpoch)) { throw StateError('VPN runtime did not report CONNECTED'); } if (verifyLocalSocks) { + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…'); - if (!await _waitForLocalSocksListener(secureSocks)) { + if (!await _waitForLocalSocksListener( + secureSocks, + connectEpoch, + )) { throw StateError('Local SOCKS5 listener did not become ready'); } } + if (!_isCurrentConnect(connectEpoch)) return false; _lastSecureSocks = secureSocks; } catch (e) { + if (!_isCurrentConnect(connectEpoch)) return false; debugPrint('[VPN] Tunnel start error: $e'); - await _stopRuntime(); + _suppressNativeConnect = true; + try { + await _stopRuntime(); + } catch (stopError) { + debugPrint('[VPN] Runtime cleanup failed after start error: $stopError'); + if (!_isCurrentConnect(connectEpoch)) return false; + _errorMessage = 'VPN failed to shut down cleanly.\nPlease restart the app.'; + _setStatus(VpnStatus.error, 'Shutdown failed'); + return false; + } + if (!_isCurrentConnect(connectEpoch)) return false; _clearRuntimeSnapshot(); _errorMessage = _activeMode == ConnectionMode.proxy ? 'SOCKS5 gateway failed to start.\nTry reconnecting.' @@ -320,6 +343,7 @@ class VpnConnection extends ChangeNotifier { return false; } + if (!_isCurrentConnect(connectEpoch)) return false; _setStatus(VpnStatus.connected, _connectedLabel); return true; } @@ -336,22 +360,20 @@ class VpnConnection extends ChangeNotifier { ); } - Future _waitForLocalSocksListener([ - SecureSocksSession? session, - ]) async { - final active = session ?? _lastSecureSocks; - if (active == null) return false; - + Future _waitForLocalSocksListener( + SecureSocksSession session, + int connectEpoch, + ) async { for (var attempt = 0; attempt < 4; attempt++) { + if (!_isCurrentConnect(connectEpoch)) return false; final result = await LocalSocksTester.testListener( host: '127.0.0.1', - port: active.port, - username: active.username, - password: active.password, + port: session.port, + username: session.username, + password: session.password, ); - if (result.ok) { - return true; - } + if (!_isCurrentConnect(connectEpoch)) return false; + if (result.ok) return true; if (attempt < 3) { await Future.delayed(const Duration(milliseconds: 250)); } @@ -359,54 +381,59 @@ class VpnConnection extends ChangeNotifier { return false; } - Future _waitForNativeConnected() async { - // startVless() queues the Android foreground service. Wait for the native - // service/core broadcast rather than treating MethodChannel completion as - // proof that a TUN interface was established. + Future _waitForNativeConnected(int connectEpoch) async { for (var attempt = 0; attempt < 24; attempt++) { + if (!_isCurrentConnect(connectEpoch)) return false; if (_status == VpnStatus.connected) return true; - if (_status == VpnStatus.error) return false; - if (_cancelled || _userDisconnecting) return false; + if (_status == VpnStatus.error || _status == VpnStatus.disconnected) { + return false; + } await Future.delayed(const Duration(milliseconds: 250)); } - return _status == VpnStatus.connected; + return _isCurrentConnect(connectEpoch) && + _status == VpnStatus.connected; } Future _stopRuntime() async { - try { - await _vless.stopVless().timeout(const Duration(seconds: 5)); - } catch (_) {} + await _vless.stopVless().timeout(const Duration(seconds: 8)); + } + + Future setNativeSessionDeadline(int remainingSeconds) async { + if (kIsWeb) return; + if (_disposed) throw StateError('VPN connection has been disposed'); + if (!_initialized) throw StateError('VPN native service is not initialized'); + if (remainingSeconds < 0) { + throw ArgumentError.value(remainingSeconds, 'remainingSeconds'); + } + await _nativeControl.invokeMethod( + 'setSessionDeadline', + {'remainingSeconds': remainingSeconds}, + ); } Future disconnect() async { - _cancelled = true; + if (_disposed || _status == VpnStatus.disconnecting) return; + + _connectEpoch++; + _suppressNativeConnect = true; _userDisconnecting = true; HivemindService.cancel(); - if (_status == VpnStatus.disconnected || - _status == VpnStatus.disconnecting) { - _clearRuntimeSnapshot(); - _userDisconnecting = false; - return; - } - _setStatus(VpnStatus.disconnecting, 'Tearing down…'); if (kIsWeb) { await Future.delayed(const Duration(milliseconds: 500)); + if (_disposed) return; _clearRuntimeSnapshot(); _setStatus(VpnStatus.disconnected, 'Tap to connect'); _userDisconnecting = false; return; } - bool timedOut = false; try { - await _vless.stopVless().timeout( - const Duration(seconds: 5), - onTimeout: () => timedOut = true, - ); + await _vless.stopVless().timeout(const Duration(seconds: 8)); } catch (e) { + if (_disposed) return; debugPrint('[VPN] VLESS stop error: $e'); _errorMessage = 'VPN shutdown error.\nPlease restart the app.'; _clearRuntimeSnapshot(); @@ -415,15 +442,7 @@ class VpnConnection extends ChangeNotifier { return; } - if (timedOut) { - debugPrint('[VPN] stopVless() timed out after 5 s.'); - _errorMessage = 'VPN did not shut down cleanly.\nPlease restart the app.'; - _clearRuntimeSnapshot(); - _setStatus(VpnStatus.error, 'Shutdown failed'); - _userDisconnecting = false; - return; - } - + if (_disposed) return; _errorMessage = null; _clearRuntimeSnapshot(); _setStatus(VpnStatus.disconnected, 'Tap to connect'); @@ -436,21 +455,27 @@ class VpnConnection extends ChangeNotifier { } void _setStatus(VpnStatus status, String message) { + if (_disposed) return; _status = status; _statusMessage = message; notifyListeners(); } Future _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(); + if (_disposed || _status == VpnStatus.connected) return; + final reachable = await HivemindService.checkHealth(); + if (_disposed || _status == VpnStatus.connected) return; + _serverReachable = reachable; notifyListeners(); } @override void dispose() { + _disposed = true; + _connectEpoch++; + _suppressNativeConnect = true; + _userDisconnecting = true; + HivemindService.cancel(); _healthTimer?.cancel(); _networkSubscription?.cancel(); // Provider/UI disposal is not a user disconnect command. Keeping teardown