From 0ed7bf27b552e19360dff3ca67b3e453916089b7 Mon Sep 17 00:00:00 2001 From: Naveax Date: Thu, 3 Sep 2026 22:44:25 +0300 Subject: [PATCH] refactor(app): remove unsupported runtime and UI state --- lib/components/connect_button.dart | 24 ++-- lib/components/data_disclosure_dialog.dart | 32 +++--- lib/components/lightning.dart | 77 ++++++++----- .../sidebar_contents/discord_link_button.dart | 4 +- .../sidebar_contents/gdpr_button.dart | 4 +- .../sidebar_contents/info_button.dart | 4 +- .../privacy_policy_button.dart | 12 +- .../sidebar_contents/website_button.dart | 4 +- lib/components/status_bar.dart | 105 ++++++------------ lib/logic/connection_settings.dart | 21 +++- lib/logic/power_settings.dart | 60 +--------- lib/logic/server_list.dart | 69 ------------ lib/main.dart | 14 +-- lib/screens/intro.dart | 11 +- .../server_selector/selection_screen.dart | 63 ----------- .../in_settings/background_reliability.dart | 19 ++-- .../settings/in_settings/connection_mode.dart | 17 ++- .../settings/in_settings/lightning.dart | 7 +- lib/screens/settings/in_settings/rain.dart | 7 +- 19 files changed, 173 insertions(+), 381 deletions(-) delete mode 100644 lib/logic/server_list.dart delete mode 100644 lib/screens/server_selector/selection_screen.dart diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart index 2e739ee..60227d2 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -19,6 +19,7 @@ class _ConnectButtonState extends State DateTime _lastTap = DateTime.fromMillisecondsSinceEpoch(0); AnimationController? _pulse; Animation? _pulseAnim; + VpnConnection? _vpn; double get _pulseValue => _pulseAnim?.value ?? 0.0; @@ -35,20 +36,25 @@ class _ConnectButtonState extends State ); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) context.read().addListener(_onVpnChanged); + if (!mounted || _vpn != null) return; + final vpn = context.read(); + _vpn = vpn; + vpn.addListener(_onVpnChanged); + _onVpnChanged(); }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - context.read().removeListener(_onVpnChanged); + _vpn?.removeListener(_onVpnChanged); _pulse?.dispose(); super.dispose(); } void _onVpnChanged() { - final vpn = context.read(); + final vpn = _vpn; + if (vpn == null) return; if (vpn.status == VpnStatus.connected) { _pulse?.repeat(reverse: true); } else { @@ -60,14 +66,12 @@ class _ConnectButtonState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { if (!mounted) return; - if (state == AppLifecycleState.paused || - state == AppLifecycleState.detached) { + if (state != AppLifecycleState.resumed) { _pulse?.stop(); - } else if (state == AppLifecycleState.resumed) { - final vpn = context.read(); - if (vpn.status == VpnStatus.connected) { - _pulse?.repeat(reverse: true); - } + return; + } + if (_vpn?.status == VpnStatus.connected) { + _pulse?.repeat(reverse: true); } } diff --git a/lib/components/data_disclosure_dialog.dart b/lib/components/data_disclosure_dialog.dart index 85d8132..95a59b5 100644 --- a/lib/components/data_disclosure_dialog.dart +++ b/lib/components/data_disclosure_dialog.dart @@ -19,23 +19,28 @@ class DataDisclosureDialog { if (!context.mounted) return; - await showDialog( + final accepted = await showDialog( context: context, barrierDismissible: false, // Must tap Continue — no tap-outside dismiss. - builder: (_) => _DisclosureDialog( - onContinue: () { - prefs.setBool(_key, true); - Navigator.of(context).pop(); // Dismiss the dialog. - }, - ), + builder: (_) => const _DisclosureDialog(), ); + if (accepted != true) return; + + // Persist only after the user action is complete. If storage fails, do not + // invent an accepted state; the disclosure will simply reappear later. + try { + final saved = await prefs.setBool(_key, true); + if (!saved) { + debugPrint('[Disclosure] Preference write was rejected'); + } + } catch (e) { + debugPrint('[Disclosure] Failed to persist acknowledgement: $e'); + } } } class _DisclosureDialog extends StatelessWidget { - final VoidCallback onContinue; - - const _DisclosureDialog({required this.onContinue}); + const _DisclosureDialog(); @override Widget build(BuildContext context) { @@ -49,7 +54,6 @@ class _DisclosureDialog extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Title const Text( 'Before you connect', style: TextStyle( @@ -59,8 +63,6 @@ class _DisclosureDialog extends StatelessWidget { ), ), const SizedBox(height: 16), - - // Body const Text( 'Revolt VPN creates a secure VPN tunnel to protect your ' 'traffic. To enforce your data/time quota, we log your ' @@ -76,12 +78,10 @@ class _DisclosureDialog extends StatelessWidget { ), ), const SizedBox(height: 24), - - // Continue button — full-width, accent SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: onContinue, + onPressed: () => Navigator.of(context).pop(true), style: ElevatedButton.styleFrom( backgroundColor: AppColors.accent, foregroundColor: AppColors.bgDeep, diff --git a/lib/components/lightning.dart b/lib/components/lightning.dart index 03a032d..0ad6e3f 100644 --- a/lib/components/lightning.dart +++ b/lib/components/lightning.dart @@ -5,13 +5,9 @@ import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/screens/settings/in_settings/lightning.dart'; /// 2D lightning bolt — a jagged yellow line from top to bottom with glow. - class LightningEffect extends StatefulWidget { - final int minIntervalSec; - final int maxIntervalSec; - final double doubleChance; const LightningEffect({ @@ -26,26 +22,49 @@ class LightningEffect extends StatefulWidget { } class _LightningEffectState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, WidgetsBindingObserver { late final AnimationController _ctrl; Timer? _timer; final _random = Random(); + bool _appActive = true; LightningBolt? _bolt; @override void initState() { super.initState(); - _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 400)); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + WidgetsBinding.instance.addObserver(this); + final lifecycle = WidgetsBinding.instance.lifecycleState; + _appActive = lifecycle == null || lifecycle == AppLifecycleState.resumed; lightningEnabled.addListener(_onEnabledChanged); + if (_appActive && lightningEnabled.value) _scheduleNext(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _appActive = state == AppLifecycleState.resumed; + if (!_appActive) { + _timer?.cancel(); + _timer = null; + _ctrl.stop(); + _bolt = null; + if (mounted) setState(() {}); + return; + } + if (lightningEnabled.value) _scheduleNext(); } void _onEnabledChanged() { - if (lightningEnabled.value) { + if (lightningEnabled.value && _appActive) { _scheduleNext(); } else { _timer?.cancel(); + _timer = null; _ctrl.stop(); _bolt = null; } @@ -54,6 +73,7 @@ class _LightningEffectState extends State @override void dispose() { + WidgetsBinding.instance.removeObserver(this); lightningEnabled.removeListener(_onEnabledChanged); _timer?.cancel(); _ctrl.dispose(); @@ -61,19 +81,23 @@ class _LightningEffectState extends State } void _scheduleNext() { + _timer?.cancel(); + if (!mounted || !_appActive || !lightningEnabled.value) return; + final secs = widget.minIntervalSec + _random.nextInt(widget.maxIntervalSec - widget.minIntervalSec + 1); _timer = Timer(Duration(seconds: secs), _strike); } void _strike() { - if (!mounted || !lightningEnabled.value) return; + _timer = null; + if (!mounted || !_appActive || !lightningEnabled.value) return; _bolt = LightningBolt.random(_random); _ctrl.forward(from: 0.0); if (_random.nextDouble() < widget.doubleChance) { Future.delayed(const Duration(milliseconds: 120), () { - if (mounted && lightningEnabled.value) { + if (mounted && _appActive && lightningEnabled.value) { _bolt = LightningBolt.random(_random); _ctrl.forward(from: 0.0); } @@ -83,10 +107,10 @@ class _LightningEffectState extends State _scheduleNext(); } - /// Sharp flash curve: quick on, quick off. No slow fade. + /// Sharp flash curve: quick on, quick off. No slow fade. double _opacity(double t) { - if (t < 0.08) return t / 0.08; // fast ramp - return 1.0 - ((t - 0.08) / 0.92); // linear decay + if (t < 0.08) return t / 0.08; + return 1.0 - ((t - 0.08) / 0.92); } @override @@ -117,37 +141,31 @@ class _LightningEffectState extends State } } - /// A single bolt defined by a list of 2D points (0.0–1.0 in width×height space). class LightningBolt { final List mainPath; - final List> branches; // 0–2 forks off the main path + final List> branches; const LightningBolt({required this.mainPath, this.branches = const []}); - /// Generate a random bolt. The path always starts near the top-center + /// Generate a random bolt. The path always starts near the top-center /// and ends near the bottom, zigzagging left/right along the way. factory LightningBolt.random(Random rng) { final points = []; - // Start: top, slight horizontal offset double x = 0.5 + (rng.nextDouble() - 0.5) * 0.15; points.add(Offset(x, 0.0)); - // Zigzag down — 6–10 segments final segments = 6 + rng.nextInt(5); for (int i = 1; i < segments; i++) { final y = i / segments; - // Random horizontal offset — each segment adds jitter x += (rng.nextDouble() - 0.5) * 0.25; - x = x.clamp(0.05, 0.95); // stay on screen + x = x.clamp(0.05, 0.95); points.add(Offset(x, y)); } - // End: bottom points.add(Offset(x.clamp(0.1, 0.9), 1.0)); - // Branches — 0–2 forks from random points along the main path final branches = >[]; final branchCount = rng.nextInt(3); for (int b = 0; b < branchCount; b++) { @@ -160,7 +178,7 @@ class LightningBolt { double by = start.dy; final branchLen = 2 + rng.nextInt(3); for (int j = 0; j < branchLen; j++) { - by += (0.05 + rng.nextDouble() * 0.12); + by += 0.05 + rng.nextDouble() * 0.12; bx += (rng.nextDouble() - 0.5) * 0.3; bx = bx.clamp(0.02, 0.98); by = by.clamp(0.0, 1.0); @@ -173,7 +191,6 @@ class LightningBolt { } } - class _LightningPainter extends CustomPainter { final LightningBolt bolt; final double opacity; @@ -182,7 +199,8 @@ class _LightningPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - final toScreen = (Offset p) => Offset(p.dx * size.width, p.dy * size.height); + Offset toScreen(Offset p) => Offset(p.dx * size.width, p.dy * size.height); + final scaledMain = bolt.mainPath.map(toScreen).toList(); final scaledBranches = bolt.branches .map((b) => b.map(toScreen).toList()) @@ -191,7 +209,6 @@ class _LightningPainter extends CustomPainter { final baseColor = AppColors.accent.withAlpha((opacity * 255).round()); final glowColor = AppColors.accent.withAlpha((opacity * 0.35 * 255).round()); - // Glow final glowPaint = Paint() ..color = glowColor ..strokeWidth = 18 @@ -202,7 +219,6 @@ class _LightningPainter extends CustomPainter { _drawPath(canvas, scaledMain, scaledBranches, glowPaint); - // Core final corePaint = Paint() ..color = baseColor ..strokeWidth = 5 @@ -212,7 +228,6 @@ class _LightningPainter extends CustomPainter { _drawPath(canvas, scaledMain, scaledBranches, corePaint); - // Hot center if (opacity > 0.6) { final hotPaint = Paint() ..color = Colors.white.withAlpha((opacity * 180).round()) @@ -225,8 +240,12 @@ class _LightningPainter extends CustomPainter { } } - void _drawPath(Canvas canvas, List main, - List> branches, Paint paint) { + void _drawPath( + Canvas canvas, + List main, + List> branches, + Paint paint, + ) { final mainPath = Path()..moveTo(main.first.dx, main.first.dy); for (int i = 1; i < main.length; i++) { mainPath.lineTo(main[i].dx, main[i].dy); diff --git a/lib/components/sidebar_contents/discord_link_button.dart b/lib/components/sidebar_contents/discord_link_button.dart index 012322d..9f1d183 100644 --- a/lib/components/sidebar_contents/discord_link_button.dart +++ b/lib/components/sidebar_contents/discord_link_button.dart @@ -14,8 +14,8 @@ class DiscordLinkButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( - icon: const Icon(Icons.discord, color: AppColors.textWhite, size: 28), + return const IconButton( + icon: Icon(Icons.discord, color: AppColors.textWhite, size: 28), onPressed: launchDiscord, tooltip: 'Join Discord', splashRadius: 24, diff --git a/lib/components/sidebar_contents/gdpr_button.dart b/lib/components/sidebar_contents/gdpr_button.dart index 696ac13..00f95d0 100644 --- a/lib/components/sidebar_contents/gdpr_button.dart +++ b/lib/components/sidebar_contents/gdpr_button.dart @@ -76,8 +76,8 @@ class GdprButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( - icon: const Icon(Icons.admin_panel_settings_outlined, + return const IconButton( + icon: Icon(Icons.admin_panel_settings_outlined, color: AppColors.textWhite, size: 28), onPressed: showConsentForm, tooltip: 'Ad consent & privacy choices', diff --git a/lib/components/sidebar_contents/info_button.dart b/lib/components/sidebar_contents/info_button.dart index 9a2f575..98559ad 100644 --- a/lib/components/sidebar_contents/info_button.dart +++ b/lib/components/sidebar_contents/info_button.dart @@ -14,8 +14,8 @@ class InfoButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( - icon: const Icon(Icons.help_outline, color: AppColors.textWhite, size: 28), + return const IconButton( + icon: Icon(Icons.help_outline, color: AppColors.textWhite, size: 28), onPressed: launchGitHub, tooltip: 'About ReVoltVPN', splashRadius: 24, diff --git a/lib/components/sidebar_contents/privacy_policy_button.dart b/lib/components/sidebar_contents/privacy_policy_button.dart index 09b6bd1..38ad136 100644 --- a/lib/components/sidebar_contents/privacy_policy_button.dart +++ b/lib/components/sidebar_contents/privacy_policy_button.dart @@ -6,7 +6,9 @@ class PrivacyPolicyButton extends StatelessWidget { const PrivacyPolicyButton({super.key}); static Future launchPrivacyPolicy() async { - final Uri url = Uri.parse('https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md'); + final Uri url = Uri.parse( + 'https://github.com/esefxdz/ReVoltVPN/blob/main/PRIVACY_POLICY.md', + ); if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { debugPrint('Could not launch $url'); } @@ -14,8 +16,12 @@ class PrivacyPolicyButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( - icon: const Icon(Icons.privacy_tip_outlined, color: AppColors.textWhite, size: 28), + return const IconButton( + icon: Icon( + Icons.privacy_tip_outlined, + color: AppColors.textWhite, + size: 28, + ), onPressed: launchPrivacyPolicy, tooltip: 'Privacy Policy', splashRadius: 24, diff --git a/lib/components/sidebar_contents/website_button.dart b/lib/components/sidebar_contents/website_button.dart index ef6496a..c5d7b0a 100644 --- a/lib/components/sidebar_contents/website_button.dart +++ b/lib/components/sidebar_contents/website_button.dart @@ -14,8 +14,8 @@ class WebsiteButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( - icon: const Icon(Icons.language, color: AppColors.textWhite, size: 28), + return const IconButton( + icon: Icon(Icons.language, color: AppColors.textWhite, size: 28), onPressed: launchWebsite, tooltip: 'Website', splashRadius: 24, diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart index 9e640b4..3eb1434 100644 --- a/lib/components/status_bar.dart +++ b/lib/components/status_bar.dart @@ -1,91 +1,58 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:revoltvpn/components/online_dot.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; -import 'package:revoltvpn/logic/session_timer.dart'; -import 'package:revoltvpn/logic/server_list.dart'; -import 'package:revoltvpn/components/online_dot.dart'; -import 'package:revoltvpn/screens/server_selector/selection_screen.dart'; class StatusBar extends StatelessWidget { const StatusBar({super.key}); @override Widget build(BuildContext context) { - return Consumer2( - builder: (context, vpn, timer, _) { + return Consumer( + builder: (context, vpn, _) { final isConnected = vpn.status == VpnStatus.connected; - final isConnecting = vpn.status == VpnStatus.connecting; - final serverHealthy = - vpn.serverReachable || (isConnected && timer.hasSyncedOnce); - final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable; - - final statusLabel = isConnected - ? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…') - : (isConnecting - ? 'Connecting…' - : (vpn.serverReachable ? 'Ready' : 'Offline')); - - final server = context.watch().selected; + final statusLabel = switch (vpn.status) { + VpnStatus.connected => 'Connected', + VpnStatus.connecting => 'Connecting…', + VpnStatus.disconnecting => 'Disconnecting…', + VpnStatus.error => 'Error', + VpnStatus.disconnected => vpn.serverReachable ? 'Ready' : 'Offline', + }; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ - GestureDetector( - onTap: () { - Navigator.of(context).push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - pageBuilder: (_, __, ___) => const SelectionScreen(), - transitionsBuilder: (_, animation, __, child) { - return SlideTransition( - position: Tween( - begin: const Offset(1.0, 0.0), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: animation, - curve: Curves.easeOut, - ), - ), - child: child, - ); - }, + Container( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: AppColors.bgCard, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.slate15), + ), + child: Row( + children: [ + Image.asset( + 'assets/finland_flag_256.png', + width: 20, + height: 20, ), - ); - }, - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: BoxDecoration( - color: AppColors.bgCard, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.slate15), - ), - child: Row( - children: [ - Image.asset(server.flagAsset, width: 20, height: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - server.name, - style: const TextStyle( - color: AppColors.accent, - fontWeight: FontWeight.w600, - fontSize: 13, - letterSpacing: 0.5, - ), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Helsinki, Finland', + style: TextStyle( + color: AppColors.accent, + fontWeight: FontWeight.w600, + fontSize: 13, + letterSpacing: 0.5, ), ), - const Icon( - Icons.chevron_right, - color: AppColors.textDim, - size: 20, - ), - ], - ), + ), + ], ), ), const SizedBox(height: 8), @@ -100,7 +67,7 @@ class StatusBar extends StatelessWidget { child: Row( children: [ OnlineDot( - isOnline: serverHealthy, + isOnline: isConnected, label: statusLabel, ), const Spacer(), diff --git a/lib/logic/connection_settings.dart b/lib/logic/connection_settings.dart index 6189360..e7a6e36 100644 --- a/lib/logic/connection_settings.dart +++ b/lib/logic/connection_settings.dart @@ -18,23 +18,32 @@ abstract final class ConnectionSettings { final prefs = await SharedPreferences.getInstance(); final storedMode = prefs.getString(_modeKey); - // Any unrecognized value (including the pre-V3 'auto') is TUN. 'proxy' is - // the only persisted value that selects the SOCKS5 mode. + // Only the explicit current proxy value restores SOCKS5. Unknown and + // obsolete values, including the former 'auto'/'ass' labels, fail closed + // to full-tunnel TUN instead of silently selecting a weaker route. _mode = storedMode == ConnectionMode.proxy.name ? ConnectionMode.proxy : ConnectionMode.tun; if (storedMode != _mode.name) { - await prefs.setString(_modeKey, _mode.name); + final migrated = await prefs.setString(_modeKey, _mode.name); + if (!migrated) { + throw StateError('Could not persist migrated connection mode.'); + } } _initialized = true; } - static Future setMode(ConnectionMode mode) async { + static Future setMode(ConnectionMode mode) async { + if (_initialized && _mode == mode) return true; + + final prefs = await SharedPreferences.getInstance(); + final saved = await prefs.setString(_modeKey, mode.name); + if (!saved) return false; + _mode = mode; _initialized = true; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_modeKey, mode.name); + return true; } } diff --git a/lib/logic/power_settings.dart b/lib/logic/power_settings.dart index cee5934..4bffc4a 100644 --- a/lib/logic/power_settings.dart +++ b/lib/logic/power_settings.dart @@ -1,13 +1,7 @@ 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; the battery-optimisation -/// exemption is the one sanctioned way an app can ask for around them, and it -/// still needs one user tap. +/// Android background-survival controls that ReVolt can actually verify. abstract final class PowerSettings { PowerSettings._(); @@ -15,7 +9,6 @@ abstract final class PowerSettings { 'com.revoltvpn.app/power', ); - /// Whether the OS has exempted this app from Doze / App Standby. static Future isBatteryOptimisationDisabled() async { if (kIsWeb) return true; try { @@ -28,8 +21,6 @@ abstract final class PowerSettings { } } - /// 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 { @@ -41,53 +32,4 @@ abstract final class PowerSettings { 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); - } - } - - /// Pushes the absolute session-expiry timestamp (epoch ms) to the VPN - /// service so it can tear the tunnel down even when the UI process is gone. - static Future setSessionExpiry(int expiresAtMs) async { - if (kIsWeb) return; - try { - await _channel.invokeMethod('setSessionExpiry', { - 'expiresAtMs': expiresAtMs, - }); - } catch (_) { - // Best-effort. The daemon also keeps its last persisted expiry. - } - } -} - -@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/server_list.dart b/lib/logic/server_list.dart deleted file mode 100644 index f5665c7..0000000 --- a/lib/logic/server_list.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -// ── lib/logic/server_list.dart ────────────────────────────────────────────── -// Server data model and ChangeNotifier — shared by StatusBar (shows current -// server name/flag) and SelectionScreen (lets user pick a server). -// -// Each server is defined by its tunnel IP/port. Reality credentials (public -// key, shortId) come from /session/status per session; only the transport -// parameters live in AppConfig. -// -// When multi-server ships: -// - _all becomes a fetched list (from /api/servers or bundled config) -// - SelectionScreen shows multiple rows -// - select() triggers a disconnect/reconnect cycle if the tunnel is active - -class ServerInfo { - final String id; - final String name; - final String flagAsset; - final String ip; - final int port; - - const ServerInfo({ - required this.id, - required this.name, - required this.flagAsset, - required this.ip, - required this.port, - }); -} - -class ServerList extends ChangeNotifier { - static const _all = [ - ServerInfo( - id: 'finland', - name: 'Helsinki, Finland', - flagAsset: 'assets/finland_flag_256.png', - ip: '204.168.246.88', - port: 443, - ), - ]; - - int _selectedIndex = 0; - - List get all => _all; - int get selectedIndex => _selectedIndex; - ServerInfo get selected => _all[_selectedIndex]; - - // Loads persisted selection from SharedPreferences. - // Fire-and-forget — UI starts with default, updates when prefs load. - Future init() async { - final prefs = await SharedPreferences.getInstance(); - final saved = prefs.getInt('selected_server_index') ?? 0; - if (saved >= 0 && saved < _all.length && saved != _selectedIndex) { - _selectedIndex = saved; - notifyListeners(); - } - } - - // Switches to a different server. Persists the choice. - Future select(int index) async { - if (index == _selectedIndex || index < 0 || index >= _all.length) return; - _selectedIndex = index; - notifyListeners(); - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('selected_server_index', index); - } -} diff --git a/lib/main.dart b/lib/main.dart index b7d21a2..c76f5c5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,7 +7,6 @@ import 'package:revoltvpn/logic/ad_manager.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/haptic_settings.dart'; -import 'package:revoltvpn/logic/server_list.dart'; import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/screens/intro.dart'; @@ -26,13 +25,10 @@ Future main() async { systemNavigationBarIconBrightness: Brightness.light, )); - FlutterError.onError = (details) { - debugPrint('[FlutterError] ${details.exception}'); - }; - + FlutterError.onError = FlutterError.presentError; PlatformDispatcher.instance.onError = (error, stack) { debugPrint('[PlatformError] $error\n$stack'); - return true; + return false; }; runApp(const ReVoltApp()); @@ -47,8 +43,6 @@ class ReVoltApp extends StatelessWidget { providers: [ ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false), ChangeNotifierProxyProvider( - // Eager, like VpnConnection: the timer has to be listening before the - // engine reports an already-running tunnel, not after the intro. lazy: false, create: (ctx) => SessionTimer(vpnConnection: ctx.read()), @@ -56,10 +50,6 @@ class ReVoltApp extends StatelessWidget { prev ?? SessionTimer(vpnConnection: vpn), ), ChangeNotifierProvider(create: (_) => AdManager()), - ChangeNotifierProvider( - create: (_) => ServerList()..init(), - lazy: false, - ), ], child: MaterialApp( title: 'ReVolt VPN', diff --git a/lib/screens/intro.dart b/lib/screens/intro.dart index cc44177..0338d18 100644 --- a/lib/screens/intro.dart +++ b/lib/screens/intro.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:revoltvpn/logic/app_colors.dart'; -import 'package:revoltvpn/logic/haptic_settings.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/screens/main_screen.dart'; import 'package:revoltvpn/screens/settings/in_settings/rain.dart'; @@ -45,11 +44,10 @@ class _IntroScreenState extends State with WidgetsBindingObserver { final elapsed = Stopwatch()..start(); final vpn = context.read(); - // The overlays and haptic layer read these before MainScreen mounts. + // Haptics is initialized before runApp. Only visual preferences remain. final settingsPrefs = Future.wait([ loadRainPref(), loadLightningPref(), - HapticSettings.initialize(), ]); try { @@ -80,11 +78,10 @@ class _IntroScreenState extends State with WidgetsBindingObserver { void _navigateIfReady() { if (!mounted || _navigated || !_bootComplete) return; - if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) { - return; - } - _navigated = true; + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return; + _navigated = true; Navigator.of(context).pushReplacement( PageRouteBuilder( transitionDuration: const Duration(milliseconds: 400), diff --git a/lib/screens/server_selector/selection_screen.dart b/lib/screens/server_selector/selection_screen.dart deleted file mode 100644 index 7d5dba1..0000000 --- a/lib/screens/server_selector/selection_screen.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:revoltvpn/logic/app_colors.dart'; -import 'package:revoltvpn/logic/server_list.dart'; - -// ── lib/screens/server_selector/selection_screen.dart ────────────────────── -// Server picker — reads the server list from the ServerList provider. -// Each row shows a flag + city name. Tapping a row persists the choice -// and pops back. No latency measurement — the app runs inside a Reality -// tunnel and must not emit separate probe traffic. - -class SelectionScreen extends StatelessWidget { - const SelectionScreen({super.key}); - - @override - Widget build(BuildContext context) { - final serverList = context.watch(); - - return Scaffold( - backgroundColor: AppColors.bgDeep, - appBar: AppBar( - backgroundColor: AppColors.bgSurface, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: AppColors.textWhite), - onPressed: () => Navigator.of(context).pop(), - ), - title: const Text( - 'Select Server', - style: TextStyle( - color: AppColors.textWhite, - fontWeight: FontWeight.w600, - ), - ), - ), - body: ListView.builder( - itemCount: serverList.all.length, - itemBuilder: (context, index) { - final server = serverList.all[index]; - final isSelected = index == serverList.selectedIndex; - - return ListTile( - leading: Image.asset(server.flagAsset, width: 28, height: 28), - title: Text(server.name, - style: TextStyle( - color: isSelected ? AppColors.accent : AppColors.textWhite, - fontWeight: FontWeight.w600, - )), - trailing: isSelected - ? const Icon(Icons.check, color: AppColors.accent) - : null, - onTap: () { - serverList.select(index); - Navigator.of(context).pop(); - }, - contentPadding: - const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - ); - }, - ), - ); - } -} diff --git a/lib/screens/settings/in_settings/background_reliability.dart b/lib/screens/settings/in_settings/background_reliability.dart index 5b74118..db65fdf 100644 --- a/lib/screens/settings/in_settings/background_reliability.dart +++ b/lib/screens/settings/in_settings/background_reliability.dart @@ -2,12 +2,9 @@ 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. +/// Android may restrict background work when battery optimisation is active. +/// Surface the OS setting we can actually detect and request; do not promise +/// reboot/Always-on restoration that the per-session runtime cannot guarantee. class BackgroundReliabilityTile extends StatefulWidget { const BackgroundReliabilityTile({super.key}); @@ -35,8 +32,6 @@ class _BackgroundReliabilityTileState extends State @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(); } @@ -68,8 +63,8 @@ class _BackgroundReliabilityTileState extends State style: TextStyle(color: AppColors.textWhite, fontSize: 15), ), subtitle: Text( - 'Android stops background apps to save power. Disabling battery ' - 'optimisation is what keeps the VPN running when the app is closed.', + 'Android may restrict background work to save power. ReVolt only ' + 'shows the system control it can verify.', style: TextStyle(color: AppColors.textDim, fontSize: 12), ), ), @@ -83,8 +78,8 @@ class _BackgroundReliabilityTileState extends State ? 'Battery optimisation: checking…' : exempt ? 'Battery optimisation: disabled for Revolt VPN ✓' - : 'Battery optimisation: still active — the system may ' - 'stop the VPN in the background.', + : 'Battery optimisation: active — Android may stop ' + 'background VPN work.', style: TextStyle( color: exempt == true ? AppColors.accent : AppColors.textMuted, fontSize: 12, diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart index 021c3b0..dde1703 100644 --- a/lib/screens/settings/in_settings/connection_mode.dart +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -51,8 +51,14 @@ class _ConnectionModeTileState extends State { return; } - await ConnectionSettings.setMode(next); + final saved = await ConnectionSettings.setMode(next); if (!mounted) return; + if (!saved) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not save connection mode.')), + ); + return; + } setState(() { _mode = next; @@ -61,10 +67,6 @@ class _ConnectionModeTileState extends State { widget.onChanged?.call(next); } - /// Recovers from an orphaned proxy: tear the runtime down and start a fresh - /// session so a readable set of credentials exists again. No rewarded ad — - /// the user is repairing a connection they already paid for, not opening a - /// new one. Future _reconnect() async { if (_reconnecting) return; final vpn = context.read(); @@ -168,11 +170,6 @@ class _ConnectionModeTileState extends State { builder: (context, vpn, _) { final session = vpn.activeSocksSession; if (session == null) { - // The proxy can be running while this session is unknown: - // Android killed the app process, the foreground service kept - // the listener alive, and its credentials existed only in the - // memory that died with it. Saying "Connect" there would be a - // lie — the user is already connected. final orphaned = vpn.status == VpnStatus.connected && vpn.adoptedRunningRuntime; if (orphaned) { diff --git a/lib/screens/settings/in_settings/lightning.dart b/lib/screens/settings/in_settings/lightning.dart index 944b6ff..4cfba72 100644 --- a/lib/screens/settings/in_settings/lightning.dart +++ b/lib/screens/settings/in_settings/lightning.dart @@ -2,9 +2,6 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:revoltvpn/logic/app_colors.dart'; -// ── lib/screens/settings/in_settings/lightning.dart ───────────────────────── -// Toggle for the lightning strike overlay on the main screen. - final ValueNotifier lightningEnabled = ValueNotifier(true); Future loadLightningPref() async { @@ -35,7 +32,9 @@ class _LightningToggleTileState extends State { Future _toggle(bool value) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('lightning_effect_enabled', value); + final saved = await prefs.setBool('lightning_effect_enabled', value); + if (!saved || !mounted) return; + lightningEnabled.value = value; setState(() => _on = value); } diff --git a/lib/screens/settings/in_settings/rain.dart b/lib/screens/settings/in_settings/rain.dart index 929b228..d894f82 100644 --- a/lib/screens/settings/in_settings/rain.dart +++ b/lib/screens/settings/in_settings/rain.dart @@ -2,9 +2,6 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:revoltvpn/logic/app_colors.dart'; -// ── lib/screens/settings/in_settings/rain.dart ────────────────────────────── -// Toggle for the animated rain overlay on the main screen. - final ValueNotifier rainEnabled = ValueNotifier(true); Future loadRainPref() async { @@ -35,7 +32,9 @@ class _RainToggleTileState extends State { Future _toggle(bool value) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('rain_effect_enabled', value); + final saved = await prefs.setBool('rain_effect_enabled', value); + if (!saved || !mounted) return; + rainEnabled.value = value; setState(() => _on = value); }