diff --git a/.gitignore b/.gitignore index 6fff17c..e59e800 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,6 @@ android/admob.properties lib/logic/app_config.dart # ── File types the shipped app does not contain ──────────────────────────── -# Server code, infrastructure configs, captures, archives, dumps and logs. -# None of these extensions are tracked, so a blanket rule is safe and stays -# safe — it covers files that do not exist yet. *.py *.pyc *.json @@ -53,6 +50,7 @@ scraped/ backup/ backups/ lib/logic/backup/ +server_files/ # ── Flutter / Dart build output ──────────────────────────────────────────── .dart_tool/ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d7ce2b1..0a5bbaf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,11 +18,6 @@ - - - - - - if (call.method == "getLaunchableApps") { - result.success(launchableApps()) - } else { - result.notImplemented() - } - } - EventChannel(flutterEngine.dartExecutor.binaryMessenger, networkChannel) .setStreamHandler(object : EventChannel.StreamHandler { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { @@ -129,6 +114,14 @@ class MainActivity : FlutterActivity() { stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) + // Public lock-screen preview: keep the VPN status visible but hide the + // session countdown/speed so usage metadata never reaches the lock screen. + val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.notification_status_icon) + .setContentTitle("Revolt VPN") + .setContentText("VPN is active") + .build() + val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.notification_status_icon) .setContentTitle(title) @@ -142,6 +135,8 @@ class MainActivity : FlutterActivity() { .setSilent(true) .setShowWhen(false) .setPriority(NotificationCompat.PRIORITY_LOW) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) if (launchIntent != null) { builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags)) @@ -311,54 +306,6 @@ class MainActivity : FlutterActivity() { null } - private fun launchableApps(): List> { - val intent = Intent(Intent.ACTION_MAIN).apply { - addCategory(Intent.CATEGORY_LAUNCHER) - } - - val resolved = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.queryIntentActivities( - intent, - PackageManager.ResolveInfoFlags.of(0L), - ) - } else { - @Suppress("DEPRECATION") - packageManager.queryIntentActivities(intent, 0) - } - - return resolved - .mapNotNull { info -> - val appPackage = info.activityInfo?.packageName ?: return@mapNotNull null - if (appPackage == packageName) return@mapNotNull null - - val item = mutableMapOf( - "packageName" to appPackage, - "label" to info.loadLabel(packageManager).toString(), - ) - appIconPng(info)?.let { item["icon"] = it } - item - } - .distinctBy { it["packageName"] as String } - .sortedBy { (it["label"] as String).lowercase() } - } - - private fun appIconPng(info: ResolveInfo): ByteArray? = try { - val drawable = info.loadIcon(packageManager) - val density = resources.displayMetrics.density - val size = (48f * density).roundToInt().coerceIn(48, 144) - val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, size, size) - drawable.draw(canvas) - - ByteArrayOutputStream().use { output -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) - output.toByteArray() - } - } catch (_: Exception) { - null - } - private fun performHaptic(kind: String): Boolean { return try { val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { diff --git a/lib/components/app_haptic_surface.dart b/lib/components/app_haptic_surface.dart deleted file mode 100644 index b969ec4..0000000 --- a/lib/components/app_haptic_surface.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:revoltvpn/logic/haptic_settings.dart'; - -/// Gives short touch taps the same haptic feedback across the app. -/// Scroll gestures are ignored by checking travel distance and touch duration. -class AppHapticSurface extends StatefulWidget { - final Widget child; - - const AppHapticSurface({super.key, required this.child}); - - @override - State createState() => _AppHapticSurfaceState(); -} - -class _PointerStart { - final Offset position; - final DateTime time; - - const _PointerStart(this.position, this.time); -} - -class _AppHapticSurfaceState extends State { - static const double _maxTapTravel = 18; - static const Duration _maxTapDuration = Duration(milliseconds: 700); - - final Map _starts = {}; - - bool _supportsHaptics(PointerDeviceKind kind) { - return kind == PointerDeviceKind.touch || - kind == PointerDeviceKind.stylus || - kind == PointerDeviceKind.invertedStylus; - } - - void _onDown(PointerDownEvent event) { - if (!_supportsHaptics(event.kind)) return; - _starts[event.pointer] = _PointerStart(event.position, DateTime.now()); - } - - void _onCancel(PointerCancelEvent event) { - _starts.remove(event.pointer); - } - - void _onUp(PointerUpEvent event) { - final start = _starts.remove(event.pointer); - if (start == null || !_supportsHaptics(event.kind)) return; - - final travelled = (event.position - start.position).distance; - final elapsed = DateTime.now().difference(start.time); - if (travelled <= _maxTapTravel && elapsed <= _maxTapDuration) { - HapticSettings.tap(); - } - } - - @override - Widget build(BuildContext context) { - return Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: _onDown, - onPointerUp: _onUp, - onPointerCancel: _onCancel, - child: widget.child, - ); - } -} diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart index dc5cde1..2e739ee 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -4,6 +4,7 @@ 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/ad_manager.dart'; +import 'package:revoltvpn/logic/haptic_settings.dart'; class ConnectButton extends StatefulWidget { const ConnectButton({super.key}); @@ -82,6 +83,7 @@ class _ConnectButtonState extends State if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) { _busy = false; await timer.disconnect(); + HapticSettings.success(); return; } @@ -93,7 +95,10 @@ class _ConnectButtonState extends State if (!adWatched && AdManager.adsEnabled) return; final ok = await vpn.connect(); - if (ok) await timer.start(); + if (ok) { + await timer.start(); + HapticSettings.success(); + } } finally { if (mounted) setState(() => _busy = false); } diff --git a/lib/components/notification.dart b/lib/components/notification.dart deleted file mode 100644 index a62a2a8..0000000 --- a/lib/components/notification.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'dart:io'; -import 'dart:ui' as ui; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; - -class VpnNotificationManager { - static final FlutterLocalNotificationsPlugin _plugin = - FlutterLocalNotificationsPlugin(); - static bool _initialized = false; - static String? _lastTimeLeft; - static String? _lastSpeedKbps; - - // Android channels are immutable — bump this if settings change. - static const _channelId = 'revolt_vpn_v4'; - static const _channelName = 'Revolt VPN'; - static const _notifId = 888; - - static Future init() async { - if (_initialized) return; - - const androidSettings = - AndroidInitializationSettings('@drawable/notification_icon'); - const initSettings = InitializationSettings(android: androidSettings); - - await _plugin.initialize(initSettings); - - if (Platform.isAndroid) { - final androidPlugin = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); - await androidPlugin?.requestNotificationsPermission(); - } - - _initialized = true; - } - - static Future showOrUpdateStatus({ - required String timeLeft, - required String speedKbps, - }) async { - if (timeLeft == _lastTimeLeft && speedKbps == _lastSpeedKbps) return; - _lastTimeLeft = timeLeft; - _lastSpeedKbps = speedKbps; - - if (!_initialized) await init(); - - final bodyText = 'Time: $timeLeft • $speedKbps'; - - final bigPictureStyle = BigPictureStyleInformation( - const DrawableResourceAndroidBitmap('notification_banner'), - largeIcon: const DrawableResourceAndroidBitmap('notification_icon'), - contentTitle: 'Revolt VPN', - summaryText: bodyText, - htmlFormatContentTitle: false, - htmlFormatSummaryText: false, - ); - - final androidDetails = AndroidNotificationDetails( - _channelId, - _channelName, - channelDescription: 'Session timer and speed', - importance: Importance.low, - priority: Priority.low, - ongoing: true, - autoCancel: false, - playSound: false, - enableVibration: false, - showWhen: false, - // Session time/speed is usage metadata. Keep it out of public lock-screen - // previews while leaving the ongoing VPN status notification intact. - visibility: NotificationVisibility.private, - usesChronometer: false, - category: AndroidNotificationCategory.service, - colorized: true, - color: const ui.Color(0xFF0D1117), - styleInformation: bigPictureStyle, - ); - - final details = NotificationDetails(android: androidDetails); - - try { - await _plugin.show(_notifId, _channelName, bodyText, details); - } catch (e) { - _initialized = false; - try { - await init(); - await _plugin.show(_notifId, _channelName, bodyText, details); - } catch (_) {} - } - } - - static Future cancel() async { - if (!_initialized) return; - await _plugin.cancel(_notifId); - } -} diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart index f197ac8..9e640b4 100644 --- a/lib/components/status_bar.dart +++ b/lib/components/status_bar.dart @@ -104,6 +104,18 @@ class StatusBar extends StatelessWidget { label: statusLabel, ), const Spacer(), + if (vpn.networkTransport != 'unknown' && + vpn.networkTransport != 'none') + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + vpn.networkTransport, + style: const TextStyle( + color: AppColors.textDim, + fontSize: 11, + ), + ), + ), if (isConnected) const Icon( Icons.lock, diff --git a/lib/logic/connection_settings.dart b/lib/logic/connection_settings.dart index 541d826..9e5f571 100644 --- a/lib/logic/connection_settings.dart +++ b/lib/logic/connection_settings.dart @@ -1,73 +1,40 @@ import 'package:shared_preferences/shared_preferences.dart'; enum ConnectionMode { tun, proxy } -enum AppRoutingMode { all, exclude, selected } enum ResilienceMode { standard, extreme } abstract final class ConnectionSettings { ConnectionSettings._(); static const String _modeKey = 'connection_mode'; - static const String _routingModeKey = 'app_routing_mode'; - static const String _appPackagesKey = 'app_routing_packages'; - static const String _legacyBlockedAppsKey = 'blocked_apps'; static const String _resilienceModeKey = 'resilience_mode'; static ConnectionMode _mode = ConnectionMode.tun; - static AppRoutingMode _routingMode = AppRoutingMode.all; static ResilienceMode _resilienceMode = ResilienceMode.standard; - static List _appPackages = const []; static bool _initialized = false; static ConnectionMode get mode => _mode; - static AppRoutingMode get routingMode => _routingMode; static ResilienceMode get resilienceMode => _resilienceMode; - static List get appPackages => List.unmodifiable(_appPackages); static Future initialize() async { if (_initialized) return; final prefs = await SharedPreferences.getInstance(); final storedMode = prefs.getString(_modeKey); - final migrateLegacyAss = storedMode == 'ass'; - final migrateLegacyAuto = storedMode == 'auto' || storedMode == null; - if (storedMode == ConnectionMode.proxy.name || migrateLegacyAss) { - _mode = ConnectionMode.proxy; - } else { - _mode = ConnectionMode.tun; - } - - final legacyBlocked = - prefs.getStringList(_legacyBlockedAppsKey) ?? const []; - final storedPackages = prefs.getStringList(_appPackagesKey); - _appPackages = _cleanPackages(storedPackages ?? legacyBlocked); - - final storedRoutingMode = prefs.getString(_routingModeKey); - if (migrateLegacyAss) { - _routingMode = _appPackages.isEmpty - ? AppRoutingMode.all - : AppRoutingMode.selected; - } else if (storedRoutingMode == AppRoutingMode.exclude.name) { - _routingMode = AppRoutingMode.exclude; - } else if (storedRoutingMode == AppRoutingMode.selected.name) { - _routingMode = AppRoutingMode.selected; - } else if (storedRoutingMode == null && legacyBlocked.isNotEmpty) { - _routingMode = AppRoutingMode.exclude; - } else { - _routingMode = AppRoutingMode.all; - } + // Legacy 'ass' was the pre-V3 SOCKS5 label; map it to proxy. Everything + // else (including the old 'auto') is TUN — there is no runtime auto mode. + _mode = storedMode == ConnectionMode.proxy.name || storedMode == 'ass' + ? ConnectionMode.proxy + : ConnectionMode.tun; _resilienceMode = prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name ? ResilienceMode.extreme : ResilienceMode.standard; - if (migrateLegacyAss) { - await prefs.setString(_modeKey, ConnectionMode.proxy.name); - await prefs.setString(_routingModeKey, _routingMode.name); - } else if (migrateLegacyAuto) { - await prefs.setString(_modeKey, ConnectionMode.tun.name); + if (storedMode != _mode.name) { + await prefs.setString(_modeKey, _mode.name); } _initialized = true; @@ -80,36 +47,10 @@ abstract final class ConnectionSettings { await prefs.setString(_modeKey, mode.name); } - static Future setRoutingMode(AppRoutingMode mode) async { - _routingMode = mode; - _initialized = true; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_routingModeKey, mode.name); - } - - static Future setAppPackages(Iterable packages) async { - _appPackages = _cleanPackages(packages); - _initialized = true; - final prefs = await SharedPreferences.getInstance(); - await prefs.setStringList(_appPackagesKey, _appPackages); - } - static Future setResilienceMode(ResilienceMode mode) async { _resilienceMode = mode; _initialized = true; final prefs = await SharedPreferences.getInstance(); await prefs.setString(_resilienceModeKey, mode.name); } - - static List _cleanPackages(Iterable packages) { - final seen = {}; - final result = []; - for (final raw in packages) { - final value = raw.trim(); - if (value.isEmpty || !seen.add(value)) continue; - result.add(value); - } - result.sort(); - return result; - } } diff --git a/lib/logic/haptic_settings.dart b/lib/logic/haptic_settings.dart index cf3c891..6630da5 100644 --- a/lib/logic/haptic_settings.dart +++ b/lib/logic/haptic_settings.dart @@ -4,8 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; -enum HapticKind { tap, selection, success } - abstract final class HapticSettings { HapticSettings._(); @@ -13,7 +11,7 @@ abstract final class HapticSettings { static const MethodChannel _channel = MethodChannel('com.revoltvpn.app/haptics'); - static bool _enabled = true; + static bool _enabled = false; static bool _initialized = false; static bool get enabled => _enabled; @@ -23,10 +21,10 @@ abstract final class HapticSettings { try { final prefs = await SharedPreferences.getInstance(); - _enabled = prefs.getBool(_prefKey) ?? true; + _enabled = prefs.getBool(_prefKey) ?? false; } catch (e) { debugPrint('[Haptics] Failed to load preference: $e'); - _enabled = true; + _enabled = false; } finally { _initialized = true; } @@ -48,11 +46,11 @@ abstract final class HapticSettings { } } - static void tap() => unawaited(_impact(HapticKind.tap)); - static void selection() => unawaited(_impact(HapticKind.selection)); - static void success() => unawaited(_impact(HapticKind.success)); + /// Confirmation haptic for a successful connect/disconnect. Best-effort: a + /// vibration failure must never block or fail the VPN action. + static void success() => unawaited(_impact()); - static Future _impact(HapticKind kind) async { + static Future _impact() async { if (!_initialized) await initialize(); if (!_enabled || kIsWeb) return; @@ -60,7 +58,7 @@ abstract final class HapticSettings { try { final handled = await _channel.invokeMethod( 'impact', - {'kind': kind.name}, + {'kind': 'success'}, ) ?? false; if (handled) return; @@ -69,16 +67,6 @@ abstract final class HapticSettings { } } - switch (kind) { - case HapticKind.tap: - await HapticFeedback.lightImpact(); - break; - case HapticKind.selection: - await HapticFeedback.selectionClick(); - break; - case HapticKind.success: - await HapticFeedback.mediumImpact(); - break; - } + await HapticFeedback.mediumImpact(); } } diff --git a/lib/logic/installed_apps_service.dart b/lib/logic/installed_apps_service.dart deleted file mode 100644 index 18e4e3a..0000000 --- a/lib/logic/installed_apps_service.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/services.dart'; - -class InstalledApp { - final String packageName; - final String label; - final Uint8List? iconPng; - - const InstalledApp({ - required this.packageName, - required this.label, - required this.iconPng, - }); -} - -abstract final class InstalledAppsService { - InstalledAppsService._(); - - static const MethodChannel _channel = MethodChannel('com.revoltvpn.app/apps'); - - static Future> loadLaunchableApps() async { - final raw = await _channel.invokeListMethod('getLaunchableApps') ?? - const []; - final apps = []; - - for (final item in raw) { - if (item is! Map) continue; - - final packageName = item['packageName']; - final label = item['label']; - final icon = item['icon']; - - if (packageName is! String || packageName.isEmpty) continue; - - apps.add( - InstalledApp( - packageName: packageName, - label: label is String && label.isNotEmpty ? label : packageName, - iconPng: icon is Uint8List ? icon : null, - ), - ); - } - - apps.sort( - (a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase()), - ); - return apps; - } -} diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index d982589..1f2f7ce 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_vless/flutter_vless.dart'; import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/hivemind_service.dart'; -import 'package:revoltvpn/logic/installed_apps_service.dart'; import 'package:revoltvpn/logic/local_socks_tester.dart'; import 'package:revoltvpn/logic/network_monitor.dart'; import 'package:revoltvpn/logic/secure_socks_session.dart'; @@ -17,18 +16,6 @@ enum VpnStatus { error, } -class _RoutingPlan { - final List? blockedApps; - final bool selectedOnly; - final bool verifyLocalSocks; - - const _RoutingPlan({ - required this.blockedApps, - this.selectedOnly = false, - this.verifyLocalSocks = false, - }); -} - class VpnConnection extends ChangeNotifier { static const int _maxExtremeRecoveryAttempts = 2; @@ -54,41 +41,10 @@ class VpnConnection extends ChangeNotifier { ResilienceMode _activeResilienceMode = ResilienceMode.standard; ResilienceMode get activeResilienceMode => _activeResilienceMode; - bool _selectedRoutingActive = false; - bool get selectedRoutingActive => _selectedRoutingActive; - String _networkTransport = 'unknown'; String get networkTransport => _networkTransport; - bool _networkValidated = false; - bool get networkValidated => _networkValidated; - - int? _lastHealthLatencyMs; - int? get lastHealthLatencyMs => _lastHealthLatencyMs; - - int _reconnectCount = 0; - int get reconnectCount => _reconnectCount; - - int get fallbackCount => 0; - - String? _lastRecoveryReason; - String? get lastRecoveryReason => _lastRecoveryReason; - - String get activeRoutingDescription { - if (_activeMode == ConnectionMode.proxy) { - if (_selectedRoutingActive) return 'SOCKS5 · selected apps'; - if (_lastBlockedApps?.isNotEmpty == true) return 'SOCKS5 · exclude apps'; - return 'SOCKS5 · all apps'; - } - if (_selectedRoutingActive) return 'Selected apps'; - if (_lastBlockedApps?.isNotEmpty == true) return 'Exclude apps'; - return 'All apps'; - } - - String get activeTransportProfile => - _status == VpnStatus.connected || _status == VpnStatus.connecting - ? 'server' - : 'inactive'; + SecureSocksSession? get activeSocksSession => _lastSecureSocks; bool get canTestActiveLocalSocks => _status == VpnStatus.connected && @@ -119,12 +75,11 @@ class VpnConnection extends ChangeNotifier { String? _lastBaseConfig; String _lastRemark = 'Revolt VPN'; - List? _lastBlockedApps; - bool _lastSelectedOnly = false; bool _lastVerifyLocalSocks = false; SecureSocksSession? _lastSecureSocks; bool _restartInProgress = false; bool _userDisconnecting = false; + int _connectionEpoch = 0; late final FlutterVless _vless; bool _initialized = false; @@ -180,7 +135,6 @@ class VpnConnection extends ChangeNotifier { // ConnectivityManager is informational only. Restarting from every // callback caused the old VPN-created-network reconnect loop. _networkTransport = snapshot.transport; - _networkValidated = snapshot.validated; notifyListeners(); }, onError: (Object error) { @@ -199,42 +153,65 @@ class VpnConnection extends ChangeNotifier { final delay = await _vless.getConnectedServerDelay(); if (delay > 0) { - _lastHealthLatencyMs = delay; _isStartupRestoration = true; - _restoreRoutingPresentationFromSettings(); + _lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy; _setStatus(VpnStatus.connected, _connectedLabel); + unawaited(_restoreRecoverySnapshot()); } } catch (_) {} } - void _restoreRoutingPresentationFromSettings() { - final routingMode = ConnectionSettings.routingMode; - final packages = ConnectionSettings.appPackages; + /// 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. + /// + /// 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; - _lastBlockedApps = null; - _lastSelectedOnly = false; - _lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy; - _selectedRoutingActive = false; + final epoch = _connectionEpoch; + bool stale() => + _connectionEpoch != epoch || + _userDisconnecting || + _status != VpnStatus.connected; - if (routingMode == AppRoutingMode.selected) { - _lastSelectedOnly = packages.isNotEmpty; - _selectedRoutingActive = packages.isNotEmpty; - 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; - if (routingMode == AppRoutingMode.exclude && packages.isNotEmpty) { - _lastBlockedApps = List.of(packages); + 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'); + } 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'); } } String get _connectedLabel { switch (_activeMode) { case ConnectionMode.proxy: - return _selectedRoutingActive - ? 'SOCKS5 · selected apps secured' - : 'SOCKS5 gateway active'; + return 'SOCKS5 gateway active'; case ConnectionMode.tun: - return _selectedRoutingActive ? 'Selected apps secured' : 'Secured'; + return 'Secured'; } } @@ -293,33 +270,21 @@ class VpnConnection extends ChangeNotifier { _cancelled = false; _userDisconnecting = false; _restartTimer?.cancel(); - _reconnectCount = 0; - _lastRecoveryReason = null; - _lastHealthLatencyMs = null; - _selectedRoutingActive = false; + _connectionEpoch++; await ConnectionSettings.initialize(); _activeMode = ConnectionSettings.mode; _activeResilienceMode = ConnectionSettings.resilienceMode; - late final _RoutingPlan routingPlan; - try { - routingPlan = await _resolveRoutingPlan(); - } catch (e) { - _errorMessage = e.toString().replaceAll('Bad state: ', ''); - _setStatus(VpnStatus.error, 'App routing error'); - return false; - } - if (!kIsWeb && !_initialized) { _errorMessage = 'VPN service unavailable.'; _setStatus(VpnStatus.error, 'Service unavailable'); return false; } - // TUN and SOCKS5 share the stable Android VpnService. SOCKS5 uses - // an authenticated per-session local ingress instead of a fixed port. - if (!kIsWeb) { + // TUN needs the Android VPN permission; SOCKS5 proxy mode runs a local + // authenticated proxy without a VpnService, so no VPN permission is needed. + if (!kIsWeb && _activeMode == ConnectionMode.tun) { final ok = await _vless.requestPermission(); if (!ok) { _errorMessage = 'VPN permission denied.'; @@ -329,12 +294,8 @@ class VpnConnection extends ChangeNotifier { } final startingMessage = switch (_activeMode) { - ConnectionMode.tun => routingPlan.selectedOnly - ? 'Starting selected-app tunnel…' - : 'Establishing secure channel…', - ConnectionMode.proxy => routingPlan.selectedOnly - ? 'SOCKS5 · starting selected-app gateway…' - : 'Starting transparent SOCKS5 gateway…', + ConnectionMode.tun => 'Establishing secure channel…', + ConnectionMode.proxy => 'Starting local SOCKS5 proxy…', }; _setStatus(VpnStatus.connecting, startingMessage); _errorMessage = null; @@ -385,23 +346,23 @@ class VpnConnection extends ChangeNotifier { final baseConfig = parsed.getFullConfiguration(); final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN'; final secureSocks = await SecureSocksSession.create(baseConfig); - - // Set this before the native callback can report CONNECTED so the first - // visible label already reflects Selected only accurately. - _selectedRoutingActive = routingPlan.selectedOnly; + final verifyLocalSocks = _activeMode == ConnectionMode.proxy; await _startRuntime( config: secureSocks.configJson, remark: remark, - blockedApps: routingPlan.blockedApps, + proxyOnly: verifyLocalSocks, ); - _setStatus(VpnStatus.connecting, 'Waiting for VPN interface…'); + _setStatus( + VpnStatus.connecting, + verifyLocalSocks ? 'Waiting for local SOCKS5…' : 'Waiting for VPN interface…', + ); if (!await _waitForNativeConnected()) { throw StateError('VPN runtime did not report CONNECTED'); } - if (routingPlan.verifyLocalSocks) { + if (verifyLocalSocks) { _setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…'); if (!await _waitForLocalSocksListener(secureSocks)) { throw StateError('Local SOCKS5 listener did not become ready'); @@ -411,12 +372,7 @@ class VpnConnection extends ChangeNotifier { _lastBaseConfig = secureSocks.configJson; _lastSecureSocks = secureSocks; _lastRemark = remark; - _lastBlockedApps = routingPlan.blockedApps == null - ? null - : List.of(routingPlan.blockedApps!); - _lastSelectedOnly = routingPlan.selectedOnly; - _lastVerifyLocalSocks = routingPlan.verifyLocalSocks; - _selectedRoutingActive = routingPlan.selectedOnly; + _lastVerifyLocalSocks = verifyLocalSocks; } catch (e) { debugPrint('[VPN] Tunnel start error: $e'); await _stopRuntime(); @@ -432,95 +388,15 @@ class VpnConnection extends ChangeNotifier { return true; } - Future<_RoutingPlan> _resolveRoutingPlan() async { - final routingMode = ConnectionSettings.routingMode; - final selected = ConnectionSettings.appPackages; - - switch (_activeMode) { - case ConnectionMode.tun: - if (routingMode == AppRoutingMode.selected) { - return _selectedCompatibilityPlan(); - } - return _tunPlan(routingMode, selected); - - case ConnectionMode.proxy: - // SOCKS5 is a transparent gateway: Android captures ordinary app - // traffic into the same local SOCKS ingress used by Xray/tun2socks. - if (routingMode == AppRoutingMode.selected) { - return _selectedCompatibilityPlan(verifyLocalSocks: true); - } - return _tunPlan( - routingMode, - selected, - verifyLocalSocks: true, - ); - } - } - - _RoutingPlan _tunPlan( - AppRoutingMode routingMode, - List selected, { - bool verifyLocalSocks = false, - }) { - if (routingMode == AppRoutingMode.exclude) { - return _RoutingPlan( - blockedApps: selected.isEmpty ? null : selected, - verifyLocalSocks: verifyLocalSocks, - ); - } - - return _RoutingPlan( - blockedApps: null, - verifyLocalSocks: verifyLocalSocks, - ); - } - - Future<_RoutingPlan> _selectedCompatibilityPlan({ - bool verifyLocalSocks = false, - }) async { - final selected = ConnectionSettings.appPackages.toSet(); - if (selected.isEmpty) { - throw StateError('Select at least one app.'); - } - - final apps = await InstalledAppsService.loadLaunchableApps(); - final availableSelected = apps - .where((app) => selected.contains(app.packageName)) - .map((app) => app.packageName) - .toSet(); - - if (availableSelected.isEmpty) { - throw StateError('None of the selected apps are installed.'); - } - - // Compatibility Selected only uses the plugin's proven blocklist path: - // every other launchable user app bypasses the VPN, while Android system - // helpers (DNS resolver, Play Services, network stack, etc.) remain able to - // support selected apps such as Discord. - final blocked = apps - .map((app) => app.packageName) - .where((packageName) => !availableSelected.contains(packageName)) - .toSet() - .toList() - ..sort(); - - return _RoutingPlan( - blockedApps: blocked.isEmpty ? null : blocked, - selectedOnly: true, - verifyLocalSocks: verifyLocalSocks, - ); - } - Future _startRuntime({ required String config, required String remark, - required List? blockedApps, + required bool proxyOnly, }) async { await _vless.startVless( remark: remark, config: config, - blockedApps: blockedApps, - proxyOnly: false, + proxyOnly: proxyOnly, ); } @@ -538,8 +414,6 @@ class VpnConnection extends ChangeNotifier { password: active.password, ); if (result.ok) { - _lastHealthLatencyMs = result.latencyMs; - notifyListeners(); return true; } if (attempt < 3) { @@ -571,7 +445,6 @@ class VpnConnection extends ChangeNotifier { return; } - _lastRecoveryReason = reason; _setStatus(VpnStatus.connecting, 'Reconnecting…'); _restartTimer = Timer(const Duration(milliseconds: 1200), () { unawaited(_restartActiveRuntime(reason)); @@ -584,11 +457,12 @@ class VpnConnection extends ChangeNotifier { } _restartInProgress = true; + final epoch = _connectionEpoch; Object? lastError; try { for (var attempt = 1; attempt <= _maxExtremeRecoveryAttempts; attempt++) { - if (_userDisconnecting || _lastBaseConfig == null) return; + if (_connectionEpoch != epoch || _userDisconnecting || _lastBaseConfig == null) return; try { debugPrint( @@ -607,7 +481,7 @@ class VpnConnection extends ChangeNotifier { await _startRuntime( config: _lastBaseConfig!, remark: _lastRemark, - blockedApps: _lastBlockedApps, + proxyOnly: _activeMode == ConnectionMode.proxy, ); if (!await _waitForNativeConnected()) { @@ -619,9 +493,6 @@ class VpnConnection extends ChangeNotifier { if (_userDisconnecting) return; - _reconnectCount++; - _lastRecoveryReason = reason; - _selectedRoutingActive = _lastSelectedOnly; _errorMessage = null; _setStatus(VpnStatus.connected, _connectedLabel); return; @@ -716,12 +587,8 @@ class VpnConnection extends ChangeNotifier { void _clearRuntimeSnapshot() { _lastBaseConfig = null; - _lastBlockedApps = null; - _lastSelectedOnly = false; _lastVerifyLocalSocks = false; _lastSecureSocks = null; - _selectedRoutingActive = false; - _lastHealthLatencyMs = null; _isStartupRestoration = false; } @@ -732,6 +599,9 @@ class VpnConnection extends ChangeNotifier { } Future _checkHealth() async { + // A live tunnel already proves reachability; only probe while disconnected + // so we don't add direct /health traffic while the VPN is active. + if (_status == VpnStatus.connected) return; _serverReachable = await HivemindService.checkHealth(); notifyListeners(); } diff --git a/lib/main.dart b/lib/main.dart index 36359c5..b11a401 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,6 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:revoltvpn/components/app_haptic_surface.dart'; import 'package:revoltvpn/logic/ad_manager.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/connection_settings.dart'; @@ -63,8 +62,6 @@ class ReVoltApp extends StatelessWidget { title: 'ReVolt VPN', debugShowCheckedModeBanner: false, theme: _buildDarkTheme(), - builder: (context, child) => - AppHapticSurface(child: child ?? const SizedBox.shrink()), home: const IntroScreen(), ), ); diff --git a/lib/screens/settings/in_settings/app_routing.dart b/lib/screens/settings/in_settings/app_routing.dart deleted file mode 100644 index 00de4f6..0000000 --- a/lib/screens/settings/in_settings/app_routing.dart +++ /dev/null @@ -1,329 +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/installed_apps_service.dart'; -import 'package:revoltvpn/logic/vpn_connection.dart'; - -class AppRoutingTile extends StatefulWidget { - final ConnectionMode connectionMode; - - const AppRoutingTile({super.key, required this.connectionMode}); - - @override - State createState() => _AppRoutingTileState(); -} - -class _AppRoutingTileState extends State { - AppRoutingMode _mode = ConnectionSettings.routingMode; - int _selectedCount = ConnectionSettings.appPackages.length; - - bool get _proxyMode => widget.connectionMode == ConnectionMode.proxy; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - await ConnectionSettings.initialize(); - if (!mounted) return; - setState(() { - _mode = ConnectionSettings.routingMode; - _selectedCount = ConnectionSettings.appPackages.length; - }); - } - - bool _vpnBusy() { - final status = context.read().status; - return status == VpnStatus.connected || - status == VpnStatus.connecting || - status == VpnStatus.disconnecting; - } - - void _showDisconnectFirst() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Disconnect before changing app routing.')), - ); - } - - Future _changeMode(AppRoutingMode? next) async { - if (next == null || next == _mode) return; - if (_vpnBusy()) { - _showDisconnectFirst(); - return; - } - - await ConnectionSettings.setRoutingMode(next); - if (mounted) setState(() => _mode = next); - } - - Future _openPicker() async { - if (_mode == AppRoutingMode.all) return; - if (_vpnBusy()) { - _showDisconnectFirst(); - return; - } - - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => _AppPicker(mode: _mode), - ), - ); - - if (mounted) { - setState(() => _selectedCount = ConnectionSettings.appPackages.length); - } - } - - String get _subtitle { - if (_proxyMode) { - switch (_mode) { - case AppRoutingMode.all: - return 'Transparent SOCKS5 routes all apps automatically.'; - case AppRoutingMode.exclude: - return _selectedCount == 0 - ? 'Transparent SOCKS5 routes all apps. No apps are excluded.' - : 'SOCKS5 routes all apps except $_selectedCount selected bypass app${_selectedCount == 1 ? '' : 's'}.'; - case AppRoutingMode.selected: - return _selectedCount == 0 - ? 'Choose apps for compatibility selected-app SOCKS routing.' - : 'SOCKS5 routes $_selectedCount selected app${_selectedCount == 1 ? '' : 's'} while other launcher apps bypass it.'; - } - } - - switch (_mode) { - case AppRoutingMode.all: - return 'All apps use the TUN VPN.'; - case AppRoutingMode.exclude: - return _selectedCount == 0 - ? 'No apps excluded from TUN.' - : '$_selectedCount app${_selectedCount == 1 ? '' : 's'} bypass the TUN VPN.'; - case AppRoutingMode.selected: - return _selectedCount == 0 - ? 'Choose at least one app for compatible Selected only routing.' - : '$_selectedCount selected user app${_selectedCount == 1 ? '' : 's'} are targeted; Android network helpers stay available.'; - } - } - - @override - Widget build(BuildContext context) { - final showPicker = _mode != AppRoutingMode.all; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListTile( - title: const Text( - 'App routing', - style: TextStyle(color: AppColors.textWhite, fontSize: 15), - ), - subtitle: Text( - _subtitle, - style: const TextStyle(color: AppColors.textDim, fontSize: 12), - ), - trailing: DropdownButtonHideUnderline( - child: DropdownButton( - value: _mode, - dropdownColor: AppColors.bgCard, - onChanged: _changeMode, - items: const [ - DropdownMenuItem( - value: AppRoutingMode.all, - child: Text('All apps'), - ), - DropdownMenuItem( - value: AppRoutingMode.exclude, - child: Text('Exclude apps'), - ), - DropdownMenuItem( - value: AppRoutingMode.selected, - child: Text('Selected only'), - ), - ], - ), - ), - ), - if (showPicker) - ListTile( - onTap: _openPicker, - leading: const Icon(Icons.apps), - title: Text( - _mode == AppRoutingMode.selected - ? 'Choose routed apps' - : 'Choose excluded apps', - ), - subtitle: Text( - _mode == AppRoutingMode.selected - ? 'Selected user apps are targeted; Android network helpers remain available for compatibility.' - : 'Routing is applied to the selected Android package IDs.', - ), - trailing: const Icon(Icons.chevron_right), - ), - ], - ); - } -} - -class _AppPicker extends StatefulWidget { - final AppRoutingMode mode; - - const _AppPicker({required this.mode}); - - @override - State<_AppPicker> createState() => _AppPickerState(); -} - -class _AppPickerState extends State<_AppPicker> { - final Set _selected = {...ConnectionSettings.appPackages}; - final TextEditingController _search = TextEditingController(); - - List _apps = const []; - bool _loading = true; - String? _error; - String _query = ''; - - @override - void initState() { - super.initState(); - _loadApps(); - } - - @override - void dispose() { - _search.dispose(); - super.dispose(); - } - - Future _loadApps() async { - try { - final apps = await InstalledAppsService.loadLaunchableApps(); - if (!mounted) return; - setState(() { - _apps = apps; - _loading = false; - }); - } catch (_) { - if (!mounted) return; - setState(() { - _error = 'Could not load installed apps.'; - _loading = false; - }); - } - } - - Future _save() async { - if (widget.mode == AppRoutingMode.selected && _selected.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Select at least one app.')), - ); - return; - } - - await ConnectionSettings.setAppPackages(_selected); - if (mounted) Navigator.of(context).pop(); - } - - @override - Widget build(BuildContext context) { - final query = _query.trim().toLowerCase(); - final visibleApps = query.isEmpty - ? _apps - : _apps - .where( - (app) => - app.label.toLowerCase().contains(query) || - app.packageName.toLowerCase().contains(query), - ) - .toList(); - - final title = widget.mode == AppRoutingMode.exclude - ? 'Exclude apps' - : 'Selected routed apps'; - - return Scaffold( - backgroundColor: AppColors.bgDeep, - appBar: AppBar( - backgroundColor: AppColors.bgSurface, - title: Text(title), - actions: [ - TextButton( - onPressed: _save, - child: const Text('Save'), - ), - ], - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(12), - child: TextField( - controller: _search, - onChanged: (value) => setState(() => _query = value), - decoration: const InputDecoration( - hintText: 'Search apps', - prefixIcon: Icon(Icons.search), - ), - ), - ), - Expanded(child: _buildList(visibleApps)), - ], - ), - ); - } - - Widget _buildList(List visibleApps) { - if (_loading) { - return const Center(child: CircularProgressIndicator()); - } - if (_error != null) { - return Center( - child: Text(_error!, style: const TextStyle(color: AppColors.textMuted)), - ); - } - if (visibleApps.isEmpty) { - return const Center( - child: Text('No apps found.', style: TextStyle(color: AppColors.textMuted)), - ); - } - - return ListView.builder( - itemCount: visibleApps.length, - itemBuilder: (context, index) { - final app = visibleApps[index]; - final selected = _selected.contains(app.packageName); - - return CheckboxListTile( - value: selected, - onChanged: (value) { - setState(() { - if (value == true) { - _selected.add(app.packageName); - } else { - _selected.remove(app.packageName); - } - }); - }, - secondary: app.iconPng == null - ? const CircleAvatar(child: Icon(Icons.android, size: 20)) - : ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.memory( - app.iconPng!, - width: 40, - height: 40, - fit: BoxFit.cover, - gaplessPlayback: true, - ), - ), - title: Text(app.label), - subtitle: Text( - app.packageName, - style: const TextStyle(color: AppColors.textDim, fontSize: 11), - ), - ); - }, - ); - } -} diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart index 5a1041e..de472fb 100644 --- a/lib/screens/settings/in_settings/connection_mode.dart +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/local_socks_tester.dart'; +import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; class ConnectionModeTile extends StatefulWidget { @@ -17,6 +18,7 @@ class ConnectionModeTile extends StatefulWidget { class _ConnectionModeTileState extends State { ConnectionMode _mode = ConnectionSettings.mode; bool _testingLocalSocks = false; + bool _reconnecting = false; LocalSocksTestResult? _lastSocksTest; @override @@ -59,14 +61,47 @@ 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(); + final timer = context.read(); + + setState(() => _reconnecting = true); + try { + await timer.disconnect(); + if (!mounted) return; + if (await vpn.connect()) { + if (!mounted) return; + await timer.start(); + } + } finally { + if (mounted) { + setState(() { + _reconnecting = false; + _lastSocksTest = null; + }); + } + } + } + Future _testLocalSocks() async { if (_testingLocalSocks) return; final vpn = context.read(); if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) { + final orphaned = + vpn.status == VpnStatus.connected && vpn.isStartupRestoration; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Connect with SOCKS5 first, then run the local test.'), + SnackBar( + content: Text( + orphaned + ? 'Credentials were lost when the app restarted. Reconnect first.' + : 'Connect with SOCKS5 first, then run the local test.', + ), ), ); return; @@ -92,7 +127,7 @@ class _ConnectionModeTileState extends State { case ConnectionMode.tun: return 'TUN: Android VPN routing for normal device traffic.'; case ConnectionMode.proxy: - return 'SOCKS5: authenticated per-session local gateway with transparent Android routing.'; + return 'SOCKS5: local authenticated proxy — point apps at 127.0.0.1: with the credentials shown below.'; } } @@ -129,12 +164,93 @@ class _ConnectionModeTileState extends State { ), ), if (_mode == ConnectionMode.proxy) ...[ - const Padding( - padding: EdgeInsets.fromLTRB(20, 0, 20, 8), - child: Text( - 'Secure Local SOCKS5 uses a private port and new credentials for every VPN session.', - style: TextStyle(color: AppColors.textMuted, fontSize: 12), - ), + Consumer( + 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.isStartupRestoration; + if (orphaned) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'The proxy is still running, but its credentials were ' + 'lost when the app restarted. Reconnect to get a new ' + 'endpoint.', + style: TextStyle( + color: AppColors.textMuted, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _reconnecting ? null : _reconnect, + icon: _reconnecting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.refresh), + label: Text( + _reconnecting ? 'Reconnecting…' : 'Reconnect', + ), + ), + ], + ), + ); + } + return const Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Text( + 'Connect to start a local SOCKS5 proxy. Credentials change every session.', + style: TextStyle(color: AppColors.textMuted, fontSize: 12), + ), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Proxy: 127.0.0.1:${session.port}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + Text( + 'Username: ${session.username}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + Text( + 'Password: ${session.password}', + style: const TextStyle( + color: AppColors.textMuted, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ], + ), + ); + }, ), 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 index c09e3bc..a4c137d 100644 --- a/lib/screens/settings/in_settings/resilience.dart +++ b/lib/screens/settings/in_settings/resilience.dart @@ -51,42 +51,34 @@ class _ResilienceModeTileState extends State { Widget build(BuildContext context) { final extreme = _mode == ResilienceMode.extreme; - return Consumer( - builder: (context, vpn, _) { - final connected = vpn.status == VpnStatus.connected || - vpn.status == VpnStatus.connecting; - final active = connected ? ' Active: ${vpn.activeTransportProfile}.' : ''; - - 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.$active' - : 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.$active', - 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'), - ), - ], + 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 06d98d4..822b945 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -1,34 +1,14 @@ import 'package:flutter/material.dart'; import 'package:revoltvpn/logic/app_colors.dart'; -import 'package:revoltvpn/logic/connection_settings.dart'; -import 'package:revoltvpn/screens/settings/in_settings/app_routing.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 StatefulWidget { +class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); - @override - State createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { - ConnectionMode _mode = ConnectionSettings.mode; - - @override - void initState() { - super.initState(); - _loadMode(); - } - - Future _loadMode() async { - await ConnectionSettings.initialize(); - if (mounted) setState(() => _mode = ConnectionSettings.mode); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -50,15 +30,12 @@ class _SettingsScreenState extends State { ), body: ListView( padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - ConnectionModeTile( - onChanged: (mode) => setState(() => _mode = mode), - ), - const ResilienceModeTile(), - AppRoutingTile(connectionMode: _mode), - const HapticFeedbackTile(), - const RainToggleTile(), - const LightningToggleTile(), + children: const [ + ConnectionModeTile(), + ResilienceModeTile(), + HapticFeedbackTile(), + RainToggleTile(), + LightningToggleTile(), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 3d27d57..1369a33 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,14 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" - dbus: - dependency: transitive - description: - name: dbus - sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 - url: "https://pub.dev" - source: hosted - version: "0.7.12" fake_async: dependency: transitive description: @@ -126,30 +118,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - flutter_local_notifications: - dependency: "direct main" - description: - name: flutter_local_notifications - sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" - url: "https://pub.dev" - source: hosted - version: "17.2.4" - flutter_local_notifications_linux: - dependency: transitive - description: - name: flutter_local_notifications_linux - sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af - url: "https://pub.dev" - source: hosted - version: "4.0.1" - flutter_local_notifications_platform_interface: - dependency: transitive - description: - name: flutter_local_notifications_platform_interface - sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" - url: "https://pub.dev" - source: hosted - version: "7.2.0" flutter_secure_storage: dependency: "direct main" description: @@ -464,14 +432,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" platform: dependency: transitive description: @@ -621,14 +581,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" - timezone: - dependency: transitive - description: - name: timezone - sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" - url: "https://pub.dev" - source: hosted - version: "0.9.4" typed_data: dependency: transitive description: @@ -781,14 +733,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5bc8de6..d33dac1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,6 @@ dependencies: url_launcher: ^6.3.2 flutter_secure_storage: ^10.3.1 uuid: ^4.5.3 - flutter_local_notifications: ^17.0.0 path_provider: ^2.1.5 package_info_plus: ^6.0.0