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
+422
-291
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
vpnConnection.isStartupRestoration) {
|
||||
vpnConnection.adoptedRunningRuntime) {
|
||||
_resumeTicking();
|
||||
return;
|
||||
}
|
||||
|
||||
+27
-185
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
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<ConnectionModeTile> {
|
||||
// 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),
|
||||
|
||||
@@ -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: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(),
|
||||
|
||||
Reference in new issue
Block a user