diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 4625647..035775d 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; 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; + 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