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