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"