feat(app): keep the tunnel alive via the OS, drop the workarounds
3.3.1 could display "connected" against a tunnel that was no longer running, with a frozen countdown and no traffic. Three causes, and the fix for each removes more code than it adds. Liveness was read from getConnectedServerDelay(), which measures through AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807. AppConfigs lives in the :RunSoLibXrayDaemon process, so from the UI process it is always null and the port always fell back to 10807 -- accidentally correct while the SOCKS port was fixed, permanently wrong once 3.3.1 made it ephemeral. Liveness now comes from the OS: is our service process running, and is a VPN transport actually present. The foreground notification (id 1) is owned by XrayVPNService via startForeground, but MainActivity was posting to the same id from the UI process. When that process died its notification stayed on screen with nothing left to update it. MainActivity now refuses to post for a dead runtime and clears the stale notification instead. Nothing ever asked Android for permission to stay alive. Adds the battery-optimisation exemption prompt and a deep link to Always-on VPN -- the two sanctioned mechanisms for background survival, each needing one user tap. Removes Standard/Extreme resilience and the runtime-restart machinery: recovery ran off a disconnect broadcast delivered to the app process, so it could never fire when Android killed that process. Removes startup restoration and the config-snapshot rebuild along with it; with the OS keeping the tunnel up there is nothing to restore. Version 3.3.2+28. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
ac52681452
commit
186dc906da
12 files changed
+420
-289
No files matched your search
@@ -5,6 +5,44 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [3.3.2] — 2026-09-01
|
||||||
|
|
||||||
|
Background reliability. 3.3.1 could show "connected" against a tunnel that was no
|
||||||
|
longer running; this release fixes the cause rather than the symptom, and removes the
|
||||||
|
machinery that existed to work around it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Background reliability settings.** Android stops background services of apps that
|
||||||
|
are not exempt from Doze, and tears down the process group when a task is swiped
|
||||||
|
away. Two OS mechanisms prevent that, and the app now surfaces both: a one-tap
|
||||||
|
**battery optimisation exemption**, and a deep link to Android's **Always-on VPN**
|
||||||
|
settings (with a note about "Block connections without VPN"). Neither can be enabled
|
||||||
|
programmatically — each needs a single user tap, once.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The app could report "connected" with no traffic flowing.** Startup state came from
|
||||||
|
`getConnectedServerDelay()`, which measures through
|
||||||
|
`AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807`. `AppConfigs` lives in the VPN's
|
||||||
|
own process, so from the UI process it is always null and the port always fell back to
|
||||||
|
10807. That was accidentally correct while the SOCKS port was fixed; 3.3.1's
|
||||||
|
per-session ephemeral ports made it permanently wrong. Liveness is now determined from
|
||||||
|
the OS — whether our VPN service process is running, and whether a VPN transport is
|
||||||
|
actually present.
|
||||||
|
- **A frozen countdown could outlive the tunnel.** The foreground notification (id 1) is
|
||||||
|
owned by the VPN service via `startForeground`, but the UI process was posting to the
|
||||||
|
same id. When that process died, its notification stayed on screen with nothing left to
|
||||||
|
update it. The app no longer posts for a runtime that is not alive, and clears a stale
|
||||||
|
notification instead.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Standard/Extreme resilience modes** and the runtime-restart machinery behind them.
|
||||||
|
Recovery ran off a disconnect broadcast delivered to the app process, so it could never
|
||||||
|
fire in the case that actually matters — Android killing that process. It was
|
||||||
|
complexity substituting for background persistence.
|
||||||
|
- **Startup restoration**, including the config-snapshot rebuild. With the tunnel kept
|
||||||
|
alive by the OS, there is nothing to restore; the app simply reports what is running.
|
||||||
|
- The `resilience_mode` preference and its settings tile.
|
||||||
|
|
||||||
## [3.3.1] — 2026-08-31
|
## [3.3.1] — 2026-08-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<!-- Doze/App Standby stop background services of non-exempt apps. A VPN
|
||||||
|
cannot stay up without this; Play allows it for VPN functionality. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
|
|
||||||
<!-- 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,6 +3,7 @@ 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
|
||||||
@@ -11,10 +12,13 @@ import android.net.ConnectivityManager
|
|||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
import android.os.Vibrator
|
import android.os.Vibrator
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
|
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.service.XrayVPNService
|
||||||
@@ -29,6 +33,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
private val notificationChannel = "com.revoltvpn.app/notification"
|
private val notificationChannel = "com.revoltvpn.app/notification"
|
||||||
private val hapticsChannel = "com.revoltvpn.app/haptics"
|
private val hapticsChannel = "com.revoltvpn.app/haptics"
|
||||||
private val networkChannel = "com.revoltvpn.app/network"
|
private val networkChannel = "com.revoltvpn.app/network"
|
||||||
|
private val powerChannel = "com.revoltvpn.app/power"
|
||||||
|
|
||||||
private var notificationChannelReady = false
|
private var notificationChannelReady = false
|
||||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||||
@@ -46,6 +51,28 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, powerChannel)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
// Doze and App Standby stop background services of apps that
|
||||||
|
// are not exempt. A VPN has to be exempt to survive the screen
|
||||||
|
// going off, so surface the real state rather than guessing.
|
||||||
|
"isIgnoringBatteryOptimizations" ->
|
||||||
|
result.success(isIgnoringBatteryOptimizations())
|
||||||
|
"requestIgnoreBatteryOptimizations" ->
|
||||||
|
result.success(requestIgnoreBatteryOptimizations())
|
||||||
|
// Android's own Always-on VPN is the only thing that keeps a
|
||||||
|
// tunnel up across swipe-from-recents and reboot. It cannot be
|
||||||
|
// toggled programmatically, so deep-link the user to it.
|
||||||
|
"openVpnSettings" -> result.success(openVpnSettings())
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
if (call.method == "updateNotificationText") {
|
if (call.method == "updateNotificationText") {
|
||||||
@@ -88,6 +115,98 @@ class MainActivity : FlutterActivity() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the OS has exempted us from Doze / App Standby. */
|
||||||
|
private fun isIgnoringBatteryOptimizations(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||||
|
return try {
|
||||||
|
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
pm.isIgnoringBatteryOptimizations(packageName)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the system exemption prompt. Android shows this as a single dialog;
|
||||||
|
* it cannot be granted silently, so the caller must treat a false return as
|
||||||
|
* "ask again later" rather than a denial.
|
||||||
|
*/
|
||||||
|
private fun requestIgnoreBatteryOptimizations(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||||
|
if (isIgnoringBatteryOptimizations()) return true
|
||||||
|
return try {
|
||||||
|
startActivity(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||||
|
Uri.parse("package:$packageName"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Some OEM builds hide the direct intent; fall back to the list.
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep-links to the OS VPN settings, where Always-on VPN lives. */
|
||||||
|
private fun openVpnSettings(): Boolean {
|
||||||
|
return try {
|
||||||
|
startActivity(
|
||||||
|
Intent(Settings.ACTION_VPN_SETTINGS)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) !=
|
||||||
@@ -96,6 +215,15 @@ 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) {
|
||||||
@@ -344,6 +472,7 @@ 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 = "XRAY_SERVICE_CHANNEL"
|
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
enum ConnectionMode { tun, proxy }
|
enum ConnectionMode { tun, proxy }
|
||||||
enum ResilienceMode { standard, extreme }
|
|
||||||
|
|
||||||
abstract final class ConnectionSettings {
|
abstract final class ConnectionSettings {
|
||||||
ConnectionSettings._();
|
ConnectionSettings._();
|
||||||
|
|
||||||
static const String _modeKey = 'connection_mode';
|
static const String _modeKey = 'connection_mode';
|
||||||
static const String _resilienceModeKey = 'resilience_mode';
|
|
||||||
|
|
||||||
static ConnectionMode _mode = ConnectionMode.tun;
|
static ConnectionMode _mode = ConnectionMode.tun;
|
||||||
static ResilienceMode _resilienceMode = ResilienceMode.standard;
|
|
||||||
static bool _initialized = false;
|
static bool _initialized = false;
|
||||||
|
|
||||||
static ConnectionMode get mode => _mode;
|
static ConnectionMode get mode => _mode;
|
||||||
static ResilienceMode get resilienceMode => _resilienceMode;
|
|
||||||
|
|
||||||
static Future<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
@@ -28,11 +24,6 @@ abstract final class ConnectionSettings {
|
|||||||
? ConnectionMode.proxy
|
? ConnectionMode.proxy
|
||||||
: ConnectionMode.tun;
|
: ConnectionMode.tun;
|
||||||
|
|
||||||
_resilienceMode =
|
|
||||||
prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name
|
|
||||||
? ResilienceMode.extreme
|
|
||||||
: ResilienceMode.standard;
|
|
||||||
|
|
||||||
if (storedMode != _mode.name) {
|
if (storedMode != _mode.name) {
|
||||||
await prefs.setString(_modeKey, _mode.name);
|
await prefs.setString(_modeKey, _mode.name);
|
||||||
}
|
}
|
||||||
@@ -46,11 +37,4 @@ abstract final class ConnectionSettings {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_modeKey, mode.name);
|
await prefs.setString(_modeKey, mode.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setResilienceMode(ResilienceMode mode) async {
|
|
||||||
_resilienceMode = mode;
|
|
||||||
_initialized = true;
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(_resilienceModeKey, mode.name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Background-survival controls.
|
||||||
|
///
|
||||||
|
/// Android stops background services of apps that are not exempt from Doze and
|
||||||
|
/// App Standby, and it tears down the whole process group when a task is swiped
|
||||||
|
/// away. Those are OS behaviours, not app bugs, and the OS provides exactly two
|
||||||
|
/// sanctioned ways around them: a battery-optimisation exemption, and the
|
||||||
|
/// system's own Always-on VPN. Both need one user tap; neither can be forced.
|
||||||
|
abstract final class PowerSettings {
|
||||||
|
PowerSettings._();
|
||||||
|
|
||||||
|
static const MethodChannel _channel = MethodChannel(
|
||||||
|
'com.revoltvpn.app/power',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Whether the OS has exempted this app from Doze / App Standby.
|
||||||
|
static Future<bool> isBatteryOptimisationDisabled() async {
|
||||||
|
if (kIsWeb) return true;
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod<bool>(
|
||||||
|
'isIgnoringBatteryOptimizations',
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the system exemption dialog. Returns false only when no system UI
|
||||||
|
/// could be opened at all — a user declining still returns true.
|
||||||
|
static Future<bool> requestDisableBatteryOptimisation() async {
|
||||||
|
if (kIsWeb) return true;
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod<bool>(
|
||||||
|
'requestIgnoreBatteryOptimizations',
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the OS VPN settings screen, where Always-on VPN is configured.
|
||||||
|
static Future<bool> openVpnSettings() async {
|
||||||
|
if (kIsWeb) return false;
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod<bool>('openVpnSettings') ?? false;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the VPN runtime is genuinely alive.
|
||||||
|
///
|
||||||
|
/// The Xray service runs in a separate process, so nothing it stores in
|
||||||
|
/// statics is visible to us. This asks the OS instead: is our service process
|
||||||
|
/// running, and (for TUN) is there an active VPN transport?
|
||||||
|
static Future<VpnRuntimeState> runtimeState() async {
|
||||||
|
if (kIsWeb) return const VpnRuntimeState(processAlive: false, vpnTransport: false);
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMapMethod<String, dynamic>(
|
||||||
|
'isVpnRuntimeAlive',
|
||||||
|
);
|
||||||
|
return VpnRuntimeState(
|
||||||
|
processAlive: result?['processAlive'] == true,
|
||||||
|
vpnTransport: result?['vpnTransport'] == true,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const VpnRuntimeState(processAlive: false, vpnTransport: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class VpnRuntimeState {
|
||||||
|
final bool processAlive;
|
||||||
|
final bool vpnTransport;
|
||||||
|
|
||||||
|
const VpnRuntimeState({
|
||||||
|
required this.processAlive,
|
||||||
|
required this.vpnTransport,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// TUN needs both a live service and a real VPN interface. Proxy mode has no
|
||||||
|
/// VPN interface at all, so the service process is the whole signal.
|
||||||
|
bool aliveFor({required bool tunMode}) =>
|
||||||
|
processAlive && (!tunMode || vpnTransport);
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
void _onVpnConnectionChanged() {
|
void _onVpnConnectionChanged() {
|
||||||
if (vpnConnection.status == VpnStatus.connected &&
|
if (vpnConnection.status == VpnStatus.connected &&
|
||||||
!isRunning &&
|
!isRunning &&
|
||||||
vpnConnection.isStartupRestoration) {
|
vpnConnection.adoptedRunningRuntime) {
|
||||||
_resumeTicking();
|
_resumeTicking();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-183
@@ -6,6 +6,7 @@ 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 {
|
||||||
@@ -17,8 +18,6 @@ enum VpnStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VpnConnection extends ChangeNotifier {
|
class VpnConnection extends ChangeNotifier {
|
||||||
static const int _maxExtremeRecoveryAttempts = 2;
|
|
||||||
|
|
||||||
bool _cancelled = false;
|
bool _cancelled = false;
|
||||||
VpnStatus _status = VpnStatus.disconnected;
|
VpnStatus _status = VpnStatus.disconnected;
|
||||||
VpnStatus get status => _status;
|
VpnStatus get status => _status;
|
||||||
@@ -29,8 +28,10 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
|
|
||||||
bool _isStartupRestoration = false;
|
/// True when the UI attached to a tunnel that was already running, rather
|
||||||
bool get isStartupRestoration => _isStartupRestoration;
|
/// than starting one itself. The session timer uses this to resume ticking.
|
||||||
|
bool _adoptedRunningRuntime = false;
|
||||||
|
bool get adoptedRunningRuntime => _adoptedRunningRuntime;
|
||||||
|
|
||||||
bool _serverReachable = false;
|
bool _serverReachable = false;
|
||||||
bool get serverReachable => _serverReachable;
|
bool get serverReachable => _serverReachable;
|
||||||
@@ -38,9 +39,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
ConnectionMode _activeMode = ConnectionMode.tun;
|
ConnectionMode _activeMode = ConnectionMode.tun;
|
||||||
ConnectionMode get activeMode => _activeMode;
|
ConnectionMode get activeMode => _activeMode;
|
||||||
|
|
||||||
ResilienceMode _activeResilienceMode = ResilienceMode.standard;
|
|
||||||
ResilienceMode get activeResilienceMode => _activeResilienceMode;
|
|
||||||
|
|
||||||
String _networkTransport = 'unknown';
|
String _networkTransport = 'unknown';
|
||||||
String get networkTransport => _networkTransport;
|
String get networkTransport => _networkTransport;
|
||||||
|
|
||||||
@@ -70,16 +68,10 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Timer? _healthTimer;
|
Timer? _healthTimer;
|
||||||
Timer? _restartTimer;
|
|
||||||
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
||||||
|
|
||||||
String? _lastBaseConfig;
|
|
||||||
String _lastRemark = 'Revolt VPN';
|
|
||||||
bool _lastVerifyLocalSocks = false;
|
|
||||||
SecureSocksSession? _lastSecureSocks;
|
SecureSocksSession? _lastSecureSocks;
|
||||||
bool _restartInProgress = false;
|
|
||||||
bool _userDisconnecting = false;
|
bool _userDisconnecting = false;
|
||||||
int _connectionEpoch = 0;
|
|
||||||
|
|
||||||
late final FlutterVless _vless;
|
late final FlutterVless _vless;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
@@ -88,7 +80,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
|
|
||||||
VpnConnection() {
|
VpnConnection() {
|
||||||
_activeMode = ConnectionSettings.mode;
|
_activeMode = ConnectionSettings.mode;
|
||||||
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +98,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
|
|
||||||
await ConnectionSettings.initialize();
|
await ConnectionSettings.initialize();
|
||||||
_activeMode = ConnectionSettings.mode;
|
_activeMode = ConnectionSettings.mode;
|
||||||
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
|
||||||
|
|
||||||
_vless = FlutterVless(
|
_vless = FlutterVless(
|
||||||
onStatusChanged: (status) {
|
onStatusChanged: (status) {
|
||||||
@@ -150,59 +140,30 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final coreVersion = await _vless.getCoreVersion();
|
final coreVersion = await _vless.getCoreVersion();
|
||||||
debugPrint('[VPN] Xray core version: $coreVersion');
|
debugPrint('[VPN] Xray core version: $coreVersion');
|
||||||
|
|
||||||
final delay = await _vless.getConnectedServerDelay();
|
|
||||||
if (delay > 0) {
|
|
||||||
_isStartupRestoration = true;
|
|
||||||
_lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
|
||||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
|
||||||
unawaited(_restoreRecoverySnapshot());
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
await _adoptRunningRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Android can kill the Dart process while the VPN foreground service keeps
|
/// Reflect a tunnel that is already up when the UI starts.
|
||||||
/// running, which leaves a fresh [VpnConnection] attached to a live tunnel it
|
|
||||||
/// holds no config snapshot for — so Extreme recovery has nothing to restart
|
|
||||||
/// from. The Hivemind session is still active, so `/session/status` returns
|
|
||||||
/// the same VLESS parameters and the snapshot can be rebuilt without opening
|
|
||||||
/// a new session or running the ad bypass.
|
|
||||||
///
|
///
|
||||||
/// TUN only. This mints *fresh* SOCKS credentials, which is invisible in TUN
|
/// This asks the OS whether our VPN service process is alive, rather than
|
||||||
/// because only tun2socks consumes them. In proxy mode those credentials are
|
/// probing a local SOCKS port. The old probe called getConnectedServerDelay(),
|
||||||
/// the user's own client configuration, so rotating them behind their back
|
/// which reads AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807 — but
|
||||||
/// would silently break it — that case is surfaced in the UI instead.
|
/// AppConfigs lives in the VPN's own process, so from here it is always null
|
||||||
Future<void> _restoreRecoverySnapshot() async {
|
/// and the port always fell back to 10807. That was correct only while the
|
||||||
if (kIsWeb || _activeMode != ConnectionMode.tun) return;
|
/// SOCKS port was fixed; per-session ephemeral ports made it permanently
|
||||||
|
/// wrong, so the app reported "connected" against a dead tunnel.
|
||||||
final epoch = _connectionEpoch;
|
Future<void> _adoptRunningRuntime() async {
|
||||||
bool stale() =>
|
if (kIsWeb) return;
|
||||||
_connectionEpoch != epoch ||
|
|
||||||
_userDisconnecting ||
|
|
||||||
_status != VpnStatus.connected;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final realUrl = await HivemindService.fetchConfigDirectly(
|
final state = await PowerSettings.runtimeState();
|
||||||
skipAdBypass: true,
|
if (state.aliveFor(tunMode: _activeMode == ConnectionMode.tun)) {
|
||||||
);
|
_adoptedRunningRuntime = true;
|
||||||
// A real connect() may have landed while the fetch was in flight; never
|
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||||
// overwrite a snapshot that belongs to a live connection.
|
}
|
||||||
if (stale() || _lastBaseConfig != null) return;
|
|
||||||
|
|
||||||
final parsed = FlutterVless.parse(realUrl);
|
|
||||||
final secureSocks = await SecureSocksSession.create(
|
|
||||||
parsed.getFullConfiguration(),
|
|
||||||
);
|
|
||||||
if (stale() || _lastBaseConfig != null) return;
|
|
||||||
|
|
||||||
_lastBaseConfig = secureSocks.configJson;
|
|
||||||
_lastRemark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
|
|
||||||
_lastVerifyLocalSocks = false;
|
|
||||||
debugPrint('[VPN] Recovery snapshot rebuilt after process restart');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Best-effort: without it the tunnel still works, only Extreme recovery
|
debugPrint('[VPN] Runtime liveness check failed: $e');
|
||||||
// stays unavailable until the next manual connect.
|
|
||||||
debugPrint('[VPN] Could not rebuild recovery snapshot: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,40 +183,21 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.disconnected:
|
case VlessConnectionState.disconnected:
|
||||||
// Repeated DISCONNECTED broadcasts during an Extreme recovery debounce
|
|
||||||
// must not clear the config snapshot the pending recovery needs.
|
|
||||||
if (_restartInProgress || (_restartTimer?.isActive ?? false)) return;
|
|
||||||
|
|
||||||
final canRecover = !_userDisconnecting &&
|
|
||||||
_activeResilienceMode == ResilienceMode.extreme &&
|
|
||||||
_lastBaseConfig != null &&
|
|
||||||
_status == VpnStatus.connected;
|
|
||||||
if (canRecover) {
|
|
||||||
_scheduleRuntimeRestart('Runtime disconnected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearRuntimeSnapshot();
|
_clearRuntimeSnapshot();
|
||||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.connecting:
|
case VlessConnectionState.connecting:
|
||||||
if (!_restartInProgress) {
|
|
||||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.disconnecting:
|
case VlessConnectionState.disconnecting:
|
||||||
_isStartupRestoration = false;
|
|
||||||
if (!_restartInProgress) {
|
|
||||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case VlessConnectionState.unknown:
|
case VlessConnectionState.unknown:
|
||||||
if (_status != VpnStatus.connected &&
|
if (_status != VpnStatus.connected &&
|
||||||
_status != VpnStatus.disconnected &&
|
_status != VpnStatus.disconnected) {
|
||||||
!_restartInProgress) {
|
|
||||||
_setStatus(VpnStatus.error, 'Connection failed');
|
_setStatus(VpnStatus.error, 'Connection failed');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -269,12 +211,9 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
|
|
||||||
_cancelled = false;
|
_cancelled = false;
|
||||||
_userDisconnecting = false;
|
_userDisconnecting = false;
|
||||||
_restartTimer?.cancel();
|
|
||||||
_connectionEpoch++;
|
|
||||||
|
|
||||||
await ConnectionSettings.initialize();
|
await ConnectionSettings.initialize();
|
||||||
_activeMode = ConnectionSettings.mode;
|
_activeMode = ConnectionSettings.mode;
|
||||||
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
|
||||||
|
|
||||||
if (!kIsWeb && !_initialized) {
|
if (!kIsWeb && !_initialized) {
|
||||||
_errorMessage = 'VPN service unavailable.';
|
_errorMessage = 'VPN service unavailable.';
|
||||||
@@ -369,10 +308,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastBaseConfig = secureSocks.configJson;
|
|
||||||
_lastSecureSocks = secureSocks;
|
_lastSecureSocks = secureSocks;
|
||||||
_lastRemark = remark;
|
|
||||||
_lastVerifyLocalSocks = verifyLocalSocks;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VPN] Tunnel start error: $e');
|
debugPrint('[VPN] Tunnel start error: $e');
|
||||||
await _stopRuntime();
|
await _stopRuntime();
|
||||||
@@ -436,96 +372,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
return _status == VpnStatus.connected;
|
return _status == VpnStatus.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleRuntimeRestart(String reason) {
|
|
||||||
if (_activeResilienceMode != ResilienceMode.extreme ||
|
|
||||||
_restartInProgress ||
|
|
||||||
_userDisconnecting ||
|
|
||||||
_lastBaseConfig == null ||
|
|
||||||
(_restartTimer?.isActive ?? false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_setStatus(VpnStatus.connecting, 'Reconnecting…');
|
|
||||||
_restartTimer = Timer(const Duration(milliseconds: 1200), () {
|
|
||||||
unawaited(_restartActiveRuntime(reason));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _restartActiveRuntime(String reason) async {
|
|
||||||
if (_restartInProgress || _userDisconnecting || _lastBaseConfig == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_restartInProgress = true;
|
|
||||||
final epoch = _connectionEpoch;
|
|
||||||
Object? lastError;
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (var attempt = 1; attempt <= _maxExtremeRecoveryAttempts; attempt++) {
|
|
||||||
if (_connectionEpoch != epoch || _userDisconnecting || _lastBaseConfig == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
debugPrint(
|
|
||||||
'[VPN] Extreme recovery attempt '
|
|
||||||
'$attempt/$_maxExtremeRecoveryAttempts: $reason',
|
|
||||||
);
|
|
||||||
|
|
||||||
await _stopRuntime();
|
|
||||||
if (_userDisconnecting) return;
|
|
||||||
|
|
||||||
await Future.delayed(
|
|
||||||
Duration(milliseconds: attempt == 1 ? 600 : 900),
|
|
||||||
);
|
|
||||||
if (_userDisconnecting || _lastBaseConfig == null) return;
|
|
||||||
|
|
||||||
await _startRuntime(
|
|
||||||
config: _lastBaseConfig!,
|
|
||||||
remark: _lastRemark,
|
|
||||||
proxyOnly: _activeMode == ConnectionMode.proxy,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!await _waitForNativeConnected()) {
|
|
||||||
throw StateError('VPN runtime did not recover');
|
|
||||||
}
|
|
||||||
if (_lastVerifyLocalSocks && !(await _waitForLocalSocksListener())) {
|
|
||||||
throw StateError('Local SOCKS5 listener did not recover');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_userDisconnecting) return;
|
|
||||||
|
|
||||||
_errorMessage = null;
|
|
||||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
lastError = e;
|
|
||||||
debugPrint(
|
|
||||||
'[VPN] Extreme recovery attempt '
|
|
||||||
'$attempt/$_maxExtremeRecoveryAttempts failed: $e',
|
|
||||||
);
|
|
||||||
await _stopRuntime();
|
|
||||||
if (_userDisconnecting) return;
|
|
||||||
|
|
||||||
if (attempt < _maxExtremeRecoveryAttempts) {
|
|
||||||
_setStatus(
|
|
||||||
VpnStatus.connecting,
|
|
||||||
'Reconnecting… (${attempt + 1}/$_maxExtremeRecoveryAttempts)',
|
|
||||||
);
|
|
||||||
await Future.delayed(const Duration(milliseconds: 700));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_userDisconnecting) return;
|
|
||||||
|
|
||||||
debugPrint('[VPN] Extreme recovery exhausted: $lastError');
|
|
||||||
_clearRuntimeSnapshot();
|
|
||||||
_errorMessage = 'Could not restore the connection.';
|
|
||||||
_setStatus(VpnStatus.error, 'Reconnect failed');
|
|
||||||
} finally {
|
|
||||||
_restartInProgress = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _stopRuntime() async {
|
Future<void> _stopRuntime() async {
|
||||||
try {
|
try {
|
||||||
await _vless.stopVless().timeout(const Duration(seconds: 5));
|
await _vless.stopVless().timeout(const Duration(seconds: 5));
|
||||||
@@ -535,7 +381,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
_cancelled = true;
|
_cancelled = true;
|
||||||
_userDisconnecting = true;
|
_userDisconnecting = true;
|
||||||
_restartTimer?.cancel();
|
|
||||||
HivemindService.cancel();
|
HivemindService.cancel();
|
||||||
|
|
||||||
if (_status == VpnStatus.disconnected ||
|
if (_status == VpnStatus.disconnected ||
|
||||||
@@ -586,10 +431,8 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _clearRuntimeSnapshot() {
|
void _clearRuntimeSnapshot() {
|
||||||
_lastBaseConfig = null;
|
|
||||||
_lastVerifyLocalSocks = false;
|
|
||||||
_lastSecureSocks = null;
|
_lastSecureSocks = null;
|
||||||
_isStartupRestoration = false;
|
_adoptedRunningRuntime = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setStatus(VpnStatus status, String message) {
|
void _setStatus(VpnStatus status, String message) {
|
||||||
@@ -609,7 +452,6 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_healthTimer?.cancel();
|
_healthTimer?.cancel();
|
||||||
_restartTimer?.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
|
||||||
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/logic/power_settings.dart';
|
||||||
|
|
||||||
|
/// Keeping the tunnel alive is an OS concern, not an app one.
|
||||||
|
///
|
||||||
|
/// Android stops background services of apps that are not exempt from Doze, and
|
||||||
|
/// removes the whole process group when its task is swiped away. There are
|
||||||
|
/// exactly two sanctioned ways around that, both requiring one user tap, and
|
||||||
|
/// neither can be enabled programmatically. This tile surfaces both.
|
||||||
|
class BackgroundReliabilityTile extends StatefulWidget {
|
||||||
|
const BackgroundReliabilityTile({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BackgroundReliabilityTile> createState() =>
|
||||||
|
_BackgroundReliabilityTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BackgroundReliabilityTileState extends State<BackgroundReliabilityTile>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
bool? _batteryExempt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// The exemption is granted in a system dialog, so the only reliable moment
|
||||||
|
// to re-read it is when we come back to the foreground.
|
||||||
|
if (state == AppLifecycleState.resumed) _refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
final exempt = await PowerSettings.isBatteryOptimisationDisabled();
|
||||||
|
if (mounted) setState(() => _batteryExempt = exempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestExemption() async {
|
||||||
|
final opened = await PowerSettings.requestDisableBatteryOptimisation();
|
||||||
|
if (!mounted || opened) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not open battery settings on this device.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openVpnSettings() async {
|
||||||
|
final opened = await PowerSettings.openVpnSettings();
|
||||||
|
if (!mounted || opened) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not open VPN settings.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final exempt = _batteryExempt;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Background reliability',
|
||||||
|
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Android stops background apps to save power. These two settings '
|
||||||
|
'are what keep the VPN running when the app is closed.',
|
||||||
|
style: TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
exempt == null
|
||||||
|
? 'Battery optimisation: checking…'
|
||||||
|
: exempt
|
||||||
|
? 'Battery optimisation: disabled for Revolt VPN ✓'
|
||||||
|
: 'Battery optimisation: still active — the system may '
|
||||||
|
'stop the VPN in the background.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: exempt == true ? AppColors.accent : AppColors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (exempt == false) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _requestExemption,
|
||||||
|
icon: const Icon(Icons.battery_saver),
|
||||||
|
label: const Text('Allow background activity'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Always-on VPN keeps the tunnel up even after the app is closed '
|
||||||
|
'or the phone restarts. Turn it on for Revolt VPN in Android\'s '
|
||||||
|
'VPN settings, and enable "Block connections without VPN" if you '
|
||||||
|
'want traffic to stop rather than leak when it drops.',
|
||||||
|
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _openVpnSettings,
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
label: const Text('Open VPN settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
|||||||
|
|
||||||
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
|
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
|
||||||
final orphaned =
|
final orphaned =
|
||||||
vpn.status == VpnStatus.connected && vpn.isStartupRestoration;
|
vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
@@ -174,7 +174,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
|||||||
// memory that died with it. Saying "Connect" there would be a
|
// memory that died with it. Saying "Connect" there would be a
|
||||||
// lie — the user is already connected.
|
// lie — the user is already connected.
|
||||||
final orphaned = vpn.status == VpnStatus.connected &&
|
final orphaned = vpn.status == VpnStatus.connected &&
|
||||||
vpn.isStartupRestoration;
|
vpn.adoptedRunningRuntime;
|
||||||
if (orphaned) {
|
if (orphaned) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
|
||||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
|
||||||
|
|
||||||
class ResilienceModeTile extends StatefulWidget {
|
|
||||||
const ResilienceModeTile({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ResilienceModeTile> createState() => _ResilienceModeTileState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ResilienceModeTileState extends State<ResilienceModeTile> {
|
|
||||||
ResilienceMode _mode = ConnectionSettings.resilienceMode;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _load() async {
|
|
||||||
await ConnectionSettings.initialize();
|
|
||||||
if (mounted) setState(() => _mode = ConnectionSettings.resilienceMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _vpnBusy() {
|
|
||||||
final status = context.read<VpnConnection>().status;
|
|
||||||
return status == VpnStatus.connected ||
|
|
||||||
status == VpnStatus.connecting ||
|
|
||||||
status == VpnStatus.disconnecting;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _change(ResilienceMode? next) async {
|
|
||||||
if (next == null || next == _mode) return;
|
|
||||||
if (_vpnBusy()) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Disconnect before changing resilience mode.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ConnectionSettings.setResilienceMode(next);
|
|
||||||
if (mounted) setState(() => _mode = next);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final extreme = _mode == ResilienceMode.extreme;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
title: const Text(
|
|
||||||
'Network resilience',
|
|
||||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
extreme
|
|
||||||
? 'Extreme: keeps the exact server transport unchanged and makes up to two controlled recovery attempts after each real Xray disconnect.'
|
|
||||||
: 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.',
|
|
||||||
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
|
|
||||||
),
|
|
||||||
trailing: DropdownButtonHideUnderline(
|
|
||||||
child: DropdownButton<ResilienceMode>(
|
|
||||||
value: _mode,
|
|
||||||
dropdownColor: AppColors.bgCard,
|
|
||||||
onChanged: _change,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ResilienceMode.standard,
|
|
||||||
child: Text('Standard'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: ResilienceMode.extreme,
|
|
||||||
child: Text('Extreme'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:revoltvpn/logic/app_colors.dart';
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
import 'package:revoltvpn/screens/settings/in_settings/background_reliability.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/connection_mode.dart';
|
import 'package:revoltvpn/screens/settings/in_settings/connection_mode.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||||
import 'package:revoltvpn/screens/settings/in_settings/resilience.dart';
|
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -32,7 +32,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
children: const [
|
children: const [
|
||||||
ConnectionModeTile(),
|
ConnectionModeTile(),
|
||||||
ResilienceModeTile(),
|
BackgroundReliabilityTile(),
|
||||||
HapticFeedbackTile(),
|
HapticFeedbackTile(),
|
||||||
RainToggleTile(),
|
RainToggleTile(),
|
||||||
LightningToggleTile(),
|
LightningToggleTile(),
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
version: 3.3.1+27
|
version: 3.3.2+28
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.0.0 <4.0.0"
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
|
|||||||
Reference in new issue
Block a user