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:
esefxdzandClaude Opus 5 committed 2026-09-01 00:16:05 +03:00
1 parent ac52681452
commit 186dc906da
12 files changed
+422 -291

No files matched your search

-16
View File
@@ -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<void> 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<void> setResilienceMode(ResilienceMode mode) async {
_resilienceMode = mode;
_initialized = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_resilienceModeKey, mode.name);
}
}
+90
View File
@@ -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);
}
+1 -1
View File
@@ -104,7 +104,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
void _onVpnConnectionChanged() {
if (vpnConnection.status == VpnStatus.connected &&
!isRunning &&
vpnConnection.isStartupRestoration) {
vpnConnection.adoptedRunningRuntime) {
_resumeTicking();
return;
}
+27 -185
View File
@@ -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<NetworkSnapshot>? _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<void> _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<void> _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<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 {
try {
await _vless.stopVless().timeout(const Duration(seconds: 5));
@@ -535,7 +381,6 @@ class VpnConnection extends ChangeNotifier {
Future<void> 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.