ReVoltVPN 3.3.5: hardened Android runtime and verified TUN/SOCKS5 #3
No files matched your search
@@ -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>
|
|
||||||
@@ -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.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user