refactor(app): debloat 3.3.1 and fix startup restoration

Maintainer changes on top of PR #2 (Naveax). Reduces the release to two
deliberate connection modes and resolves the issues found in review:

- Lock-screen privacy: move VISIBILITY_PRIVATE and a generic publicVersion
  into MainActivity.updateVpnNotification. The PR's fix lived in an
  unreferenced class and never reached the live notification.
- Haptics: restore the default to off, and reduce HapticSettings to a
  single success() on connect/disconnect via the native channel.
- SOCKS5: make it a real proxyOnly local-proxy mode rather than a TUN
  connection under a different label. TUN stays the default.
- Health probe: restore the idle-only guard so /health is no longer polled
  every 30s while connected.
- Recovery: add a _connectionEpoch counter so connect() aborts an
  in-flight Extreme recovery.
- Startup restoration: rebuild the recovery snapshot from the still-active
  session after an app-process restart (TUN), and surface an honest
  orphaned state with a Reconnect action in proxy mode.

Drops per-app routing entirely. It only ever functioned in TUN --
addDisallowedApplication lives inside the VPN-interface setup that
proxyOnly skips -- and its allowlist half had no Dart caller.

Also removes flutter_local_notifications, the global tap-haptic surface,
and unused runtime telemetry.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
esefxdzandClaude Opus 5 committed 2026-08-31 23:31:51 +03:00
1 parent a71161e7e5
commit 6fcde9dd7b
18 files changed
+271 -1027

No files matched your search

