ReVoltVPN 3.3.5: hardened Android runtime and verified TUN/SOCKS5 #3

Closed
Naveax wants to merge 6 commits from release/3.3.5 into main
pull from: release/3.3.5
6 changed files with 231 additions and 274 deletions
Showing only changes of commit 9f248eb218 - Show all commits

No files matched your search

-3
View File
@@ -10,9 +10,6 @@
<!-- Doze/App Standby stop background services of non-exempt apps. A VPN <!-- Doze/App Standby stop background services of non-exempt apps. A VPN
cannot stay up without this; Play allows it for VPN functionality. --> cannot stay up without this; Play allows it for VPN functionality. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- OS-level session-expiry alarm. Auto-granted on 31-32; on 33+ the daemon
falls back to setAndAllowWhileIdle if exact alarms aren't granted. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. --> <!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
<uses-permission <uses-permission
@@ -3,7 +3,6 @@ package com.paladinvpn.app
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.ActivityManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -21,8 +20,6 @@ import android.os.VibratorManager
import android.provider.Settings import android.provider.Settings
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
@@ -54,24 +51,10 @@ class MainActivity : FlutterActivity() {
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, powerChannel) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, powerChannel)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
when (call.method) { 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" -> "isIgnoringBatteryOptimizations" ->
result.success(isIgnoringBatteryOptimizations()) result.success(isIgnoringBatteryOptimizations())
"requestIgnoreBatteryOptimizations" -> "requestIgnoreBatteryOptimizations" ->
result.success(requestIgnoreBatteryOptimizations()) result.success(requestIgnoreBatteryOptimizations())
"setSessionExpiry" -> {
val expiresAtMs = call.argument<Number>("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() else -> result.notImplemented()
} }
} }
@@ -118,7 +101,6 @@ class MainActivity : FlutterActivity() {
super.onDestroy() super.onDestroy()
} }
/** True when the OS has exempted us from Doze / App Standby. */
private fun isIgnoringBatteryOptimizations(): Boolean { private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
return try { 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 { private fun requestIgnoreBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
if (isIgnoringBatteryOptimizations()) return true if (isIgnoringBatteryOptimizations()) return true
@@ -146,7 +123,6 @@ class MainActivity : FlutterActivity() {
) )
true true
} catch (_: Exception) { } catch (_: Exception) {
// Some OEM builds hide the direct intent; fall back to the list.
try { try {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
true 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<String, Any> {
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) { private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
@@ -216,15 +140,6 @@ class MainActivity : FlutterActivity() {
return 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() ensureNotificationChannel()
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -234,17 +149,28 @@ class MainActivity : FlutterActivity() {
} }
val launchIntent = packageManager.getLaunchIntentForPackage(packageName) val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
launchIntent?.action = "FROM_DISCONNECT_BTN"
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK Intent.FLAG_ACTIVITY_NEW_TASK
val stopIntent = Intent(this, XrayVPNService::class.java) // The VPN service owns the authoritative Disconnect PendingIntent and
stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) // embeds the current runtime-generation token in it. Never recreate an
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) // 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) val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notification_status_icon) .setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle("Revolt VPN") .setContentTitle("Revolt VPN")
@@ -255,7 +181,6 @@ class MainActivity : FlutterActivity() {
.setSmallIcon(R.drawable.notification_status_icon) .setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle(title) .setContentTitle(title)
.setContentText(text) .setContentText(text)
.addAction(0, actionLabel, stopPendingIntent)
.setColorized(true) .setColorized(true)
.setColor(0xFF0D1117.toInt()) .setColor(0xFF0D1117.toInt())
.setOngoing(true) .setOngoing(true)
@@ -267,6 +192,10 @@ class MainActivity : FlutterActivity() {
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion) .setPublicVersion(publicVersion)
if (serviceStopAction != null) {
builder.addAction(0, actionLabel, serviceStopAction)
}
if (launchIntent != null) { if (launchIntent != null) {
builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags)) builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags))
} }
@@ -473,7 +402,6 @@ class MainActivity : FlutterActivity() {
companion object { companion object {
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon" private const val NOTIFICATION_CHANNEL_ID = "REVOLT_VPN_SERVICE"
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
} }
} }
@@ -1,9 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:src="@drawable/notification_icon"
android:height="24dp" android:gravity="center"
android:viewportWidth="24" android:antialias="true" />
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,1.5L21,5.2V11.1C21,16.7 17.4,21 12,22.5C6.6,21 3,16.7 3,11.1V5.2L12,1.5ZM12,5L6.5,7.2V11C6.5,14.8 8.7,17.8 12,19C15.3,17.8 17.5,14.8 17.5,11V7.2L12,5ZM10.8,9H13.2V13.8H10.8V9ZM10.8,15H13.2V17.4H10.8V15Z" />
</vector>
+3 -2
View File
@@ -16,16 +16,17 @@ class NotificationService {
/// Sets the notification's content text to [text]. /// Sets the notification's content text to [text].
/// ///
/// The native side rebuilds the whole notification, so skip the round-trip /// 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<void> updateTimer(String text) async { static Future<void> updateTimer(String text) async {
if (text == _lastText) return; if (text == _lastText) return;
_lastText = text;
try { try {
await _channel.invokeMethod<void>('updateNotificationText', { await _channel.invokeMethod<void>('updateNotificationText', {
'title': 'Revolt VPN', 'title': 'Revolt VPN',
'text': text, 'text': text,
'disconnectLabel': 'Disconnect', 'disconnectLabel': 'Disconnect',
}); });
_lastText = text;
} catch (_) { } catch (_) {
// Cosmetic. // Cosmetic.
} }
+90 -79
View File
@@ -7,13 +7,11 @@ import 'package:revoltvpn/logic/app_config.dart';
import 'package:revoltvpn/logic/crypto_service.dart'; import 'package:revoltvpn/logic/crypto_service.dart';
import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/notification_service.dart'; import 'package:revoltvpn/logic/notification_service.dart';
import 'package:revoltvpn/logic/power_settings.dart';
import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/vpn_connection.dart';
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver { class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
Timer? _timer; Timer? _timer;
int _tickCount = 0; int _tickCount = 0;
int _lastPushedExpiryAtMs = 0;
bool _appBackgrounded = false; bool _appBackgrounded = false;
final VpnConnection vpnConnection; final VpnConnection vpnConnection;
@@ -25,7 +23,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
bool _hasSyncedOnce = false; bool _hasSyncedOnce = false;
int _consecutiveFailures = 0; int _consecutiveFailures = 0;
bool _isDisconnecting = false; bool _isDisconnecting = false;
bool _syncInProgress = false; int? _syncEpochInProgress;
Completer<void>? _syncCompletion;
int _sessionEpoch = 0;
bool _supportRewardClaimed = false; bool _supportRewardClaimed = false;
bool _supportRewardStateLoaded = false; bool _supportRewardStateLoaded = false;
int _supportStateEpoch = 0; int _supportStateEpoch = 0;
@@ -38,25 +39,21 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
static const int _maxOfflineSeconds = 120; static const int _maxOfflineSeconds = 120;
int _offlineSeconds = 0; int _offlineSeconds = 0;
// The countdown ticks locally every second; these only drive the slow static const int _pollIntervalSeconds = 5;
// server heartbeat that catches server-side changes (support-ad extension, static const int _backgroundPollIntervalSeconds = 30;
// 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; int _lastUsedBytes = 0;
DateTime? _lastSuccessfulSyncAt; Stopwatch? _sinceLastSuccessfulSync;
double _currentSpeedKBps = 0.0; double _currentSpeedKBps = 0.0;
SessionTimer({required this.vpnConnection}) { SessionTimer({required this.vpnConnection}) {
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
vpnConnection.addListener(_onVpnConnectionChanged); vpnConnection.addListener(_onVpnConnectionChanged);
unawaited(_loadSupportRewardState()); 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. // VpnConnection is eager and can report an already-running native tunnel
// A listener does not replay history -- evaluate the current state once. // before this provider is created. ChangeNotifier listeners do not replay
// Deferred so we never notify during the build that created us. // old events, so always evaluate the current state once after construction.
unawaited(Future.microtask(_onVpnConnectionChanged)); unawaited(Future.microtask(_onVpnConnectionChanged));
} }
@@ -113,11 +110,8 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
} }
void _onVpnConnectionChanged() { void _onVpnConnectionChanged() {
// A live tunnel with a stopped clock is always wrong, however we got here: // A live tunnel with a stopped clock is always wrong, whether this process
// adopting a tunnel that outlived the UI process, recovering from a blip, or // adopted an existing runtime, attached late, or recovered from a blip.
// 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.
if (vpnConnection.status == VpnStatus.connected && if (vpnConnection.status == VpnStatus.connected &&
!isRunning && !isRunning &&
!_isDisconnecting) { !_isDisconnecting) {
@@ -130,7 +124,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
if (!hadActiveSession || _isDisconnecting) return; if (!hadActiveSession || _isDisconnecting) return;
if (vpnConnection.status == VpnStatus.error) { if (vpnConnection.status == VpnStatus.error) {
_stopForVpnFailure('VPN entered error state'); unawaited(_doDisconnect('VPN entered error state'));
return; 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<void> start() async { Future<void> start() async {
_sessionEpoch++;
_remainingSeconds = 0; _remainingSeconds = 0;
_remainingAtLastSync = 0; _remainingAtLastSync = 0;
_usedBytes = 0; _usedBytes = 0;
_lastUsedBytes = 0; _lastUsedBytes = 0;
_lastSuccessfulSyncAt = null; _sinceLastSuccessfulSync?.stop();
_sinceLastSuccessfulSync = null;
_currentSpeedKBps = 0.0; _currentSpeedKBps = 0.0;
_tickCount = 0; _tickCount = 0;
_lastPushedExpiryAtMs = 0;
_hasSyncedOnce = false; _hasSyncedOnce = false;
_consecutiveFailures = 0; _consecutiveFailures = 0;
_offlineSeconds = 0; _offlineSeconds = 0;
@@ -188,7 +165,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
void _tick(Timer t) { void _tick(Timer t) {
if (_isDisconnecting) return; if (_isDisconnecting) return;
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) { if (_hasSyncedOnce) {
_reconcileElapsedTime(); _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 /// Force a control-plane refresh after an event that changes the session
/// countdown reflects the new expiry without waiting for the next heartbeat). /// server-side. If a periodic sync is already in flight, wait for it and
Future<void> syncNow() => _syncWithHivemind(); /// issue a fresh request so the event cannot be hidden behind the busy guard.
Future<void> 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<void> disconnect({String reason = 'User requested'}) async { Future<void> disconnect({String reason = 'User requested'}) async {
await _doDisconnect(reason); await _doDisconnect(reason);
@@ -231,6 +222,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
Future<void> _doDisconnect(String reason) async { Future<void> _doDisconnect(String reason) async {
if (_isDisconnecting) return; if (_isDisconnecting) return;
_isDisconnecting = true; _isDisconnecting = true;
_sessionEpoch++;
debugPrint('[Timer] Disconnecting: $reason'); debugPrint('[Timer] Disconnecting: $reason');
_timer?.cancel(); _timer?.cancel();
@@ -239,13 +231,13 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_remainingSeconds = 0; _remainingSeconds = 0;
_remainingAtLastSync = 0; _remainingAtLastSync = 0;
_hasSyncedOnce = false; _hasSyncedOnce = false;
_lastSuccessfulSyncAt = null; _sinceLastSuccessfulSync?.stop();
_sinceLastSuccessfulSync = null;
NotificationService.reset(); NotificationService.reset();
notifyListeners(); notifyListeners();
await vpnConnection.disconnect(); await vpnConnection.disconnect();
if (!_isDisconnecting) return;
_isDisconnecting = false; _isDisconnecting = false;
notifyListeners(); notifyListeners();
} }
@@ -257,20 +249,28 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
} }
Future<void> _syncWithHivemind() async { Future<void> _syncWithHivemind() async {
if (_isDisconnecting || _syncInProgress) return; if (_isDisconnecting) return;
_syncInProgress = true; final epoch = _sessionEpoch;
if (_syncEpochInProgress == epoch) return;
final completion = Completer<void>();
_syncCompletion = completion;
_syncEpochInProgress = epoch;
try { try {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
if (epoch != _sessionEpoch || _isDisconnecting) return;
final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status'); final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status');
final url = base.replace(queryParameters: {'device_id': deviceId}); final url = base.replace(queryParameters: {'device_id': deviceId});
final response = await HivemindService.directGet(url); final response = await HivemindService.controlGet(url);
if (_isDisconnecting) return; if (epoch != _sessionEpoch || _isDisconnecting) return;
if (response.statusCode == 200) { if (response.statusCode == 200) {
final decoded = jsonDecode(response.body); final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic>) {
debugPrint('[Timer] Invalid session payload type.'); debugPrint('[Timer] Invalid session payload type.');
_markSyncFailure(); _markSyncFailure(epoch);
return; return;
} }
final data = decoded; final data = decoded;
@@ -278,7 +278,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
final activeValue = data['active']; final activeValue = data['active'];
if (activeValue is! bool) { if (activeValue is! bool) {
debugPrint('[Timer] Session payload missing boolean active state.'); debugPrint('[Timer] Session payload missing boolean active state.');
_markSyncFailure(); _markSyncFailure(epoch);
return; return;
} }
if (!activeValue) { if (!activeValue) {
@@ -291,61 +291,66 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
!expiresValue.isFinite || !expiresValue.isFinite ||
expiresValue < 0) { expiresValue < 0) {
debugPrint('[Timer] Session payload has invalid expiry.'); debugPrint('[Timer] Session payload has invalid expiry.');
_markSyncFailure(); _markSyncFailure(epoch);
return; return;
} }
final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds;
_remainingAtLastSync = expiresValue.toInt(); _remainingAtLastSync = expiresValue.toInt();
_remainingSeconds = _remainingAtLastSync; _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); _usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
final now = DateTime.now();
final previousSyncAt = _lastSuccessfulSyncAt;
final deltaBytes = _usedBytes - _lastUsedBytes; final deltaBytes = _usedBytes - _lastUsedBytes;
if (_hasSyncedOnce && previousSyncAt != null) { if (_hasSyncedOnce && elapsedMs != null && elapsedMs > 0) {
final elapsedMs = now.difference(previousSyncAt).inMilliseconds; _currentSpeedKBps = deltaBytes > 0
if (deltaBytes > 0 && elapsedMs > 0) { ? (deltaBytes / (elapsedMs / 1000.0)) / 1000.0
_currentSpeedKBps = (deltaBytes / (elapsedMs / 1000.0)) / 1000.0; : 0.0;
} else {
_currentSpeedKBps = 0.0;
}
} else { } else {
_currentSpeedKBps = 0.0; _currentSpeedKBps = 0.0;
} }
_lastUsedBytes = _usedBytes; _lastUsedBytes = _usedBytes;
_lastSuccessfulSyncAt = now; _sinceLastSuccessfulSync?.stop();
_sinceLastSuccessfulSync = Stopwatch()..start();
if (data['cap_exhausted'] == true) { if (data['cap_exhausted'] == true) {
await _doDisconnect('Data cap reached'); await _doDisconnect('Data cap reached');
return; 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; _consecutiveFailures = 0;
_offlineSeconds = 0; _offlineSeconds = 0;
_hasSyncedOnce = true; _hasSyncedOnce = true;
notifyListeners(); notifyListeners();
} else { } else {
_markSyncFailure(); _markSyncFailure(epoch);
} }
} catch (e) { } catch (e) {
if (!_isDisconnecting) { if (epoch == _sessionEpoch && !_isDisconnecting) {
debugPrint('Hivemind sync error: $e'); debugPrint('Hivemind sync error: $e');
_markSyncFailure(); _markSyncFailure(epoch);
} }
} finally { } 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++; _consecutiveFailures++;
if (_consecutiveFailures >= _maxConsecutiveFailures) { if (_consecutiveFailures >= _maxConsecutiveFailures) {
notifyListeners(); notifyListeners();
@@ -353,10 +358,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
} }
void _reconcileElapsedTime() { void _reconcileElapsedTime() {
final syncedAt = _lastSuccessfulSyncAt; final clock = _sinceLastSuccessfulSync;
if (!_hasSyncedOnce || syncedAt == null) return; if (!_hasSyncedOnce || clock == null) return;
final elapsed = DateTime.now().difference(syncedAt).inSeconds; final elapsed = clock.elapsed.inSeconds;
_remainingSeconds = _remainingSeconds =
(_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt(); (_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
} }
@@ -396,9 +401,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
@override @override
void dispose() { 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); WidgetsBinding.instance.removeObserver(this);
vpnConnection.removeListener(_onVpnConnectionChanged); vpnConnection.removeListener(_onVpnConnectionChanged);
_timer?.cancel(); _timer?.cancel();
_sinceLastSuccessfulSync?.stop();
super.dispose(); super.dispose();
} }
} }
+112 -87
View File
@@ -1,12 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_vless/flutter_vless.dart'; import 'package:flutter_vless/flutter_vless.dart';
import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart'; import 'package:revoltvpn/logic/local_socks_tester.dart';
import 'package:revoltvpn/logic/network_monitor.dart'; import 'package:revoltvpn/logic/network_monitor.dart';
import 'package:revoltvpn/logic/power_settings.dart';
import 'package:revoltvpn/logic/secure_socks_session.dart'; import 'package:revoltvpn/logic/secure_socks_session.dart';
enum VpnStatus { enum VpnStatus {
@@ -18,7 +18,12 @@ enum VpnStatus {
} }
class VpnConnection extends ChangeNotifier { 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 _status = VpnStatus.disconnected;
VpnStatus get status => _status; VpnStatus get status => _status;
@@ -45,6 +50,7 @@ class VpnConnection extends ChangeNotifier {
SecureSocksSession? get activeSocksSession => _lastSecureSocks; SecureSocksSession? get activeSocksSession => _lastSecureSocks;
bool get canTestActiveLocalSocks => bool get canTestActiveLocalSocks =>
!_disposed &&
_status == VpnStatus.connected && _status == VpnStatus.connected &&
_activeMode == ConnectionMode.proxy && _activeMode == ConnectionMode.proxy &&
_lastSecureSocks != null; _lastSecureSocks != null;
@@ -94,9 +100,10 @@ class VpnConnection extends ChangeNotifier {
} }
Future<void> _startEngine() async { Future<void> _startEngine() async {
if (kIsWeb) return; if (kIsWeb || _disposed) return;
await ConnectionSettings.initialize(); await ConnectionSettings.initialize();
if (_disposed) return;
_activeMode = ConnectionSettings.mode; _activeMode = ConnectionSettings.mode;
_vless = FlutterVless( _vless = FlutterVless(
@@ -115,13 +122,16 @@ class VpnConnection extends ChangeNotifier {
notificationIconResourceType: 'drawable', notificationIconResourceType: 'drawable',
notificationIconResourceName: 'notification_status_icon', notificationIconResourceName: 'notification_status_icon',
); );
if (_disposed) return;
_initialized = true; _initialized = true;
} catch (e) { } catch (e) {
debugPrint('[VPN] VLESS init error (expected on emulator): $e'); debugPrint('[VPN] VLESS init error (expected on emulator): $e');
} }
if (_disposed) return;
_networkSubscription = NetworkMonitor.changes.listen( _networkSubscription = NetworkMonitor.changes.listen(
(snapshot) { (snapshot) {
if (_disposed) return;
// ConnectivityManager is informational only. Restarting from every // ConnectivityManager is informational only. Restarting from every
// callback caused the old VPN-created-network reconnect loop. // callback caused the old VPN-created-network reconnect loop.
_networkTransport = snapshot.transport; _networkTransport = snapshot.transport;
@@ -139,32 +149,10 @@ class VpnConnection extends ChangeNotifier {
try { try {
final coreVersion = await _vless.getCoreVersion(); final coreVersion = await _vless.getCoreVersion();
if (!_disposed) {
debugPrint('[VPN] Xray core version: $coreVersion'); debugPrint('[VPN] Xray core version: $coreVersion');
}
} catch (_) {} } 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<void> _adoptRunningRuntime() async {
if (kIsWeb) return;
try {
final state = await PowerSettings.runtimeState();
if (state.aliveFor(tunMode: _activeMode == ConnectionMode.tun)) {
_adoptedRunningRuntime = true;
_setStatus(VpnStatus.connected, _connectedLabel);
}
} catch (e) {
debugPrint('[VPN] Runtime liveness check failed: $e');
}
} }
String get _connectedLabel { String get _connectedLabel {
@@ -176,9 +164,15 @@ class VpnConnection extends ChangeNotifier {
} }
} }
bool _isCurrentConnect(int epoch) =>
!_disposed && epoch == _connectEpoch && !_userDisconnecting;
void _mapStatus(VlessStatus status) { void _mapStatus(VlessStatus status) {
if (_disposed) return;
switch (status.connectionState) { switch (status.connectionState) {
case VlessConnectionState.connected: case VlessConnectionState.connected:
if (_suppressNativeConnect || _userDisconnecting) return;
if (_connectEpoch == 0) _adoptedRunningRuntime = true;
_setStatus(VpnStatus.connected, _connectedLabel); _setStatus(VpnStatus.connected, _connectedLabel);
break; break;
@@ -188,6 +182,7 @@ class VpnConnection extends ChangeNotifier {
break; break;
case VlessConnectionState.connecting: case VlessConnectionState.connecting:
if (_suppressNativeConnect || _userDisconnecting) return;
_setStatus(VpnStatus.connecting, 'Establishing tunnel…'); _setStatus(VpnStatus.connecting, 'Establishing tunnel…');
break; break;
@@ -196,6 +191,7 @@ class VpnConnection extends ChangeNotifier {
break; break;
case VlessConnectionState.unknown: case VlessConnectionState.unknown:
if (_suppressNativeConnect || _userDisconnecting) return;
if (_status != VpnStatus.connected && if (_status != VpnStatus.connected &&
_status != VpnStatus.disconnected) { _status != VpnStatus.disconnected) {
_setStatus(VpnStatus.error, 'Connection failed'); _setStatus(VpnStatus.error, 'Connection failed');
@@ -205,27 +201,34 @@ class VpnConnection extends ChangeNotifier {
} }
Future<bool> connect({bool skipAdBypass = false}) async { Future<bool> 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; return false;
} }
_cancelled = false; final connectEpoch = ++_connectEpoch;
_suppressNativeConnect = false;
_userDisconnecting = false; _userDisconnecting = false;
await ConnectionSettings.initialize(); await ConnectionSettings.initialize();
if (!_isCurrentConnect(connectEpoch)) return false;
_activeMode = ConnectionSettings.mode; _activeMode = ConnectionSettings.mode;
if (!kIsWeb && !_initialized) { if (!kIsWeb && !_initialized) {
_suppressNativeConnect = true;
_errorMessage = 'VPN service unavailable.'; _errorMessage = 'VPN service unavailable.';
_setStatus(VpnStatus.error, 'Service unavailable'); _setStatus(VpnStatus.error, 'Service unavailable');
return false; 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) { if (!kIsWeb && _activeMode == ConnectionMode.tun) {
final ok = await _vless.requestPermission(); final ok = await _vless.requestPermission();
if (!_isCurrentConnect(connectEpoch)) return false;
if (!ok) { if (!ok) {
_suppressNativeConnect = true;
_errorMessage = 'VPN permission denied.'; _errorMessage = 'VPN permission denied.';
_setStatus(VpnStatus.error, 'Permission required'); _setStatus(VpnStatus.error, 'Permission required');
return false; return false;
@@ -241,6 +244,7 @@ class VpnConnection extends ChangeNotifier {
if (kIsWeb) { if (kIsWeb) {
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus(VpnStatus.connected, 'Secured (dev mode)'); _setStatus(VpnStatus.connected, 'Secured (dev mode)');
return true; return true;
} }
@@ -252,6 +256,7 @@ class VpnConnection extends ChangeNotifier {
realUrl = await HivemindService.fetchConfigDirectly( realUrl = await HivemindService.fetchConfigDirectly(
skipAdBypass: skipAdBypass, skipAdBypass: skipAdBypass,
onAttempt: (attempt, total) { onAttempt: (attempt, total) {
if (!_isCurrentConnect(connectEpoch)) return;
_setStatus( _setStatus(
VpnStatus.connecting, VpnStatus.connecting,
'Contacting server ($attempt/$total)…', 'Contacting server ($attempt/$total)…',
@@ -259,9 +264,11 @@ class VpnConnection extends ChangeNotifier {
}, },
); );
} catch (e) { } catch (e) {
if (!_isCurrentConnect(connectEpoch)) return false;
debugPrint('[VPN] Config fetch error: $e'); debugPrint('[VPN] Config fetch error: $e');
final raw = e.toString().replaceAll('Exception: ', ''); final raw = e.toString().replaceAll('Exception: ', '');
if (raw.contains('Cancelled')) return false; if (raw.contains('Cancelled')) return false;
_suppressNativeConnect = true;
if (raw.contains('timed out') || raw.contains('Session not activated')) { if (raw.contains('timed out') || raw.contains('Session not activated')) {
_errorMessage = _errorMessage =
'The server did not respond in time.\nCheck your connection and try again.'; 'The server did not respond in time.\nCheck your connection and try again.';
@@ -273,11 +280,7 @@ class VpnConnection extends ChangeNotifier {
return false; return false;
} }
if (_cancelled) { if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus(VpnStatus.disconnected, 'Tap to connect');
return false;
}
_setStatus(VpnStatus.connecting, 'Starting secure route…'); _setStatus(VpnStatus.connecting, 'Starting secure route…');
try { try {
@@ -285,6 +288,7 @@ class VpnConnection extends ChangeNotifier {
final baseConfig = parsed.getFullConfiguration(); final baseConfig = parsed.getFullConfiguration();
final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN'; final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
final secureSocks = await SecureSocksSession.create(baseConfig); final secureSocks = await SecureSocksSession.create(baseConfig);
if (!_isCurrentConnect(connectEpoch)) return false;
final verifyLocalSocks = _activeMode == ConnectionMode.proxy; final verifyLocalSocks = _activeMode == ConnectionMode.proxy;
await _startRuntime( await _startRuntime(
@@ -292,26 +296,45 @@ class VpnConnection extends ChangeNotifier {
remark: remark, remark: remark,
proxyOnly: verifyLocalSocks, proxyOnly: verifyLocalSocks,
); );
if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus( _setStatus(
VpnStatus.connecting, 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'); throw StateError('VPN runtime did not report CONNECTED');
} }
if (verifyLocalSocks) { if (verifyLocalSocks) {
if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…'); _setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…');
if (!await _waitForLocalSocksListener(secureSocks)) { if (!await _waitForLocalSocksListener(
secureSocks,
connectEpoch,
)) {
throw StateError('Local SOCKS5 listener did not become ready'); throw StateError('Local SOCKS5 listener did not become ready');
} }
} }
if (!_isCurrentConnect(connectEpoch)) return false;
_lastSecureSocks = secureSocks; _lastSecureSocks = secureSocks;
} catch (e) { } catch (e) {
if (!_isCurrentConnect(connectEpoch)) return false;
debugPrint('[VPN] Tunnel start error: $e'); debugPrint('[VPN] Tunnel start error: $e');
_suppressNativeConnect = true;
try {
await _stopRuntime(); 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(); _clearRuntimeSnapshot();
_errorMessage = _activeMode == ConnectionMode.proxy _errorMessage = _activeMode == ConnectionMode.proxy
? 'SOCKS5 gateway failed to start.\nTry reconnecting.' ? 'SOCKS5 gateway failed to start.\nTry reconnecting.'
@@ -320,6 +343,7 @@ class VpnConnection extends ChangeNotifier {
return false; return false;
} }
if (!_isCurrentConnect(connectEpoch)) return false;
_setStatus(VpnStatus.connected, _connectedLabel); _setStatus(VpnStatus.connected, _connectedLabel);
return true; return true;
} }
@@ -336,22 +360,20 @@ class VpnConnection extends ChangeNotifier {
); );
} }
Future<bool> _waitForLocalSocksListener([ Future<bool> _waitForLocalSocksListener(
SecureSocksSession? session, SecureSocksSession session,
]) async { int connectEpoch,
final active = session ?? _lastSecureSocks; ) async {
if (active == null) return false;
for (var attempt = 0; attempt < 4; attempt++) { for (var attempt = 0; attempt < 4; attempt++) {
if (!_isCurrentConnect(connectEpoch)) return false;
final result = await LocalSocksTester.testListener( final result = await LocalSocksTester.testListener(
host: '127.0.0.1', host: '127.0.0.1',
port: active.port, port: session.port,
username: active.username, username: session.username,
password: active.password, password: session.password,
); );
if (result.ok) { if (!_isCurrentConnect(connectEpoch)) return false;
return true; if (result.ok) return true;
}
if (attempt < 3) { if (attempt < 3) {
await Future.delayed(const Duration(milliseconds: 250)); await Future.delayed(const Duration(milliseconds: 250));
} }
@@ -359,54 +381,59 @@ class VpnConnection extends ChangeNotifier {
return false; return false;
} }
Future<bool> _waitForNativeConnected() async { Future<bool> _waitForNativeConnected(int connectEpoch) 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.
for (var attempt = 0; attempt < 24; attempt++) { for (var attempt = 0; attempt < 24; attempt++) {
if (!_isCurrentConnect(connectEpoch)) return false;
if (_status == VpnStatus.connected) return true; if (_status == VpnStatus.connected) return true;
if (_status == VpnStatus.error) return false; if (_status == VpnStatus.error || _status == VpnStatus.disconnected) {
if (_cancelled || _userDisconnecting) return false; return false;
}
await Future.delayed(const Duration(milliseconds: 250)); await Future.delayed(const Duration(milliseconds: 250));
} }
return _status == VpnStatus.connected; return _isCurrentConnect(connectEpoch) &&
_status == VpnStatus.connected;
} }
Future<void> _stopRuntime() async { Future<void> _stopRuntime() async {
try { await _vless.stopVless().timeout(const Duration(seconds: 8));
await _vless.stopVless().timeout(const Duration(seconds: 5)); }
} catch (_) {}
Future<void> 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<void>(
'setSessionDeadline',
<String, Object>{'remainingSeconds': remainingSeconds},
);
} }
Future<void> disconnect() async { Future<void> disconnect() async {
_cancelled = true; if (_disposed || _status == VpnStatus.disconnecting) return;
_connectEpoch++;
_suppressNativeConnect = true;
_userDisconnecting = true; _userDisconnecting = true;
HivemindService.cancel(); HivemindService.cancel();
if (_status == VpnStatus.disconnected ||
_status == VpnStatus.disconnecting) {
_clearRuntimeSnapshot();
_userDisconnecting = false;
return;
}
_setStatus(VpnStatus.disconnecting, 'Tearing down…'); _setStatus(VpnStatus.disconnecting, 'Tearing down…');
if (kIsWeb) { if (kIsWeb) {
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
if (_disposed) return;
_clearRuntimeSnapshot(); _clearRuntimeSnapshot();
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
_userDisconnecting = false; _userDisconnecting = false;
return; return;
} }
bool timedOut = false;
try { try {
await _vless.stopVless().timeout( await _vless.stopVless().timeout(const Duration(seconds: 8));
const Duration(seconds: 5),
onTimeout: () => timedOut = true,
);
} catch (e) { } catch (e) {
if (_disposed) return;
debugPrint('[VPN] VLESS stop error: $e'); debugPrint('[VPN] VLESS stop error: $e');
_errorMessage = 'VPN shutdown error.\nPlease restart the app.'; _errorMessage = 'VPN shutdown error.\nPlease restart the app.';
_clearRuntimeSnapshot(); _clearRuntimeSnapshot();
@@ -415,15 +442,7 @@ class VpnConnection extends ChangeNotifier {
return; return;
} }
if (timedOut) { if (_disposed) return;
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;
}
_errorMessage = null; _errorMessage = null;
_clearRuntimeSnapshot(); _clearRuntimeSnapshot();
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
@@ -436,21 +455,27 @@ class VpnConnection extends ChangeNotifier {
} }
void _setStatus(VpnStatus status, String message) { void _setStatus(VpnStatus status, String message) {
if (_disposed) return;
_status = status; _status = status;
_statusMessage = message; _statusMessage = message;
notifyListeners(); notifyListeners();
} }
Future<void> _checkHealth() async { Future<void> _checkHealth() async {
// A live tunnel already proves reachability; only probe while disconnected if (_disposed || _status == VpnStatus.connected) return;
// so we don't add direct /health traffic while the VPN is active. final reachable = await HivemindService.checkHealth();
if (_status == VpnStatus.connected) return; if (_disposed || _status == VpnStatus.connected) return;
_serverReachable = await HivemindService.checkHealth(); _serverReachable = reachable;
notifyListeners(); notifyListeners();
} }
@override @override
void dispose() { void dispose() {
_disposed = true;
_connectEpoch++;
_suppressNativeConnect = true;
_userDisconnecting = true;
HivemindService.cancel();
_healthTimer?.cancel(); _healthTimer?.cancel();
_networkSubscription?.cancel(); _networkSubscription?.cancel();
// Provider/UI disposal is not a user disconnect command. Keeping teardown // Provider/UI disposal is not a user disconnect command. Keeping teardown