diff --git a/CHANGELOG.md b/CHANGELOG.md
index 896b244..48d990c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
### Added
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0a5bbaf..0b45736 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,9 @@
+
+
+ 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)
.setMethodCallHandler { call, result ->
if (call.method == "updateNotificationText") {
@@ -88,6 +115,98 @@ class MainActivity : FlutterActivity() {
super.onDestroy()
}
+ /** True when the OS has exempted us from Doze / App Standby. */
+ private fun isIgnoringBatteryOptimizations(): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
+ return try {
+ 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 {
+ var processAlive = false
+ try {
+ val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ processAlive = am.runningAppProcesses
+ ?.any { it.processName.endsWith(VPN_PROCESS_SUFFIX) } == true
+ } catch (_: Exception) {
+ }
+
+ var vpnTransport = false
+ try {
+ val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ vpnTransport = cm.allNetworks.any { network ->
+ cm.getNetworkCapabilities(network)
+ ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
+ }
+ } catch (_: Exception) {
+ }
+
+ return mapOf(
+ "processAlive" to processAlive,
+ "vpnTransport" to vpnTransport,
+ )
+ }
+
+ /** Removes a notification left behind by a runtime that is no longer alive. */
+ private fun cancelVpnNotification() {
+ try {
+ NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID)
+ } catch (_: Exception) {
+ }
+ }
+
private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
@@ -96,6 +215,15 @@ class MainActivity : FlutterActivity() {
return
}
+ // The foreground notification (id 1) is owned by XrayVPNService via
+ // startForeground. Posting to it from this process while the service is
+ // gone leaves a frozen countdown nothing will ever update again.
+ val alive = isVpnRuntimeAlive()["processAlive"] == true
+ if (!alive) {
+ cancelVpnNotification()
+ return
+ }
+
ensureNotificationChannel()
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -344,6 +472,7 @@ class MainActivity : FlutterActivity() {
companion object {
private const val NOTIFICATION_ID = 1
+ private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon"
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
}
}
diff --git a/lib/logic/connection_settings.dart b/lib/logic/connection_settings.dart
index 9e5f571..35ae125 100644
--- a/lib/logic/connection_settings.dart
+++ b/lib/logic/connection_settings.dart
@@ -1,20 +1,16 @@
import 'package:shared_preferences/shared_preferences.dart';
enum ConnectionMode { tun, proxy }
-enum ResilienceMode { standard, extreme }
abstract final class ConnectionSettings {
ConnectionSettings._();
static const String _modeKey = 'connection_mode';
- static const String _resilienceModeKey = 'resilience_mode';
static ConnectionMode _mode = ConnectionMode.tun;
- static ResilienceMode _resilienceMode = ResilienceMode.standard;
static bool _initialized = false;
static ConnectionMode get mode => _mode;
- static ResilienceMode get resilienceMode => _resilienceMode;
static Future initialize() async {
if (_initialized) return;
@@ -28,11 +24,6 @@ abstract final class ConnectionSettings {
? ConnectionMode.proxy
: ConnectionMode.tun;
- _resilienceMode =
- prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name
- ? ResilienceMode.extreme
- : ResilienceMode.standard;
-
if (storedMode != _mode.name) {
await prefs.setString(_modeKey, _mode.name);
}
@@ -46,11 +37,4 @@ abstract final class ConnectionSettings {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_modeKey, mode.name);
}
-
- static Future setResilienceMode(ResilienceMode mode) async {
- _resilienceMode = mode;
- _initialized = true;
- final prefs = await SharedPreferences.getInstance();
- await prefs.setString(_resilienceModeKey, mode.name);
- }
}
diff --git a/lib/logic/power_settings.dart b/lib/logic/power_settings.dart
new file mode 100644
index 0000000..2768818
--- /dev/null
+++ b/lib/logic/power_settings.dart
@@ -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 isBatteryOptimisationDisabled() async {
+ if (kIsWeb) return true;
+ try {
+ return await _channel.invokeMethod(
+ '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 requestDisableBatteryOptimisation() async {
+ if (kIsWeb) return true;
+ try {
+ return await _channel.invokeMethod(
+ 'requestIgnoreBatteryOptimizations',
+ ) ??
+ false;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ /// Opens the OS VPN settings screen, where Always-on VPN is configured.
+ static Future openVpnSettings() async {
+ if (kIsWeb) return false;
+ try {
+ return await _channel.invokeMethod('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 runtimeState() async {
+ if (kIsWeb) return const VpnRuntimeState(processAlive: false, vpnTransport: false);
+ try {
+ final result = await _channel.invokeMapMethod(
+ '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);
+}
diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart
index 7d3f784..8ef89b7 100644
--- a/lib/logic/session_timer.dart
+++ b/lib/logic/session_timer.dart
@@ -104,7 +104,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
void _onVpnConnectionChanged() {
if (vpnConnection.status == VpnStatus.connected &&
!isRunning &&
- vpnConnection.isStartupRestoration) {
+ vpnConnection.adoptedRunningRuntime) {
_resumeTicking();
return;
}
diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart
index 1f2f7ce..80d7c83 100644
--- a/lib/logic/vpn_connection.dart
+++ b/lib/logic/vpn_connection.dart
@@ -6,6 +6,7 @@ import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart';
import 'package:revoltvpn/logic/network_monitor.dart';
+import 'package:revoltvpn/logic/power_settings.dart';
import 'package:revoltvpn/logic/secure_socks_session.dart';
enum VpnStatus {
@@ -17,8 +18,6 @@ enum VpnStatus {
}
class VpnConnection extends ChangeNotifier {
- static const int _maxExtremeRecoveryAttempts = 2;
-
bool _cancelled = false;
VpnStatus _status = VpnStatus.disconnected;
VpnStatus get status => _status;
@@ -29,8 +28,10 @@ class VpnConnection extends ChangeNotifier {
String? _errorMessage;
String? get errorMessage => _errorMessage;
- bool _isStartupRestoration = false;
- bool get isStartupRestoration => _isStartupRestoration;
+ /// True when the UI attached to a tunnel that was already running, rather
+ /// than starting one itself. The session timer uses this to resume ticking.
+ bool _adoptedRunningRuntime = false;
+ bool get adoptedRunningRuntime => _adoptedRunningRuntime;
bool _serverReachable = false;
bool get serverReachable => _serverReachable;
@@ -38,9 +39,6 @@ class VpnConnection extends ChangeNotifier {
ConnectionMode _activeMode = ConnectionMode.tun;
ConnectionMode get activeMode => _activeMode;
- ResilienceMode _activeResilienceMode = ResilienceMode.standard;
- ResilienceMode get activeResilienceMode => _activeResilienceMode;
-
String _networkTransport = 'unknown';
String get networkTransport => _networkTransport;
@@ -70,16 +68,10 @@ class VpnConnection extends ChangeNotifier {
}
Timer? _healthTimer;
- Timer? _restartTimer;
StreamSubscription? _networkSubscription;
- String? _lastBaseConfig;
- String _lastRemark = 'Revolt VPN';
- bool _lastVerifyLocalSocks = false;
SecureSocksSession? _lastSecureSocks;
- bool _restartInProgress = false;
bool _userDisconnecting = false;
- int _connectionEpoch = 0;
late final FlutterVless _vless;
bool _initialized = false;
@@ -88,7 +80,6 @@ class VpnConnection extends ChangeNotifier {
VpnConnection() {
_activeMode = ConnectionSettings.mode;
- _activeResilienceMode = ConnectionSettings.resilienceMode;
_init();
}
@@ -107,7 +98,6 @@ class VpnConnection extends ChangeNotifier {
await ConnectionSettings.initialize();
_activeMode = ConnectionSettings.mode;
- _activeResilienceMode = ConnectionSettings.resilienceMode;
_vless = FlutterVless(
onStatusChanged: (status) {
@@ -150,59 +140,30 @@ class VpnConnection extends ChangeNotifier {
try {
final coreVersion = await _vless.getCoreVersion();
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 (_) {}
+
+ await _adoptRunningRuntime();
}
- /// Android can kill the Dart process while the VPN foreground service keeps
- /// 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.
+ /// Reflect a tunnel that is already up when the UI starts.
///
- /// TUN only. This mints *fresh* SOCKS credentials, which is invisible in TUN
- /// because only tun2socks consumes them. In proxy mode those credentials are
- /// the user's own client configuration, so rotating them behind their back
- /// would silently break it — that case is surfaced in the UI instead.
- Future _restoreRecoverySnapshot() async {
- if (kIsWeb || _activeMode != ConnectionMode.tun) return;
-
- final epoch = _connectionEpoch;
- bool stale() =>
- _connectionEpoch != epoch ||
- _userDisconnecting ||
- _status != VpnStatus.connected;
-
+ /// This asks the OS whether our VPN service process is alive, rather than
+ /// probing a local SOCKS port. The old probe called getConnectedServerDelay(),
+ /// which reads AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807 — but
+ /// AppConfigs lives in the VPN's own process, so from here it is always null
+ /// and the port always fell back to 10807. That was correct only while the
+ /// SOCKS port was fixed; per-session ephemeral ports made it permanently
+ /// wrong, so the app reported "connected" against a dead tunnel.
+ Future _adoptRunningRuntime() async {
+ if (kIsWeb) return;
try {
- final realUrl = await HivemindService.fetchConfigDirectly(
- skipAdBypass: true,
- );
- // A real connect() may have landed while the fetch was in flight; never
- // 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');
+ final state = await PowerSettings.runtimeState();
+ if (state.aliveFor(tunMode: _activeMode == ConnectionMode.tun)) {
+ _adoptedRunningRuntime = true;
+ _setStatus(VpnStatus.connected, _connectedLabel);
+ }
} catch (e) {
- // Best-effort: without it the tunnel still works, only Extreme recovery
- // stays unavailable until the next manual connect.
- debugPrint('[VPN] Could not rebuild recovery snapshot: $e');
+ debugPrint('[VPN] Runtime liveness check failed: $e');
}
}
@@ -222,40 +183,21 @@ class VpnConnection extends ChangeNotifier {
break;
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();
_setStatus(VpnStatus.disconnected, 'Tap to connect');
break;
case VlessConnectionState.connecting:
- if (!_restartInProgress) {
- _setStatus(VpnStatus.connecting, 'Establishing tunnel…');
- }
+ _setStatus(VpnStatus.connecting, 'Establishing tunnel…');
break;
case VlessConnectionState.disconnecting:
- _isStartupRestoration = false;
- if (!_restartInProgress) {
- _setStatus(VpnStatus.disconnecting, 'Tearing down…');
- }
+ _setStatus(VpnStatus.disconnecting, 'Tearing down…');
break;
case VlessConnectionState.unknown:
if (_status != VpnStatus.connected &&
- _status != VpnStatus.disconnected &&
- !_restartInProgress) {
+ _status != VpnStatus.disconnected) {
_setStatus(VpnStatus.error, 'Connection failed');
}
break;
@@ -269,12 +211,9 @@ class VpnConnection extends ChangeNotifier {
_cancelled = false;
_userDisconnecting = false;
- _restartTimer?.cancel();
- _connectionEpoch++;
await ConnectionSettings.initialize();
_activeMode = ConnectionSettings.mode;
- _activeResilienceMode = ConnectionSettings.resilienceMode;
if (!kIsWeb && !_initialized) {
_errorMessage = 'VPN service unavailable.';
@@ -369,10 +308,7 @@ class VpnConnection extends ChangeNotifier {
}
}
- _lastBaseConfig = secureSocks.configJson;
_lastSecureSocks = secureSocks;
- _lastRemark = remark;
- _lastVerifyLocalSocks = verifyLocalSocks;
} catch (e) {
debugPrint('[VPN] Tunnel start error: $e');
await _stopRuntime();
@@ -436,96 +372,6 @@ class VpnConnection extends ChangeNotifier {
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 _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 _stopRuntime() async {
try {
await _vless.stopVless().timeout(const Duration(seconds: 5));
@@ -535,7 +381,6 @@ class VpnConnection extends ChangeNotifier {
Future disconnect() async {
_cancelled = true;
_userDisconnecting = true;
- _restartTimer?.cancel();
HivemindService.cancel();
if (_status == VpnStatus.disconnected ||
@@ -586,10 +431,8 @@ class VpnConnection extends ChangeNotifier {
}
void _clearRuntimeSnapshot() {
- _lastBaseConfig = null;
- _lastVerifyLocalSocks = false;
_lastSecureSocks = null;
- _isStartupRestoration = false;
+ _adoptedRunningRuntime = false;
}
void _setStatus(VpnStatus status, String message) {
@@ -609,7 +452,6 @@ class VpnConnection extends ChangeNotifier {
@override
void dispose() {
_healthTimer?.cancel();
- _restartTimer?.cancel();
_networkSubscription?.cancel();
// Provider/UI disposal is not a user disconnect command. Keeping teardown
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
diff --git a/lib/screens/settings/in_settings/background_reliability.dart b/lib/screens/settings/in_settings/background_reliability.dart
new file mode 100644
index 0000000..ffa5f29
--- /dev/null
+++ b/lib/screens/settings/in_settings/background_reliability.dart
@@ -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 createState() =>
+ _BackgroundReliabilityTileState();
+}
+
+class _BackgroundReliabilityTileState extends State
+ 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 _refresh() async {
+ final exempt = await PowerSettings.isBatteryOptimisationDisabled();
+ if (mounted) setState(() => _batteryExempt = exempt);
+ }
+
+ Future _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 _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'),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart
index de472fb..021c3b0 100644
--- a/lib/screens/settings/in_settings/connection_mode.dart
+++ b/lib/screens/settings/in_settings/connection_mode.dart
@@ -94,7 +94,7 @@ class _ConnectionModeTileState extends State {
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
final orphaned =
- vpn.status == VpnStatus.connected && vpn.isStartupRestoration;
+ vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -174,7 +174,7 @@ class _ConnectionModeTileState extends State {
// memory that died with it. Saying "Connect" there would be a
// lie — the user is already connected.
final orphaned = vpn.status == VpnStatus.connected &&
- vpn.isStartupRestoration;
+ vpn.adoptedRunningRuntime;
if (orphaned) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
diff --git a/lib/screens/settings/in_settings/resilience.dart b/lib/screens/settings/in_settings/resilience.dart
deleted file mode 100644
index a4c137d..0000000
--- a/lib/screens/settings/in_settings/resilience.dart
+++ /dev/null
@@ -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 createState() => _ResilienceModeTileState();
-}
-
-class _ResilienceModeTileState extends State {
- ResilienceMode _mode = ConnectionSettings.resilienceMode;
-
- @override
- void initState() {
- super.initState();
- _load();
- }
-
- Future _load() async {
- await ConnectionSettings.initialize();
- if (mounted) setState(() => _mode = ConnectionSettings.resilienceMode);
- }
-
- bool _vpnBusy() {
- final status = context.read().status;
- return status == VpnStatus.connected ||
- status == VpnStatus.connecting ||
- status == VpnStatus.disconnecting;
- }
-
- Future _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(
- value: _mode,
- dropdownColor: AppColors.bgCard,
- onChanged: _change,
- items: const [
- DropdownMenuItem(
- value: ResilienceMode.standard,
- child: Text('Standard'),
- ),
- DropdownMenuItem(
- value: ResilienceMode.extreme,
- child: Text('Extreme'),
- ),
- ],
- ),
- ),
- );
- }
-}
diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart
index 822b945..3253a7f 100644
--- a/lib/screens/settings/settings_screen.dart
+++ b/lib/screens/settings/settings_screen.dart
@@ -1,10 +1,10 @@
import 'package:flutter/material.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/haptic.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/resilience.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -32,7 +32,7 @@ class SettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 8),
children: const [
ConnectionModeTile(),
- ResilienceModeTile(),
+ BackgroundReliabilityTile(),
HapticFeedbackTile(),
RainToggleTile(),
LightningToggleTile(),
diff --git a/pubspec.yaml b/pubspec.yaml
index d33dac1..4dc3973 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
-version: 3.3.1+27
+version: 3.3.2+28
environment:
sdk: ">=3.0.0 <4.0.0"