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:
1 parent
a71161e7e5
commit
6fcde9dd7b
18 files changed
+241
-997
No files matched your search
+1
-3
@@ -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/
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</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>
|
||||
|
||||
<application
|
||||
|
||||
@@ -7,9 +7,6 @@ import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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.Network
|
||||
import android.net.NetworkCapabilities
|
||||
@@ -26,14 +23,11 @@ import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val installerChannel = "com.revoltvpn.app/installer"
|
||||
private val notificationChannel = "com.revoltvpn.app/notification"
|
||||
private val hapticsChannel = "com.revoltvpn.app/haptics"
|
||||
private val appsChannel = "com.revoltvpn.app/apps"
|
||||
private val networkChannel = "com.revoltvpn.app/network"
|
||||
|
||||
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)
|
||||
.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<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 {
|
||||
return try {
|
||||
val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ConnectButton>
|
||||
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<ConnectButton>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String> _appPackages = const <String>[];
|
||||
static bool _initialized = false;
|
||||
|
||||
static ConnectionMode get mode => _mode;
|
||||
static AppRoutingMode get routingMode => _routingMode;
|
||||
static ResilienceMode get resilienceMode => _resilienceMode;
|
||||
static List<String> get appPackages => List.unmodifiable(_appPackages);
|
||||
|
||||
static Future<void> 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 <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;
|
||||
}
|
||||
// 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<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 {
|
||||
_resilienceMode = mode;
|
||||
_initialized = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> _impact(HapticKind kind) async {
|
||||
static Future<void> _impact() async {
|
||||
if (!_initialized) await initialize();
|
||||
if (!_enabled || kIsWeb) return;
|
||||
|
||||
@@ -60,7 +58,7 @@ abstract final class HapticSettings {
|
||||
try {
|
||||
final handled = await _channel.invokeMethod<bool>(
|
||||
'impact',
|
||||
<String, Object>{'kind': kind.name},
|
||||
<String, Object>{'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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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<String>? 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<String>? _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<void> _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<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({
|
||||
required String config,
|
||||
required String remark,
|
||||
required List<String>? 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<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();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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/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<ConnectionModeTile> {
|
||||
ConnectionMode _mode = ConnectionSettings.mode;
|
||||
bool _testingLocalSocks = false;
|
||||
bool _reconnecting = false;
|
||||
LocalSocksTestResult? _lastSocksTest;
|
||||
|
||||
@override
|
||||
@@ -59,14 +61,47 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||
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 {
|
||||
if (_testingLocalSocks) return;
|
||||
final vpn = context.read<VpnConnection>();
|
||||
|
||||
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<ConnectionModeTile> {
|
||||
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:<port> with the credentials shown below.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,12 +164,93 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||
),
|
||||
),
|
||||
if (_mode == ConnectionMode.proxy) ...[
|
||||
const Padding(
|
||||
Consumer<VpnConnection>(
|
||||
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(
|
||||
'Secure Local SOCKS5 uses a private port and new credentials for every VPN session.',
|
||||
'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),
|
||||
|
||||
@@ -51,12 +51,6 @@ class _ResilienceModeTileState extends State<ResilienceModeTile> {
|
||||
Widget build(BuildContext context) {
|
||||
final extreme = _mode == ResilienceMode.extreme;
|
||||
|
||||
return Consumer<VpnConnection>(
|
||||
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',
|
||||
@@ -64,8 +58,8 @@ class _ResilienceModeTileState extends State<ResilienceModeTile> {
|
||||
),
|
||||
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',
|
||||
? '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(
|
||||
@@ -86,7 +80,5 @@ class _ResilienceModeTileState extends State<ResilienceModeTile> {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -50,15 +30,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user