+1 -3
View File
@@ -18,9 +18,6 @@ android/admob.properties
lib/logic/app_config.dart lib/logic/app_config.dart
# ── File types the shipped app does not contain ──────────────────────────── # ── 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 *.py
*.pyc *.pyc
*.json *.json
@@ -53,6 +50,7 @@ scraped/
backup/ backup/
backups/ backups/
lib/logic/backup/ lib/logic/backup/
server_files/
# ── Flutter / Dart build output ──────────────────────────────────────────── # ── Flutter / Dart build output ────────────────────────────────────────────
.dart_tool/ .dart_tool/
-5
View File
@@ -18,11 +18,6 @@
<action android:name="android.intent.action.VIEW"/> <action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/> <data android:scheme="https"/>
</intent> </intent>
<!-- App routing only enumerates launcher apps; QUERY_ALL_PACKAGES is not required. -->
<intent>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent>
</queries> </queries>
<application <application
@@ -7,9 +7,6 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
@@ -26,14 +23,11 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val installerChannel = "com.revoltvpn.app/installer" private val installerChannel = "com.revoltvpn.app/installer"
private val notificationChannel = "com.revoltvpn.app/notification" private val notificationChannel = "com.revoltvpn.app/notification"
private val hapticsChannel = "com.revoltvpn.app/haptics" private val hapticsChannel = "com.revoltvpn.app/haptics"
private val appsChannel = "com.revoltvpn.app/apps"
private val networkChannel = "com.revoltvpn.app/network" private val networkChannel = "com.revoltvpn.app/network"
private var notificationChannelReady = false private var notificationChannelReady = false
@@ -75,15 +69,6 @@ class MainActivity : FlutterActivity() {
} }
} }
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, appsChannel)
.setMethodCallHandler { call, result ->
if (call.method == "getLaunchableApps") {
result.success(launchableApps())
} else {
result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, networkChannel) EventChannel(flutterEngine.dartExecutor.binaryMessenger, networkChannel)
.setStreamHandler(object : EventChannel.StreamHandler { .setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
@@ -129,6 +114,14 @@ class MainActivity : FlutterActivity() {
stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE)
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) 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) val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notification_status_icon) .setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle(title) .setContentTitle(title)
@@ -142,6 +135,8 @@ class MainActivity : FlutterActivity() {
.setSilent(true) .setSilent(true)
.setShowWhen(false) .setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_LOW)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion)
if (launchIntent != null) { if (launchIntent != null) {
builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags)) builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags))
@@ -311,54 +306,6 @@ class MainActivity : FlutterActivity() {
null null
} }
private fun launchableApps(): List<Map<String, Any>> {
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<String, Any>(
"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 { private fun performHaptic(kind: String): Boolean {
return try { return try {
val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-65
View File
@@ -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<AppHapticSurface> createState() => _AppHapticSurfaceState();
}
class _PointerStart {
final Offset position;
final DateTime time;
const _PointerStart(this.position, this.time);
}
class _AppHapticSurfaceState extends State<AppHapticSurface> {
static const double _maxTapTravel = 18;
static const Duration _maxTapDuration = Duration(milliseconds: 700);
final Map<int, _PointerStart> _starts = <int, _PointerStart>{};
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,
);
}
}
+6 -1
View File
@@ -4,6 +4,7 @@ import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/session_timer.dart';
import 'package:revoltvpn/logic/ad_manager.dart'; import 'package:revoltvpn/logic/ad_manager.dart';
import 'package:revoltvpn/logic/haptic_settings.dart';
class ConnectButton extends StatefulWidget { class ConnectButton extends StatefulWidget {
const ConnectButton({super.key}); const ConnectButton({super.key});
@@ -82,6 +83,7 @@ class _ConnectButtonState extends State<ConnectButton>
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) { if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
_busy = false; _busy = false;
await timer.disconnect(); await timer.disconnect();
HapticSettings.success();
return; return;
} }
@@ -93,7 +95,10 @@ class _ConnectButtonState extends State<ConnectButton>
if (!adWatched && AdManager.adsEnabled) return; if (!adWatched && AdManager.adsEnabled) return;
final ok = await vpn.connect(); final ok = await vpn.connect();
if (ok) await timer.start(); if (ok) {
await timer.start();
HapticSettings.success();
}
} finally { } finally {
if (mounted) setState(() => _busy = false); if (mounted) setState(() => _busy = false);
} }
-95
View File
@@ -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<void> 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<void> 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<void> cancel() async {
if (!_initialized) return;
await _plugin.cancel(_notifId);
}
}
+12
View File
@@ -104,6 +104,18 @@ class StatusBar extends StatelessWidget {
label: statusLabel, label: statusLabel,
), ),
const Spacer(), 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) if (isConnected)
const Icon( const Icon(
Icons.lock, Icons.lock,
+7 -66
View File
@@ -1,73 +1,40 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
enum ConnectionMode { tun, proxy } enum ConnectionMode { tun, proxy }
enum AppRoutingMode { all, exclude, selected }
enum ResilienceMode { standard, extreme } enum ResilienceMode { standard, extreme }
abstract final class ConnectionSettings { abstract final class ConnectionSettings {
ConnectionSettings._(); ConnectionSettings._();
static const String _modeKey = 'connection_mode'; 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 const String _resilienceModeKey = 'resilience_mode';
static ConnectionMode _mode = ConnectionMode.tun; static ConnectionMode _mode = ConnectionMode.tun;
static AppRoutingMode _routingMode = AppRoutingMode.all;
static ResilienceMode _resilienceMode = ResilienceMode.standard; static ResilienceMode _resilienceMode = ResilienceMode.standard;
static List<String> _appPackages = const <String>[];
static bool _initialized = false; static bool _initialized = false;
static ConnectionMode get mode => _mode; static ConnectionMode get mode => _mode;
static AppRoutingMode get routingMode => _routingMode;
static ResilienceMode get resilienceMode => _resilienceMode; static ResilienceMode get resilienceMode => _resilienceMode;
static List<String> get appPackages => List.unmodifiable(_appPackages);
static Future<void> initialize() async { static Future<void> initialize() async {
if (_initialized) return; if (_initialized) return;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final storedMode = prefs.getString(_modeKey); final storedMode = prefs.getString(_modeKey);
final migrateLegacyAss = storedMode == 'ass';
final migrateLegacyAuto = storedMode == 'auto' || storedMode == null;
if (storedMode == ConnectionMode.proxy.name || migrateLegacyAss) { // Legacy 'ass' was the pre-V3 SOCKS5 label; map it to proxy. Everything
_mode = ConnectionMode.proxy; // else (including the old 'auto') is TUN — there is no runtime auto mode.
} else { _mode = storedMode == ConnectionMode.proxy.name || storedMode == 'ass'
_mode = ConnectionMode.tun; ? ConnectionMode.proxy
} : ConnectionMode.tun;
final legacyBlocked =
prefs.getStringList(_legacyBlockedAppsKey) ?? const <String>[];
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;
}
_resilienceMode = _resilienceMode =
prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name prefs.getString(_resilienceModeKey) == ResilienceMode.extreme.name
? ResilienceMode.extreme ? ResilienceMode.extreme
: ResilienceMode.standard; : ResilienceMode.standard;
if (migrateLegacyAss) { if (storedMode != _mode.name) {
await prefs.setString(_modeKey, ConnectionMode.proxy.name); await prefs.setString(_modeKey, _mode.name);
await prefs.setString(_routingModeKey, _routingMode.name);
} else if (migrateLegacyAuto) {
await prefs.setString(_modeKey, ConnectionMode.tun.name);
} }
_initialized = true; _initialized = true;
@@ -80,36 +47,10 @@ abstract final class ConnectionSettings {
await prefs.setString(_modeKey, mode.name); await prefs.setString(_modeKey, mode.name);
} }
static Future<void> setRoutingMode(AppRoutingMode mode) async {
_routingMode = mode;
_initialized = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_routingModeKey, mode.name);
}
static Future<void> setAppPackages(Iterable<String> packages) async {
_appPackages = _cleanPackages(packages);
_initialized = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_appPackagesKey, _appPackages);
}
static Future<void> setResilienceMode(ResilienceMode mode) async { static Future<void> setResilienceMode(ResilienceMode mode) async {
_resilienceMode = mode; _resilienceMode = mode;
_initialized = true; _initialized = true;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_resilienceModeKey, mode.name); await prefs.setString(_resilienceModeKey, mode.name);
} }
static List<String> _cleanPackages(Iterable<String> packages) {
final seen = <String>{};
final result = <String>[];
for (final raw in packages) {
final value = raw.trim();
if (value.isEmpty || !seen.add(value)) continue;
result.add(value);
}
result.sort();
return result;
}
} }
+9 -21
View File
@@ -4,8 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
enum HapticKind { tap, selection, success }
abstract final class HapticSettings { abstract final class HapticSettings {
HapticSettings._(); HapticSettings._();
@@ -13,7 +11,7 @@ abstract final class HapticSettings {
static const MethodChannel _channel = static const MethodChannel _channel =
MethodChannel('com.revoltvpn.app/haptics'); MethodChannel('com.revoltvpn.app/haptics');
static bool _enabled = true; static bool _enabled = false;
static bool _initialized = false; static bool _initialized = false;
static bool get enabled => _enabled; static bool get enabled => _enabled;
@@ -23,10 +21,10 @@ abstract final class HapticSettings {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_enabled = prefs.getBool(_prefKey) ?? true; _enabled = prefs.getBool(_prefKey) ?? false;
} catch (e) { } catch (e) {
debugPrint('[Haptics] Failed to load preference: $e'); debugPrint('[Haptics] Failed to load preference: $e');
_enabled = true; _enabled = false;
} finally { } finally {
_initialized = true; _initialized = true;
} }
@@ -48,11 +46,11 @@ abstract final class HapticSettings {
} }
} }
static void tap() => unawaited(_impact(HapticKind.tap)); /// Confirmation haptic for a successful connect/disconnect. Best-effort: a
static void selection() => unawaited(_impact(HapticKind.selection)); /// vibration failure must never block or fail the VPN action.
static void success() => unawaited(_impact(HapticKind.success)); static void success() => unawaited(_impact());
static Future<void> _impact(HapticKind kind) async { static Future<void> _impact() async {
if (!_initialized) await initialize(); if (!_initialized) await initialize();
if (!_enabled || kIsWeb) return; if (!_enabled || kIsWeb) return;
@@ -60,7 +58,7 @@ abstract final class HapticSettings {
try { try {
final handled = await _channel.invokeMethod<bool>( final handled = await _channel.invokeMethod<bool>(
'impact', 'impact',
<String, Object>{'kind': kind.name}, <String, Object>{'kind': 'success'},
) ?? ) ??
false; false;
if (handled) return; if (handled) return;
@@ -69,16 +67,6 @@ abstract final class HapticSettings {
} }
} }
switch (kind) { await HapticFeedback.mediumImpact();
case HapticKind.tap:
await HapticFeedback.lightImpact();
break;
case HapticKind.selection:
await HapticFeedback.selectionClick();
break;
case HapticKind.success:
await HapticFeedback.mediumImpact();
break;
}
} }
} }
-48
View File
@@ -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<List<InstalledApp>> loadLaunchableApps() async {
final raw = await _channel.invokeListMethod<dynamic>('getLaunchableApps') ??
const <dynamic>[];
final apps = <InstalledApp>[];
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;
}
}
+67 -197
View File
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_vless/flutter_vless.dart'; import 'package:flutter_vless/flutter_vless.dart';
import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/hivemind_service.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/local_socks_tester.dart';
import 'package:revoltvpn/logic/network_monitor.dart'; import 'package:revoltvpn/logic/network_monitor.dart';
import 'package:revoltvpn/logic/secure_socks_session.dart'; import 'package:revoltvpn/logic/secure_socks_session.dart';
@@ -17,18 +16,6 @@ enum VpnStatus {
error, error,
} }
class _RoutingPlan {
final List<String>? blockedApps;
final bool selectedOnly;
final bool verifyLocalSocks;
const _RoutingPlan({
required this.blockedApps,
this.selectedOnly = false,
this.verifyLocalSocks = false,
});
}
class VpnConnection extends ChangeNotifier { class VpnConnection extends ChangeNotifier {
static const int _maxExtremeRecoveryAttempts = 2; static const int _maxExtremeRecoveryAttempts = 2;
@@ -54,41 +41,10 @@ class VpnConnection extends ChangeNotifier {
ResilienceMode _activeResilienceMode = ResilienceMode.standard; ResilienceMode _activeResilienceMode = ResilienceMode.standard;
ResilienceMode get activeResilienceMode => _activeResilienceMode; ResilienceMode get activeResilienceMode => _activeResilienceMode;
bool _selectedRoutingActive = false;
bool get selectedRoutingActive => _selectedRoutingActive;
String _networkTransport = 'unknown'; String _networkTransport = 'unknown';
String get networkTransport => _networkTransport; String get networkTransport => _networkTransport;
bool _networkValidated = false; SecureSocksSession? get activeSocksSession => _lastSecureSocks;
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';
bool get canTestActiveLocalSocks => bool get canTestActiveLocalSocks =>
_status == VpnStatus.connected && _status == VpnStatus.connected &&
@@ -119,12 +75,11 @@ class VpnConnection extends ChangeNotifier {
String? _lastBaseConfig; String? _lastBaseConfig;
String _lastRemark = 'Revolt VPN'; String _lastRemark = 'Revolt VPN';
List<String>? _lastBlockedApps;
bool _lastSelectedOnly = false;
bool _lastVerifyLocalSocks = false; bool _lastVerifyLocalSocks = false;
SecureSocksSession? _lastSecureSocks; SecureSocksSession? _lastSecureSocks;
bool _restartInProgress = false; bool _restartInProgress = false;
bool _userDisconnecting = false; bool _userDisconnecting = false;
int _connectionEpoch = 0;
late final FlutterVless _vless; late final FlutterVless _vless;
bool _initialized = false; bool _initialized = false;
@@ -180,7 +135,6 @@ class VpnConnection extends ChangeNotifier {
// ConnectivityManager is informational only. Restarting from every // ConnectivityManager is informational only. Restarting from every
// callback caused the old VPN-created-network reconnect loop. // callback caused the old VPN-created-network reconnect loop.
_networkTransport = snapshot.transport; _networkTransport = snapshot.transport;
_networkValidated = snapshot.validated;
notifyListeners(); notifyListeners();
}, },
onError: (Object error) { onError: (Object error) {
@@ -199,42 +153,65 @@ class VpnConnection extends ChangeNotifier {
final delay = await _vless.getConnectedServerDelay(); final delay = await _vless.getConnectedServerDelay();
if (delay > 0) { if (delay > 0) {
_lastHealthLatencyMs = delay;
_isStartupRestoration = true; _isStartupRestoration = true;
_restoreRoutingPresentationFromSettings(); _lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy;
_setStatus(VpnStatus.connected, _connectedLabel); _setStatus(VpnStatus.connected, _connectedLabel);
unawaited(_restoreRecoverySnapshot());
} }
} catch (_) {} } catch (_) {}
} }
void _restoreRoutingPresentationFromSettings() { /// Android can kill the Dart process while the VPN foreground service keeps
final routingMode = ConnectionSettings.routingMode; /// running, which leaves a fresh [VpnConnection] attached to a live tunnel it
final packages = ConnectionSettings.appPackages; /// 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<void> _restoreRecoverySnapshot() async {
if (kIsWeb || _activeMode != ConnectionMode.tun) return;
_lastBlockedApps = null; final epoch = _connectionEpoch;
_lastSelectedOnly = false; bool stale() =>
_lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy; _connectionEpoch != epoch ||
_selectedRoutingActive = false; _userDisconnecting ||
_status != VpnStatus.connected;
if (routingMode == AppRoutingMode.selected) { try {
_lastSelectedOnly = packages.isNotEmpty; final realUrl = await HivemindService.fetchConfigDirectly(
_selectedRoutingActive = packages.isNotEmpty; skipAdBypass: true,
return; );
} // 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) { final parsed = FlutterVless.parse(realUrl);
_lastBlockedApps = List.of(packages); 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 { String get _connectedLabel {
switch (_activeMode) { switch (_activeMode) {
case ConnectionMode.proxy: case ConnectionMode.proxy:
return _selectedRoutingActive return 'SOCKS5 gateway active';
? 'SOCKS5 · selected apps secured'
: 'SOCKS5 gateway active';
case ConnectionMode.tun: case ConnectionMode.tun:
return _selectedRoutingActive ? 'Selected apps secured' : 'Secured'; return 'Secured';
} }
} }
@@ -293,33 +270,21 @@ class VpnConnection extends ChangeNotifier {
_cancelled = false; _cancelled = false;
_userDisconnecting = false; _userDisconnecting = false;
_restartTimer?.cancel(); _restartTimer?.cancel();
_reconnectCount = 0; _connectionEpoch++;
_lastRecoveryReason = null;
_lastHealthLatencyMs = null;
_selectedRoutingActive = false;
await ConnectionSettings.initialize(); await ConnectionSettings.initialize();
_activeMode = ConnectionSettings.mode; _activeMode = ConnectionSettings.mode;
_activeResilienceMode = ConnectionSettings.resilienceMode; _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) { if (!kIsWeb && !_initialized) {
_errorMessage = 'VPN service unavailable.'; _errorMessage = 'VPN service unavailable.';
_setStatus(VpnStatus.error, 'Service unavailable'); _setStatus(VpnStatus.error, 'Service unavailable');
return false; return false;
} }
// TUN and SOCKS5 share the stable Android VpnService. SOCKS5 uses // TUN needs the Android VPN permission; SOCKS5 proxy mode runs a local
// an authenticated per-session local ingress instead of a fixed port. // authenticated proxy without a VpnService, so no VPN permission is needed.
if (!kIsWeb) { if (!kIsWeb && _activeMode == ConnectionMode.tun) {
final ok = await _vless.requestPermission(); final ok = await _vless.requestPermission();
if (!ok) { if (!ok) {
_errorMessage = 'VPN permission denied.'; _errorMessage = 'VPN permission denied.';
@@ -329,12 +294,8 @@ class VpnConnection extends ChangeNotifier {
} }
final startingMessage = switch (_activeMode) { final startingMessage = switch (_activeMode) {
ConnectionMode.tun => routingPlan.selectedOnly ConnectionMode.tun => 'Establishing secure channel…',
? 'Starting selected-app tunnel…' ConnectionMode.proxy => 'Starting local SOCKS5 proxy…',
: 'Establishing secure channel…',
ConnectionMode.proxy => routingPlan.selectedOnly
? 'SOCKS5 · starting selected-app gateway…'
: 'Starting transparent SOCKS5 gateway…',
}; };
_setStatus(VpnStatus.connecting, startingMessage); _setStatus(VpnStatus.connecting, startingMessage);
_errorMessage = null; _errorMessage = null;
@@ -385,23 +346,23 @@ class VpnConnection extends ChangeNotifier {
final baseConfig = parsed.getFullConfiguration(); final baseConfig = parsed.getFullConfiguration();
final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN'; final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
final secureSocks = await SecureSocksSession.create(baseConfig); final secureSocks = await SecureSocksSession.create(baseConfig);
final verifyLocalSocks = _activeMode == ConnectionMode.proxy;
// Set this before the native callback can report CONNECTED so the first
// visible label already reflects Selected only accurately.
_selectedRoutingActive = routingPlan.selectedOnly;
await _startRuntime( await _startRuntime(
config: secureSocks.configJson, config: secureSocks.configJson,
remark: remark, 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()) { if (!await _waitForNativeConnected()) {
throw StateError('VPN runtime did not report CONNECTED'); throw StateError('VPN runtime did not report CONNECTED');
} }
if (routingPlan.verifyLocalSocks) { if (verifyLocalSocks) {
_setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…'); _setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…');
if (!await _waitForLocalSocksListener(secureSocks)) { if (!await _waitForLocalSocksListener(secureSocks)) {
throw StateError('Local SOCKS5 listener did not become ready'); throw StateError('Local SOCKS5 listener did not become ready');
@@ -411,12 +372,7 @@ class VpnConnection extends ChangeNotifier {
_lastBaseConfig = secureSocks.configJson; _lastBaseConfig = secureSocks.configJson;
_lastSecureSocks = secureSocks; _lastSecureSocks = secureSocks;
_lastRemark = remark; _lastRemark = remark;
_lastBlockedApps = routingPlan.blockedApps == null _lastVerifyLocalSocks = verifyLocalSocks;
? null
: List.of(routingPlan.blockedApps!);
_lastSelectedOnly = routingPlan.selectedOnly;
_lastVerifyLocalSocks = routingPlan.verifyLocalSocks;
_selectedRoutingActive = routingPlan.selectedOnly;
} catch (e) { } catch (e) {
debugPrint('[VPN] Tunnel start error: $e'); debugPrint('[VPN] Tunnel start error: $e');
await _stopRuntime(); await _stopRuntime();
@@ -432,95 +388,15 @@ class VpnConnection extends ChangeNotifier {
return true; 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<String> 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<void> _startRuntime({ Future<void> _startRuntime({
required String config, required String config,
required String remark, required String remark,
required List<String>? blockedApps, required bool proxyOnly,
}) async { }) async {
await _vless.startVless( await _vless.startVless(
remark: remark, remark: remark,
config: config, config: config,
blockedApps: blockedApps, proxyOnly: proxyOnly,
proxyOnly: false,
); );
} }
@@ -538,8 +414,6 @@ class VpnConnection extends ChangeNotifier {
password: active.password, password: active.password,
); );
if (result.ok) { if (result.ok) {
_lastHealthLatencyMs = result.latencyMs;
notifyListeners();
return true; return true;
} }
if (attempt < 3) { if (attempt < 3) {
@@ -571,7 +445,6 @@ class VpnConnection extends ChangeNotifier {
return; return;
} }
_lastRecoveryReason = reason;
_setStatus(VpnStatus.connecting, 'Reconnecting…'); _setStatus(VpnStatus.connecting, 'Reconnecting…');
_restartTimer = Timer(const Duration(milliseconds: 1200), () { _restartTimer = Timer(const Duration(milliseconds: 1200), () {
unawaited(_restartActiveRuntime(reason)); unawaited(_restartActiveRuntime(reason));
@@ -584,11 +457,12 @@ class VpnConnection extends ChangeNotifier {
} }
_restartInProgress = true; _restartInProgress = true;
final epoch = _connectionEpoch;
Object? lastError; Object? lastError;
try { try {
for (var attempt = 1; attempt <= _maxExtremeRecoveryAttempts; attempt++) { for (var attempt = 1; attempt <= _maxExtremeRecoveryAttempts; attempt++) {
if (_userDisconnecting || _lastBaseConfig == null) return; if (_connectionEpoch != epoch || _userDisconnecting || _lastBaseConfig == null) return;
try { try {
debugPrint( debugPrint(
@@ -607,7 +481,7 @@ class VpnConnection extends ChangeNotifier {
await _startRuntime( await _startRuntime(
config: _lastBaseConfig!, config: _lastBaseConfig!,
remark: _lastRemark, remark: _lastRemark,
blockedApps: _lastBlockedApps, proxyOnly: _activeMode == ConnectionMode.proxy,
); );
if (!await _waitForNativeConnected()) { if (!await _waitForNativeConnected()) {
@@ -619,9 +493,6 @@ class VpnConnection extends ChangeNotifier {
if (_userDisconnecting) return; if (_userDisconnecting) return;
_reconnectCount++;
_lastRecoveryReason = reason;
_selectedRoutingActive = _lastSelectedOnly;
_errorMessage = null; _errorMessage = null;
_setStatus(VpnStatus.connected, _connectedLabel); _setStatus(VpnStatus.connected, _connectedLabel);
return; return;
@@ -716,12 +587,8 @@ class VpnConnection extends ChangeNotifier {
void _clearRuntimeSnapshot() { void _clearRuntimeSnapshot() {
_lastBaseConfig = null; _lastBaseConfig = null;
_lastBlockedApps = null;
_lastSelectedOnly = false;
_lastVerifyLocalSocks = false; _lastVerifyLocalSocks = false;
_lastSecureSocks = null; _lastSecureSocks = null;
_selectedRoutingActive = false;
_lastHealthLatencyMs = null;
_isStartupRestoration = false; _isStartupRestoration = false;
} }
@@ -732,6 +599,9 @@ class VpnConnection extends ChangeNotifier {
} }
Future<void> _checkHealth() async { Future<void> _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(); _serverReachable = await HivemindService.checkHealth();
notifyListeners(); notifyListeners();
} }
-3
View File
@@ -3,7 +3,6 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.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/ad_manager.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/connection_settings.dart';
@@ -63,8 +62,6 @@ class ReVoltApp extends StatelessWidget {
title: 'ReVolt VPN', title: 'ReVolt VPN',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: _buildDarkTheme(), theme: _buildDarkTheme(),
builder: (context, child) =>
AppHapticSurface(child: child ?? const SizedBox.shrink()),
home: const IntroScreen(), home: const IntroScreen(),
), ),
); );
@@ -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<AppRoutingTile> createState() => _AppRoutingTileState();
}
class _AppRoutingTileState extends State<AppRoutingTile> {
AppRoutingMode _mode = ConnectionSettings.routingMode;
int _selectedCount = ConnectionSettings.appPackages.length;
bool get _proxyMode => widget.connectionMode == ConnectionMode.proxy;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await ConnectionSettings.initialize();
if (!mounted) return;
setState(() {
_mode = ConnectionSettings.routingMode;
_selectedCount = ConnectionSettings.appPackages.length;
});
}
bool _vpnBusy() {
final status = context.read<VpnConnection>().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<void> _changeMode(AppRoutingMode? next) async {
if (next == null || next == _mode) return;
if (_vpnBusy()) {
_showDisconnectFirst();
return;
}
await ConnectionSettings.setRoutingMode(next);
if (mounted) setState(() => _mode = next);
}
Future<void> _openPicker() async {
if (_mode == AppRoutingMode.all) return;
if (_vpnBusy()) {
_showDisconnectFirst();
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
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<AppRoutingMode>(
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<String> _selected = <String>{...ConnectionSettings.appPackages};
final TextEditingController _search = TextEditingController();
List<InstalledApp> _apps = const <InstalledApp>[];
bool _loading = true;
String? _error;
String _query = '';
@override
void initState() {
super.initState();
_loadApps();
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
Future<void> _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<void> _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<InstalledApp> 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),
),
);
},
);
}
}
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart'; import 'package:revoltvpn/logic/local_socks_tester.dart';
import 'package:revoltvpn/logic/session_timer.dart';
import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/vpn_connection.dart';
class ConnectionModeTile extends StatefulWidget { class ConnectionModeTile extends StatefulWidget {
@@ -17,6 +18,7 @@ class ConnectionModeTile extends StatefulWidget {
class _ConnectionModeTileState extends State<ConnectionModeTile> { class _ConnectionModeTileState extends State<ConnectionModeTile> {
ConnectionMode _mode = ConnectionSettings.mode; ConnectionMode _mode = ConnectionSettings.mode;
bool _testingLocalSocks = false; bool _testingLocalSocks = false;
bool _reconnecting = false;
LocalSocksTestResult? _lastSocksTest; LocalSocksTestResult? _lastSocksTest;
@override @override
@@ -59,14 +61,47 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
widget.onChanged?.call(next); 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<void> _reconnect() async {
if (_reconnecting) return;
final vpn = context.read<VpnConnection>();
final timer = context.read<SessionTimer>();
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<void> _testLocalSocks() async { Future<void> _testLocalSocks() async {
if (_testingLocalSocks) return; if (_testingLocalSocks) return;
final vpn = context.read<VpnConnection>(); final vpn = context.read<VpnConnection>();
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) { if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
final orphaned =
vpn.status == VpnStatus.connected && vpn.isStartupRestoration;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Connect with SOCKS5 first, then run the local test.'), content: Text(
orphaned
? 'Credentials were lost when the app restarted. Reconnect first.'
: 'Connect with SOCKS5 first, then run the local test.',
),
), ),
); );
return; return;
@@ -92,7 +127,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
case ConnectionMode.tun: case ConnectionMode.tun:
return 'TUN: Android VPN routing for normal device traffic.'; return 'TUN: Android VPN routing for normal device traffic.';
case ConnectionMode.proxy: 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:<port> with the credentials shown below.';
} }
} }
@@ -129,12 +164,93 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
), ),
), ),
if (_mode == ConnectionMode.proxy) ...[ if (_mode == ConnectionMode.proxy) ...[
const Padding( Consumer<VpnConnection>(
padding: EdgeInsets.fromLTRB(20, 0, 20, 8), builder: (context, vpn, _) {
child: Text( final session = vpn.activeSocksSession;
'Secure Local SOCKS5 uses a private port and new credentials for every VPN session.', if (session == null) {
style: TextStyle(color: AppColors.textMuted, fontSize: 12), // 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(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
@@ -51,42 +51,34 @@ class _ResilienceModeTileState extends State<ResilienceModeTile> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final extreme = _mode == ResilienceMode.extreme; final extreme = _mode == ResilienceMode.extreme;
return Consumer<VpnConnection>( return ListTile(
builder: (context, vpn, _) { title: const Text(
final connected = vpn.status == VpnStatus.connected || 'Network resilience',
vpn.status == VpnStatus.connecting; style: TextStyle(color: AppColors.textWhite, fontSize: 15),
final active = connected ? ' Active: ${vpn.activeTransportProfile}.' : ''; ),
subtitle: Text(
return ListTile( extreme
title: const Text( ? 'Extreme: keeps the exact server transport unchanged and makes up to two controlled recovery attempts after each real Xray disconnect.'
'Network resilience', : 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.',
style: TextStyle(color: AppColors.textWhite, fontSize: 15), style: const TextStyle(color: AppColors.textDim, fontSize: 12),
), ),
subtitle: Text( trailing: DropdownButtonHideUnderline(
extreme child: DropdownButton<ResilienceMode>(
? 'Extreme: keeps the exact server transport unchanged and makes up to two controlled recovery attempts after each real Xray disconnect.$active' value: _mode,
: 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.$active', dropdownColor: AppColors.bgCard,
style: const TextStyle(color: AppColors.textDim, fontSize: 12), onChanged: _change,
), items: const [
trailing: DropdownButtonHideUnderline( DropdownMenuItem(
child: DropdownButton<ResilienceMode>( value: ResilienceMode.standard,
value: _mode, child: Text('Standard'),
dropdownColor: AppColors.bgCard,
onChanged: _change,
items: const [
DropdownMenuItem(
value: ResilienceMode.standard,
child: Text('Standard'),
),
DropdownMenuItem(
value: ResilienceMode.extreme,
child: Text('Extreme'),
),
],
), ),
), DropdownMenuItem(
); value: ResilienceMode.extreme,
}, child: Text('Extreme'),
),
],
),
),
); );
} }
} }
+7 -30
View File
@@ -1,34 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/app_colors.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/connection_mode.dart';
import 'package:revoltvpn/screens/settings/in_settings/haptic.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/lightning.dart';
import 'package:revoltvpn/screens/settings/in_settings/rain.dart'; import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
import 'package:revoltvpn/screens/settings/in_settings/resilience.dart'; import 'package:revoltvpn/screens/settings/in_settings/resilience.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
ConnectionMode _mode = ConnectionSettings.mode;
@override
void initState() {
super.initState();
_loadMode();
}
Future<void> _loadMode() async {
await ConnectionSettings.initialize();
if (mounted) setState(() => _mode = ConnectionSettings.mode);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -50,15 +30,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
children: [ children: const [
ConnectionModeTile( ConnectionModeTile(),
onChanged: (mode) => setState(() => _mode = mode), ResilienceModeTile(),
), HapticFeedbackTile(),
const ResilienceModeTile(), RainToggleTile(),
AppRoutingTile(connectionMode: _mode), LightningToggleTile(),
const HapticFeedbackTile(),
const RainToggleTile(),
const LightningToggleTile(),
], ],
), ),
); );
-56
View File
@@ -73,14 +73,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -126,30 +118,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" 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: flutter_secure_storage:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -464,14 +432,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@@ -621,14 +581,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.11"
timezone:
dependency: transitive
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -781,14 +733,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:
-1
View File
@@ -36,7 +36,6 @@ dependencies:
url_launcher: ^6.3.2 url_launcher: ^6.3.2
flutter_secure_storage: ^10.3.1 flutter_secure_storage: ^10.3.1
uuid: ^4.5.3 uuid: ^4.5.3
flutter_local_notifications: ^17.0.0
path_provider: ^2.1.5 path_provider: ^2.1.5
package_info_plus: ^6.0.0 package_info_plus: ^6.0.0