feat(app): add secure routing and runtime resilience
Add explicit TUN and SOCKS5 connection modes, app routing controls, authenticated per-session local SOCKS5, Android network monitoring, persistent haptics, resilient session recovery, notification hardening and client-side Hivemind cleanup. Keep the hosted flutter_vless_android 1.1.5 runtime as the base and apply the Android compatibility/security patches at build time instead of restoring the unstable vendored runtime.
This commit is contained in:
1 parent
371db8da9f
commit
8ead3ff249
30 files changed
+3959
-484
No files matched your search
@@ -0,0 +1,65 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
||||
|
||||
class ConnectButton extends StatefulWidget {
|
||||
const ConnectButton({super.key});
|
||||
@@ -17,7 +15,7 @@ class ConnectButton extends StatefulWidget {
|
||||
class _ConnectButtonState extends State<ConnectButton>
|
||||
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
bool _busy = false;
|
||||
DateTime _lastTap = DateTime.now();
|
||||
DateTime _lastTap = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
AnimationController? _pulse;
|
||||
Animation<double>? _pulseAnim;
|
||||
|
||||
@@ -26,12 +24,17 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final pulse = AnimationController(vsync: this, duration: const Duration(milliseconds: 2000));
|
||||
final pulse = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
_pulse = pulse;
|
||||
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut));
|
||||
_pulseAnim = Tween(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: pulse, curve: Curves.easeInOut),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<VpnConnection>().addListener(_onVpnChanged);
|
||||
if (mounted) context.read<VpnConnection>().addListener(_onVpnChanged);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,7 +71,6 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
}
|
||||
|
||||
Future<void> _handleTap() async {
|
||||
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastTap).inMilliseconds < 1000) return;
|
||||
_lastTap = now;
|
||||
@@ -77,32 +79,21 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
final timer = context.read<SessionTimer>();
|
||||
final ad = context.read<AdManager>();
|
||||
|
||||
|
||||
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
||||
_busy = false;
|
||||
await timer.disconnect();
|
||||
unawaited(Haptics.connectionChanged());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (vpn.status == VpnStatus.disconnecting) return;
|
||||
|
||||
|
||||
if (_busy) return;
|
||||
if (vpn.status == VpnStatus.disconnecting || _busy) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final adWatched = await ad.showAd('main');
|
||||
if (!adWatched && AdManager.adsEnabled) {
|
||||
setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
if (!adWatched && AdManager.adsEnabled) return;
|
||||
|
||||
final ok = await vpn.connect();
|
||||
if (ok) {
|
||||
timer.start();
|
||||
unawaited(Haptics.connectionChanged());
|
||||
}
|
||||
if (ok) await timer.start();
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
@@ -170,7 +161,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
color: Color.fromARGB(glowAlpha, 255, 214, 0),
|
||||
blurRadius: glowRadius,
|
||||
spreadRadius: glowRadius * 0.3,
|
||||
)
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
|
||||
@@ -141,7 +141,10 @@ class _SidebarDrawerState extends State<SidebarDrawer> {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
Navigator.of(context).pop();
|
||||
unawaited(
|
||||
Updater.check(navigator: navigator, messenger: messenger),
|
||||
Updater.checkWithHandles(
|
||||
navigator: navigator,
|
||||
messenger: messenger,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -7,16 +7,6 @@ import 'package:revoltvpn/logic/server_list.dart';
|
||||
import 'package:revoltvpn/components/online_dot.dart';
|
||||
import 'package:revoltvpn/screens/server_selector/selection_screen.dart';
|
||||
|
||||
// ── lib/components/status_bar.dart ──────────────────────────────────────────
|
||||
// Two-card layout at the bottom of the main screen.
|
||||
//
|
||||
// Top card: server location from ServerList provider (flag + city).
|
||||
// Tappable → opens server selector. Always visible.
|
||||
// Bottom card: connection status (health dot + label + lock icon).
|
||||
//
|
||||
// Separated into two rows — no more RenderFlex overflow and ready for
|
||||
// multi-server picker.
|
||||
|
||||
class StatusBar extends StatelessWidget {
|
||||
const StatusBar({super.key});
|
||||
|
||||
@@ -28,15 +18,14 @@ class StatusBar extends StatelessWidget {
|
||||
final isConnecting = vpn.status == VpnStatus.connecting;
|
||||
final serverHealthy =
|
||||
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
|
||||
|
||||
final statusLabel = isConnected
|
||||
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
|
||||
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
|
||||
: (isConnecting
|
||||
? 'Connecting…'
|
||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||
|
||||
// Read from the ServerList provider — the source of truth for
|
||||
// which server is selected and what it looks like.
|
||||
final server = context.watch<ServerList>().selected;
|
||||
|
||||
return Padding(
|
||||
@@ -44,7 +33,6 @@ class StatusBar extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ── Server location ──────────────────────────────────────────
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
@@ -56,10 +44,12 @@ class StatusBar extends StatelessWidget {
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
)),
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
@@ -79,24 +69,26 @@ class StatusBar extends StatelessWidget {
|
||||
Image.asset(server.flagAsset, width: 20, height: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(server.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
)),
|
||||
child: Text(
|
||||
server.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textDim,
|
||||
size: 20,
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: AppColors.textDim, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Connection status ────────────────────────────────────────
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
@@ -113,8 +105,11 @@ class StatusBar extends StatelessWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
if (isConnected)
|
||||
const Icon(Icons.lock,
|
||||
color: AppColors.green50, size: 14),
|
||||
const Icon(
|
||||
Icons.lock,
|
||||
color: AppColors.green50,
|
||||
size: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,13 +20,21 @@ class _SupportButtonState extends State<SupportButton> {
|
||||
final ad = context.read<AdManager>();
|
||||
final timer = context.read<SessionTimer>();
|
||||
|
||||
// Only available when there's an active session.
|
||||
if (!timer.isRunning && !timer.hasSyncedOnce) return;
|
||||
// Only available when there's an active session, once the persisted
|
||||
// support state is known, and only once per client-side VPN session.
|
||||
if ((!timer.isRunning && !timer.hasSyncedOnce) ||
|
||||
!timer.supportRewardStateLoaded ||
|
||||
timer.supportRewardClaimed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _busy = true);
|
||||
|
||||
try {
|
||||
await ad.showAd('support');
|
||||
final rewarded = await ad.showAd('support');
|
||||
if (rewarded) {
|
||||
await timer.markSupportRewardClaimed();
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
@@ -34,48 +42,62 @@ class _SupportButtonState extends State<SupportButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<SessionTimer, bool>(
|
||||
selector: (_, timer) => timer.isRunning || timer.hasSyncedOnce,
|
||||
builder: (context, visible, _) {
|
||||
if (!visible) {
|
||||
return Consumer<SessionTimer>(
|
||||
builder: (context, timer, _) {
|
||||
if (!timer.isRunning && !timer.hasSyncedOnce) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final stateLoaded = timer.supportRewardStateLoaded;
|
||||
final claimed = timer.supportRewardClaimed;
|
||||
final disabled = !stateLoaded || claimed;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _handleTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassBg,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: AppColors.accent.withAlpha(100),
|
||||
width: 1,
|
||||
onTap: disabled ? null : _handleTap,
|
||||
child: Opacity(
|
||||
opacity: disabled ? 0.55 : 1.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassBg,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: AppColors.accent.withAlpha(100),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.favorite_border, color: AppColors.accent, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
_busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
claimed ? Icons.check_circle_outline : Icons.favorite_border,
|
||||
color: AppColors.accent,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
!stateLoaded
|
||||
? 'Checking support bonus…'
|
||||
: claimed
|
||||
? 'Support bonus used'
|
||||
: 'Support us — 30 min!',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textWhite,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Support us \u2014 30 min!',
|
||||
style: TextStyle(
|
||||
color: AppColors.textWhite,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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;
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<void> setMode(ConnectionMode mode) async {
|
||||
_mode = mode;
|
||||
_initialized = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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,11 +4,17 @@ import 'package:uuid/uuid.dart';
|
||||
class CryptoService {
|
||||
static const String _deviceIdPref = 'device_uuid';
|
||||
static const _storage = FlutterSecureStorage();
|
||||
static final RegExp _uuidV4 = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Get or create a persistent device UUID for server-side session tracking.
|
||||
/// Corrupted or tampered storage values are replaced instead of being sent
|
||||
/// to the public API as arbitrary query data.
|
||||
static Future<String> getDeviceId() async {
|
||||
final existing = await _storage.read(key: _deviceIdPref);
|
||||
if (existing != null) return existing;
|
||||
if (existing != null && _uuidV4.hasMatch(existing)) return existing;
|
||||
|
||||
final newId = const Uuid().v4();
|
||||
await _storage.write(key: _deviceIdPref, value: newId);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
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._();
|
||||
|
||||
static const String _prefKey = 'haptic_feedback_enabled';
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('com.revoltvpn.app/haptics');
|
||||
|
||||
static bool _enabled = true;
|
||||
static bool _initialized = false;
|
||||
|
||||
static bool get enabled => _enabled;
|
||||
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_enabled = prefs.getBool(_prefKey) ?? true;
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Failed to load preference: $e');
|
||||
_enabled = true;
|
||||
} finally {
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setEnabled(bool value) async {
|
||||
final previous = _enabled;
|
||||
_enabled = value;
|
||||
_initialized = true;
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = await prefs.setBool(_prefKey, value);
|
||||
if (!saved) throw StateError('Could not save haptic setting');
|
||||
} catch (e) {
|
||||
_enabled = previous;
|
||||
debugPrint('[Haptics] Failed to save preference: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static void tap() => unawaited(_impact(HapticKind.tap));
|
||||
static void selection() => unawaited(_impact(HapticKind.selection));
|
||||
static void success() => unawaited(_impact(HapticKind.success));
|
||||
|
||||
static Future<void> _impact(HapticKind kind) async {
|
||||
if (!_initialized) await initialize();
|
||||
if (!_enabled || kIsWeb) return;
|
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
try {
|
||||
final handled = await _channel.invokeMethod<bool>(
|
||||
'impact',
|
||||
<String, Object>{'kind': kind.name},
|
||||
) ??
|
||||
false;
|
||||
if (handled) return;
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Native vibration unavailable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case HapticKind.tap:
|
||||
await HapticFeedback.lightImpact();
|
||||
break;
|
||||
case HapticKind.selection:
|
||||
await HapticFeedback.selectionClick();
|
||||
break;
|
||||
case HapticKind.success:
|
||||
await HapticFeedback.mediumImpact();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
-54
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:revoltvpn/logic/app_config.dart';
|
||||
@@ -12,14 +13,21 @@ class HivemindService {
|
||||
|
||||
static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
|
||||
static const _pollBackoff = <Duration>[
|
||||
Duration(seconds: 1),
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 3),
|
||||
Duration(seconds: 5),
|
||||
];
|
||||
|
||||
static Future<http.Response> directGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
static Future<http.Response> directGet(
|
||||
Uri uri, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) {
|
||||
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
||||
}
|
||||
|
||||
static void cancel() {
|
||||
_currentCallId++;
|
||||
}
|
||||
static void cancel() => _currentCallId++;
|
||||
|
||||
static void setExpectedNonce(String nonce) {
|
||||
_expectedNonce = nonce;
|
||||
@@ -30,66 +38,34 @@ class HivemindService {
|
||||
bool skipAdBypass = false,
|
||||
}) async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
|
||||
final callId = ++_currentCallId;
|
||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final nonce = _newNonce();
|
||||
_expectedNonce = nonce;
|
||||
debugPrint('[HivemindService] Call #$callId — nonce: $nonce');
|
||||
|
||||
final url = _publicUrl('/session/status?device_id=$deviceId');
|
||||
|
||||
if (!skipAdBypass) {
|
||||
try {
|
||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||
final fakeUrl = _publicUrl(
|
||||
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
||||
} catch (_) {}
|
||||
await _runConfiguredBypass(deviceId, nonce);
|
||||
}
|
||||
|
||||
const maxAttempts = 5;
|
||||
for (int i = 1; i <= maxAttempts; i++) {
|
||||
if (_currentCallId != callId) throw Exception('Cancelled');
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
_throwIfCancelled(callId);
|
||||
onAttempt?.call(attempt, maxAttempts);
|
||||
|
||||
onAttempt?.call(i, maxAttempts);
|
||||
try {
|
||||
final response = await directGet(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
final serverNonce = data['nonce'] as String?;
|
||||
if (_expectedNonce != null && serverNonce != null && serverNonce != _expectedNonce) {
|
||||
debugPrint('[HivemindService] Nonce mismatch — retrying…');
|
||||
} else if (data['active'] == true && data['vless_uuid'] != null) {
|
||||
final vlessUuid = data['vless_uuid'];
|
||||
final vlessIp = data['vless_ip'] ?? AppConfig.serverIp;
|
||||
final vlessPort = data['vless_port'] ?? 443;
|
||||
final pbk = data['reality_pbk'] ?? '';
|
||||
final sid = data['reality_sid'] ?? '';
|
||||
final sni = data['reality_sni'];
|
||||
if (sni == null) throw Exception('Server did not provide reality_sni');
|
||||
final fp = data['reality_fp'] ?? AppConfig.realityFp;
|
||||
final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath;
|
||||
|
||||
final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort'
|
||||
'?security=${AppConfig.vlessSecurity}'
|
||||
'&type=${AppConfig.vlessType}'
|
||||
'&path=$xhttpPath'
|
||||
'&pbk=${Uri.encodeComponent(pbk)}'
|
||||
'&sni=${Uri.encodeComponent(sni)}'
|
||||
'&sid=${Uri.encodeComponent(sid)}'
|
||||
'&fp=${Uri.encodeComponent(fp)}'
|
||||
'#Revolt VPN';
|
||||
_expectedNonce = null;
|
||||
return vlessUrl;
|
||||
}
|
||||
final session = await _fetchActiveSession(deviceId, nonce);
|
||||
if (session != null) {
|
||||
_expectedNonce = null;
|
||||
return session.toVlessUrl();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.toString().contains('Cancelled')) rethrow;
|
||||
debugPrint('[HivemindService] Attempt $i failed: $e');
|
||||
if (_isCancelledError(e)) rethrow;
|
||||
debugPrint('[Hivemind] session attempt $attempt failed');
|
||||
}
|
||||
|
||||
if (i < maxAttempts) await Future.delayed(const Duration(seconds: 1));
|
||||
if (attempt < maxAttempts) {
|
||||
await Future.delayed(_pollBackoff[attempt - 1]);
|
||||
_throwIfCancelled(callId);
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception('Session not activated. Server callback may have timed out.');
|
||||
@@ -97,16 +73,139 @@ class HivemindService {
|
||||
|
||||
static Future<bool> checkHealth() async {
|
||||
try {
|
||||
final url = Uri.parse('${AppConfig.hivemindApiPublic}/health');
|
||||
final response = await directGet(url, timeout: const Duration(seconds: 3));
|
||||
final response = await directGet(
|
||||
_publicUrl('/health'),
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL builders ──────────────────────────────────────────────────
|
||||
static String _newNonce() {
|
||||
final random = Random.secure();
|
||||
final high = random.nextInt(0x7FFFFFFF);
|
||||
final low = random.nextInt(0x7FFFFFFF);
|
||||
return '$high-$low-${DateTime.now().microsecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
static Future<void> _runConfiguredBypass(String deviceId, String nonce) async {
|
||||
try {
|
||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||
final callback = _publicUrl(
|
||||
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}',
|
||||
);
|
||||
await directGet(callback, timeout: const Duration(seconds: 8));
|
||||
} catch (_) {
|
||||
// Validation bypass behavior is intentionally best-effort in this build.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
||||
String deviceId,
|
||||
String nonce,
|
||||
) async {
|
||||
final statusUri = _publicUrl('/session/status').replace(
|
||||
queryParameters: {'device_id': deviceId},
|
||||
);
|
||||
final response = await directGet(statusUri);
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
final serverNonce = decoded['nonce'];
|
||||
if (_expectedNonce != null &&
|
||||
serverNonce is String &&
|
||||
serverNonce != nonce) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _HivemindSessionConfig.fromJson(decoded);
|
||||
}
|
||||
|
||||
static void _throwIfCancelled(int callId) {
|
||||
if (_currentCallId != callId) throw Exception('Cancelled');
|
||||
}
|
||||
|
||||
static bool _isCancelledError(Object error) =>
|
||||
error.toString().contains('Cancelled');
|
||||
|
||||
static Uri _publicUrl(String path) =>
|
||||
Uri.parse('${AppConfig.hivemindApiPublic}$path');
|
||||
}
|
||||
|
||||
class _HivemindSessionConfig {
|
||||
final String uuid;
|
||||
final String host;
|
||||
final int port;
|
||||
final String publicKey;
|
||||
final String shortId;
|
||||
final String sni;
|
||||
final String fingerprint;
|
||||
final String path;
|
||||
|
||||
const _HivemindSessionConfig({
|
||||
required this.uuid,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.publicKey,
|
||||
required this.shortId,
|
||||
required this.sni,
|
||||
required this.fingerprint,
|
||||
required this.path,
|
||||
});
|
||||
|
||||
factory _HivemindSessionConfig.fromJson(Map<String, dynamic> json) {
|
||||
final uuid = _requiredString(json, 'vless_uuid');
|
||||
final host = _stringOr(json['vless_ip'], AppConfig.serverIp);
|
||||
final port = _portOr(json['vless_port'], 443);
|
||||
final sni = _requiredString(json, 'reality_sni');
|
||||
|
||||
return _HivemindSessionConfig(
|
||||
uuid: uuid,
|
||||
host: host,
|
||||
port: port,
|
||||
publicKey: _stringOr(json['reality_pbk'], ''),
|
||||
shortId: _stringOr(json['reality_sid'], ''),
|
||||
sni: sni,
|
||||
fingerprint: _stringOr(json['reality_fp'], AppConfig.realityFp),
|
||||
path: _stringOr(json['xhttp_path'], AppConfig.vlessPath),
|
||||
);
|
||||
}
|
||||
|
||||
String toVlessUrl() {
|
||||
return 'vless://$uuid@$host:$port'
|
||||
'?security=${AppConfig.vlessSecurity}'
|
||||
'&type=${AppConfig.vlessType}'
|
||||
'&path=$path'
|
||||
'&pbk=${Uri.encodeComponent(publicKey)}'
|
||||
'&sni=${Uri.encodeComponent(sni)}'
|
||||
'&sid=${Uri.encodeComponent(shortId)}'
|
||||
'&fp=${Uri.encodeComponent(fingerprint)}'
|
||||
'#Revolt VPN';
|
||||
}
|
||||
|
||||
static String _requiredString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||
throw FormatException('Missing required session field: $key');
|
||||
}
|
||||
|
||||
static String _stringOr(Object? value, String fallback) {
|
||||
return value is String && value.isNotEmpty ? value : fallback;
|
||||
}
|
||||
|
||||
static int _portOr(Object? value, int fallback) {
|
||||
final parsed = value is num ? value.toInt() : fallback;
|
||||
if (parsed <= 0 || parsed > 65535) {
|
||||
throw const FormatException('Invalid VLESS port');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
class LocalSocksTestResult {
|
||||
final bool ok;
|
||||
final int? latencyMs;
|
||||
final String message;
|
||||
|
||||
const LocalSocksTestResult({
|
||||
required this.ok,
|
||||
required this.latencyMs,
|
||||
required this.message,
|
||||
});
|
||||
}
|
||||
|
||||
abstract final class LocalSocksTester {
|
||||
LocalSocksTester._();
|
||||
|
||||
/// Fast local readiness probe used while starting the VPN.
|
||||
///
|
||||
/// This stops after SOCKS5 authentication. Startup must not depend on an
|
||||
/// unrelated external host being reachable.
|
||||
static Future<LocalSocksTestResult> testListener({
|
||||
String host = '127.0.0.1',
|
||||
int port = 10807,
|
||||
String? username,
|
||||
String? password,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
Socket? socket;
|
||||
_SocketReader? reader;
|
||||
|
||||
try {
|
||||
socket = await Socket.connect(
|
||||
host,
|
||||
port,
|
||||
timeout: const Duration(seconds: 1),
|
||||
);
|
||||
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||
reader = _SocketReader(socket);
|
||||
|
||||
final authenticated = await _authenticate(
|
||||
reader,
|
||||
socket,
|
||||
username: username,
|
||||
password: password,
|
||||
timeout: const Duration(seconds: 1),
|
||||
);
|
||||
if (!authenticated) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 listener rejected authentication.',
|
||||
);
|
||||
}
|
||||
|
||||
stopwatch.stop();
|
||||
return LocalSocksTestResult(
|
||||
ok: true,
|
||||
latencyMs: stopwatch.elapsedMilliseconds,
|
||||
message: 'Local SOCKS5 listener is ready.',
|
||||
);
|
||||
} on TimeoutException {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 listener timed out.',
|
||||
);
|
||||
} on SocketException {
|
||||
return LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 is not listening on $host:$port.',
|
||||
);
|
||||
} catch (_) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 listener check failed.',
|
||||
);
|
||||
} finally {
|
||||
await reader?.close();
|
||||
await socket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Full user-facing test: authenticated local SOCKS handshake plus outbound
|
||||
/// CONNECT. Credentials are optional so the helper remains usable for legacy
|
||||
/// no-auth diagnostic builds.
|
||||
static Future<LocalSocksTestResult> test({
|
||||
String host = '127.0.0.1',
|
||||
int port = 10807,
|
||||
String? username,
|
||||
String? password,
|
||||
String targetHost = 'paladinvpn.duckdns.org',
|
||||
int targetPort = 443,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
Socket? socket;
|
||||
_SocketReader? reader;
|
||||
|
||||
try {
|
||||
socket = await Socket.connect(
|
||||
host,
|
||||
port,
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
socket.setOption(SocketOption.tcpNoDelay, true);
|
||||
reader = _SocketReader(socket);
|
||||
|
||||
final authenticated = await _authenticate(
|
||||
reader,
|
||||
socket,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
if (!authenticated) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 listener rejected authentication.',
|
||||
);
|
||||
}
|
||||
|
||||
final hostBytes = utf8.encode(targetHost);
|
||||
if (hostBytes.isEmpty || hostBytes.length > 255) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'SOCKS5 test target is invalid.',
|
||||
);
|
||||
}
|
||||
|
||||
socket.add(<int>[
|
||||
0x05,
|
||||
0x01,
|
||||
0x00,
|
||||
0x03,
|
||||
hostBytes.length,
|
||||
...hostBytes,
|
||||
(targetPort >> 8) & 0xff,
|
||||
targetPort & 0xff,
|
||||
]);
|
||||
await socket.flush();
|
||||
|
||||
final replyHead = await reader.readExactly(4);
|
||||
if (replyHead[0] != 0x05 || replyHead[1] != 0x00) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message:
|
||||
'SOCKS5 is listening, but the ReVolt route could not reach the test target.',
|
||||
);
|
||||
}
|
||||
|
||||
await _consumeAddress(reader, replyHead[3]);
|
||||
stopwatch.stop();
|
||||
return LocalSocksTestResult(
|
||||
ok: true,
|
||||
latencyMs: stopwatch.elapsedMilliseconds,
|
||||
message: 'Local SOCKS5 and the ReVolt outbound are reachable.',
|
||||
);
|
||||
} on TimeoutException {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 test timed out.',
|
||||
);
|
||||
} on SocketException {
|
||||
return LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 is not listening on $host:$port.',
|
||||
);
|
||||
} catch (_) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'Local SOCKS5 test failed.',
|
||||
);
|
||||
} finally {
|
||||
await reader?.close();
|
||||
await socket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> _authenticate(
|
||||
_SocketReader reader,
|
||||
Socket socket, {
|
||||
required String? username,
|
||||
required String? password,
|
||||
Duration timeout = const Duration(seconds: 4),
|
||||
}) async {
|
||||
final passwordAuth = username != null && password != null;
|
||||
if ((username == null) != (password == null)) return false;
|
||||
|
||||
socket.add(passwordAuth
|
||||
? const <int>[0x05, 0x01, 0x02]
|
||||
: const <int>[0x05, 0x01, 0x00]);
|
||||
await socket.flush();
|
||||
|
||||
final greeting = await reader.readExactly(2, timeout: timeout);
|
||||
if (greeting[0] != 0x05) return false;
|
||||
if (!passwordAuth) return greeting[1] == 0x00;
|
||||
if (greeting[1] != 0x02) return false;
|
||||
|
||||
final userBytes = utf8.encode(username);
|
||||
final passBytes = utf8.encode(password);
|
||||
if (userBytes.isEmpty ||
|
||||
passBytes.isEmpty ||
|
||||
userBytes.length > 255 ||
|
||||
passBytes.length > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
socket.add(<int>[
|
||||
0x01,
|
||||
userBytes.length,
|
||||
...userBytes,
|
||||
passBytes.length,
|
||||
...passBytes,
|
||||
]);
|
||||
await socket.flush();
|
||||
|
||||
final authReply = await reader.readExactly(2, timeout: timeout);
|
||||
return authReply[0] == 0x01 && authReply[1] == 0x00;
|
||||
}
|
||||
|
||||
static Future<void> _consumeAddress(
|
||||
_SocketReader reader, int addressType) async {
|
||||
if (addressType == 0x01) {
|
||||
await reader.readExactly(4 + 2);
|
||||
return;
|
||||
}
|
||||
if (addressType == 0x04) {
|
||||
await reader.readExactly(16 + 2);
|
||||
return;
|
||||
}
|
||||
if (addressType == 0x03) {
|
||||
final length = (await reader.readExactly(1)).first;
|
||||
await reader.readExactly(length + 2);
|
||||
return;
|
||||
}
|
||||
throw const FormatException('Unsupported SOCKS5 address type');
|
||||
}
|
||||
}
|
||||
|
||||
class _SocketReader {
|
||||
final StreamIterator<Uint8List> _iterator;
|
||||
final List<int> _buffer = <int>[];
|
||||
|
||||
_SocketReader(Socket socket) : _iterator = StreamIterator<Uint8List>(socket);
|
||||
|
||||
Future<List<int>> readExactly(
|
||||
int count, {
|
||||
Duration timeout = const Duration(seconds: 4),
|
||||
}) async {
|
||||
while (_buffer.length < count) {
|
||||
final hasNext = await _iterator.moveNext().timeout(timeout);
|
||||
if (!hasNext) {
|
||||
throw const SocketException('Socket closed early');
|
||||
}
|
||||
_buffer.addAll(_iterator.current);
|
||||
}
|
||||
|
||||
final result = _buffer.sublist(0, count);
|
||||
_buffer.removeRange(0, count);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> close() => _iterator.cancel();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class NetworkSnapshot {
|
||||
final String reason;
|
||||
final String transport;
|
||||
final bool connected;
|
||||
final bool validated;
|
||||
final bool metered;
|
||||
final int timestamp;
|
||||
|
||||
const NetworkSnapshot({
|
||||
required this.reason,
|
||||
required this.transport,
|
||||
required this.connected,
|
||||
required this.validated,
|
||||
required this.metered,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory NetworkSnapshot.fromMap(Map<dynamic, dynamic> map) {
|
||||
return NetworkSnapshot(
|
||||
reason: map['reason'] as String? ?? 'changed',
|
||||
transport: map['transport'] as String? ?? 'unknown',
|
||||
connected: map['connected'] == true,
|
||||
validated: map['validated'] == true,
|
||||
metered: map['metered'] == true,
|
||||
timestamp: (map['timestamp'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract final class NetworkMonitor {
|
||||
NetworkMonitor._();
|
||||
|
||||
static const EventChannel _channel = EventChannel('com.revoltvpn.app/network');
|
||||
|
||||
static Stream<NetworkSnapshot> get changes {
|
||||
return _channel.receiveBroadcastStream().where((event) => event is Map).map(
|
||||
(event) => NetworkSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
class SecureSocksSession {
|
||||
static const String inboundTag = 'revolt-secure-socks';
|
||||
static final Random _random = Random.secure();
|
||||
|
||||
final int port;
|
||||
final String username;
|
||||
final String password;
|
||||
final String configJson;
|
||||
|
||||
const SecureSocksSession._({
|
||||
required this.port,
|
||||
required this.username,
|
||||
required this.password,
|
||||
required this.configJson,
|
||||
});
|
||||
|
||||
static Future<SecureSocksSession> create(String baseConfig) async {
|
||||
final decoded = jsonDecode(baseConfig);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('VLESS configuration must be a JSON object.');
|
||||
}
|
||||
|
||||
final reservation = await ServerSocket.bind(
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
shared: false,
|
||||
);
|
||||
final port = reservation.port;
|
||||
await reservation.close();
|
||||
|
||||
if (port <= 1024 || port > 65535) {
|
||||
throw StateError('Could not allocate a safe local SOCKS5 port.');
|
||||
}
|
||||
|
||||
final username = _token(16);
|
||||
final password = _token(32);
|
||||
|
||||
final sourceInbounds = decoded['inbounds'];
|
||||
final inbounds = <dynamic>[];
|
||||
if (sourceInbounds is List) {
|
||||
for (final value in sourceInbounds) {
|
||||
if (value is Map) {
|
||||
final protocol = value['protocol']?.toString().toLowerCase();
|
||||
// ReVolt owns local proxy ingress on Android. Drop imported SOCKS/HTTP
|
||||
// listeners so a stale no-auth proxy cannot remain reachable beside
|
||||
// the authenticated session listener.
|
||||
if (protocol == 'socks' || protocol == 'http') continue;
|
||||
}
|
||||
inbounds.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
inbounds.add(<String, dynamic>{
|
||||
'tag': inboundTag,
|
||||
'port': port,
|
||||
'listen': '127.0.0.1',
|
||||
'protocol': 'socks',
|
||||
'settings': <String, dynamic>{
|
||||
'auth': 'password',
|
||||
'udp': true,
|
||||
'ip': '127.0.0.1',
|
||||
'users': <Map<String, String>>[
|
||||
<String, String>{'user': username, 'pass': password},
|
||||
],
|
||||
},
|
||||
'sniffing': <String, dynamic>{
|
||||
'enabled': true,
|
||||
'destOverride': <String>['http', 'tls'],
|
||||
},
|
||||
});
|
||||
decoded['inbounds'] = inbounds;
|
||||
|
||||
return SecureSocksSession._(
|
||||
port: port,
|
||||
username: username,
|
||||
password: password,
|
||||
configJson: jsonEncode(decoded),
|
||||
);
|
||||
}
|
||||
|
||||
static String _token(int byteCount) {
|
||||
final bytes = List<int>.generate(byteCount, (_) => _random.nextInt(256));
|
||||
return bytes.map((value) => value.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
}
|
||||
+204
-81
@@ -1,11 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:revoltvpn/logic/app_config.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/crypto_service.dart';
|
||||
import 'package:revoltvpn/logic/hivemind_service.dart';
|
||||
import 'package:revoltvpn/logic/notification_service.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
|
||||
class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
Timer? _timer;
|
||||
@@ -15,12 +17,20 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
final VpnConnection vpnConnection;
|
||||
|
||||
int _remainingSeconds = 0;
|
||||
int _usedBytes = 0;
|
||||
int _remainingAtLastSync = 0;
|
||||
int _usedBytes = 0;
|
||||
|
||||
bool _hasSyncedOnce = false;
|
||||
int _consecutiveFailures = 0;
|
||||
bool _isDisconnecting = false;
|
||||
bool _syncInProgress = false;
|
||||
bool _hasSyncedOnce = false;
|
||||
int _consecutiveFailures = 0;
|
||||
bool _isDisconnecting = false;
|
||||
bool _syncInProgress = false;
|
||||
bool _supportRewardClaimed = false;
|
||||
bool _supportRewardStateLoaded = false;
|
||||
int _supportStateEpoch = 0;
|
||||
|
||||
static const String _supportRewardClaimKey =
|
||||
'support_reward_claimed_active_session';
|
||||
static const FlutterSecureStorage _supportStorage = FlutterSecureStorage();
|
||||
|
||||
static const int _maxConsecutiveFailures = 3;
|
||||
static const int _maxOfflineSeconds = 120;
|
||||
@@ -29,21 +39,24 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
static const int _pollIntervalSeconds = 5;
|
||||
static const int _backgroundPollIntervalSeconds = 30;
|
||||
|
||||
int _lastUsedBytes = 0;
|
||||
double _currentSpeedKBps = 0.0;
|
||||
DateTime? _lastSyncAt;
|
||||
int _lastUsedBytes = 0;
|
||||
DateTime? _lastSuccessfulSyncAt;
|
||||
double _currentSpeedKBps = 0.0;
|
||||
|
||||
SessionTimer({required this.vpnConnection}) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
vpnConnection.addListener(_onVpnConnectionChanged);
|
||||
unawaited(_loadSupportRewardState());
|
||||
}
|
||||
|
||||
int get remaining => _remainingSeconds;
|
||||
bool get isRunning => _timer != null && _timer!.isActive;
|
||||
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
|
||||
bool get hasSyncedOnce => _hasSyncedOnce;
|
||||
int get remaining => _remainingSeconds;
|
||||
bool get isRunning => _timer != null && _timer!.isActive;
|
||||
bool get isExpired => _remainingSeconds <= 0 && !isRunning;
|
||||
bool get hasSyncedOnce => _hasSyncedOnce;
|
||||
bool get supportRewardClaimed => _supportRewardClaimed;
|
||||
bool get supportRewardStateLoaded => _supportRewardStateLoaded;
|
||||
|
||||
int get usedBytes => _usedBytes;
|
||||
int get usedBytes => _usedBytes;
|
||||
double get currentSpeedKBps => _currentSpeedKBps;
|
||||
|
||||
String get formatted {
|
||||
@@ -53,8 +66,42 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
|
||||
Future<void> _loadSupportRewardState() async {
|
||||
final epoch = _supportStateEpoch;
|
||||
var claimed = false;
|
||||
try {
|
||||
claimed =
|
||||
(await _supportStorage.read(key: _supportRewardClaimKey)) == '1';
|
||||
} catch (e) {
|
||||
debugPrint('[Timer] Failed to load support reward state: $e');
|
||||
}
|
||||
|
||||
if (epoch != _supportStateEpoch) return;
|
||||
_supportRewardClaimed = claimed;
|
||||
_supportRewardStateLoaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _persistSupportRewardState(bool value) async {
|
||||
try {
|
||||
await _supportStorage.write(
|
||||
key: _supportRewardClaimKey,
|
||||
value: value ? '1' : '0',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[Timer] Failed to persist support reward state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> markSupportRewardClaimed() async {
|
||||
if (_supportRewardClaimed) return;
|
||||
_supportRewardClaimed = true;
|
||||
_supportRewardStateLoaded = true;
|
||||
notifyListeners();
|
||||
await _persistSupportRewardState(true);
|
||||
}
|
||||
|
||||
void _onVpnConnectionChanged() {
|
||||
// Restore timer if VPN was already running at app launch.
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
vpnConnection.isStartupRestoration) {
|
||||
@@ -62,59 +109,90 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume after brief tunnel disconnection.
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
!_isDisconnecting &&
|
||||
_hasSyncedOnce) {
|
||||
debugPrint('[Timer] VPN reconnected after blip — resuming.');
|
||||
debugPrint('[Timer] VPN reconnected after blip - resuming.');
|
||||
_resumeTicking();
|
||||
return;
|
||||
}
|
||||
|
||||
if (vpnConnection.status == VpnStatus.disconnected && !_isDisconnecting) {
|
||||
_doDisconnect('VPN tunnel dropped');
|
||||
final hadActiveSession = isRunning || _hasSyncedOnce;
|
||||
if (!hadActiveSession || _isDisconnecting) return;
|
||||
|
||||
if (vpnConnection.status == VpnStatus.error) {
|
||||
_stopForVpnFailure('VPN entered error state');
|
||||
return;
|
||||
}
|
||||
|
||||
if (vpnConnection.status == VpnStatus.disconnected) {
|
||||
unawaited(_doDisconnect('VPN tunnel dropped'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
_remainingSeconds = 0;
|
||||
_usedBytes = 0;
|
||||
_lastUsedBytes = 0;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_lastSyncAt = null;
|
||||
NotificationService.reset();
|
||||
_tickCount = 0;
|
||||
_hasSyncedOnce = false;
|
||||
void _stopForVpnFailure(String reason) {
|
||||
if (_isDisconnecting) return;
|
||||
_isDisconnecting = true;
|
||||
debugPrint('[Timer] Stopping after VPN failure: $reason');
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_remainingSeconds = 0;
|
||||
_remainingAtLastSync = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_lastSuccessfulSyncAt = null;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_isDisconnecting = false;
|
||||
_appBackgrounded = false;
|
||||
_offlineSeconds = 0;
|
||||
NotificationService.reset();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
_remainingSeconds = 0;
|
||||
_remainingAtLastSync = 0;
|
||||
_usedBytes = 0;
|
||||
_lastUsedBytes = 0;
|
||||
_lastSuccessfulSyncAt = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_tickCount = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_isDisconnecting = false;
|
||||
_appBackgrounded = false;
|
||||
NotificationService.reset();
|
||||
|
||||
_supportStateEpoch++;
|
||||
_supportRewardClaimed = false;
|
||||
_supportRewardStateLoaded = true;
|
||||
unawaited(_persistSupportRewardState(false));
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
|
||||
notifyListeners();
|
||||
_syncWithHivemind();
|
||||
unawaited(_syncWithHivemind());
|
||||
}
|
||||
|
||||
void _tick(Timer t) {
|
||||
if (_isDisconnecting) return;
|
||||
// A local clock: poll failures are about the network, not the clock.
|
||||
if (_hasSyncedOnce && _remainingSeconds > 0) {
|
||||
_remainingSeconds--;
|
||||
|
||||
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
|
||||
_reconcileElapsedTime();
|
||||
}
|
||||
|
||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||
_offlineSeconds++;
|
||||
if (_offlineSeconds >= _maxOfflineSeconds) {
|
||||
_doDisconnect('Server unreachable');
|
||||
unawaited(_doDisconnect('Server unreachable'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
||||
_doDisconnect('Session expired');
|
||||
unawaited(_doDisconnect('Session expired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,16 +201,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
? _backgroundPollIntervalSeconds
|
||||
: _pollIntervalSeconds;
|
||||
if (_tickCount % pollInterval == 0) {
|
||||
_syncWithHivemind();
|
||||
unawaited(_syncWithHivemind());
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Gated on a live tunnel. The plugin cancels the notification when the
|
||||
// service stops, and re-posting after that leaves an ongoing notification
|
||||
// the user cannot swipe away below Android 14.
|
||||
if (_hasSyncedOnce &&
|
||||
vpnConnection.status == VpnStatus.connected) {
|
||||
if (_hasSyncedOnce && vpnConnection.status == VpnStatus.connected) {
|
||||
NotificationService.updateTimer(formatted);
|
||||
}
|
||||
}
|
||||
@@ -150,8 +224,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_remainingSeconds = 0;
|
||||
_remainingAtLastSync = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_lastSyncAt = null;
|
||||
_lastSuccessfulSyncAt = null;
|
||||
NotificationService.reset();
|
||||
notifyListeners();
|
||||
|
||||
@@ -162,45 +237,72 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int _readNonNegativeInt(dynamic value, int fallback) {
|
||||
if (value is int) return value >= 0 ? value : fallback;
|
||||
if (value is num && value >= 0) return value.toInt();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
Future<void> _syncWithHivemind() async {
|
||||
if (_isDisconnecting || _syncInProgress) return;
|
||||
_syncInProgress = true;
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final url = Uri.parse(
|
||||
'${AppConfig.hivemindApiPublic}/session/status?device_id=$deviceId');
|
||||
final base = Uri.parse('${AppConfig.hivemindApiPublic}/session/status');
|
||||
final url = base.replace(queryParameters: {'device_id': deviceId});
|
||||
final response = await HivemindService.directGet(url);
|
||||
if (_isDisconnecting) return;
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('[Timer] Invalid session payload type.');
|
||||
_markSyncFailure();
|
||||
return;
|
||||
}
|
||||
final data = decoded;
|
||||
|
||||
final bool active = data['active'] ?? false;
|
||||
if (!active) {
|
||||
final activeValue = data['active'];
|
||||
if (activeValue is! bool) {
|
||||
debugPrint('[Timer] Session payload missing boolean active state.');
|
||||
_markSyncFailure();
|
||||
return;
|
||||
}
|
||||
if (!activeValue) {
|
||||
await _doDisconnect('Server ended session');
|
||||
return;
|
||||
}
|
||||
|
||||
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
|
||||
_usedBytes = data['used_bytes'] ?? _usedBytes;
|
||||
final expiresValue = data['expires_in_seconds'];
|
||||
if (expiresValue is! num ||
|
||||
!expiresValue.isFinite ||
|
||||
expiresValue < 0) {
|
||||
debugPrint('[Timer] Session payload has invalid expiry.');
|
||||
_markSyncFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
// Measure the real gap between polls: the interval is 5 s in the
|
||||
// foreground, 30 s in the background, and arbitrary on a resume sync.
|
||||
final now = DateTime.now();
|
||||
final lastSync = _lastSyncAt;
|
||||
_lastSyncAt = now;
|
||||
_remainingAtLastSync = expiresValue.toInt();
|
||||
_remainingSeconds = _remainingAtLastSync;
|
||||
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
||||
|
||||
final int deltaBytes = _usedBytes - _lastUsedBytes;
|
||||
if (_hasSyncedOnce && deltaBytes > 0 && lastSync != null) {
|
||||
final elapsed = now.difference(lastSync).inMilliseconds / 1000.0;
|
||||
_currentSpeedKBps =
|
||||
elapsed > 0 ? (deltaBytes / elapsed) / 1000 : _currentSpeedKBps;
|
||||
} else if (!_hasSyncedOnce) {
|
||||
final now = DateTime.now();
|
||||
final previousSyncAt = _lastSuccessfulSyncAt;
|
||||
final deltaBytes = _usedBytes - _lastUsedBytes;
|
||||
if (_hasSyncedOnce && previousSyncAt != null) {
|
||||
final elapsedMs = now.difference(previousSyncAt).inMilliseconds;
|
||||
if (deltaBytes > 0 && elapsedMs > 0) {
|
||||
_currentSpeedKBps = (deltaBytes / (elapsedMs / 1000.0)) / 1000.0;
|
||||
} else {
|
||||
_currentSpeedKBps = 0.0;
|
||||
}
|
||||
} else {
|
||||
_currentSpeedKBps = 0.0;
|
||||
}
|
||||
_lastUsedBytes = _usedBytes;
|
||||
_lastSuccessfulSyncAt = now;
|
||||
|
||||
final capExhausted = data['cap_exhausted'] ?? false;
|
||||
if (capExhausted) {
|
||||
if (data['cap_exhausted'] == true) {
|
||||
await _doDisconnect('Data cap reached');
|
||||
return;
|
||||
}
|
||||
@@ -213,8 +315,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_markSyncFailure();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Hivemind sync error: $e');
|
||||
_markSyncFailure();
|
||||
if (!_isDisconnecting) {
|
||||
debugPrint('Hivemind sync error: $e');
|
||||
_markSyncFailure();
|
||||
}
|
||||
} finally {
|
||||
_syncInProgress = false;
|
||||
}
|
||||
@@ -227,27 +331,46 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
void _resumeTicking() {
|
||||
_lastSyncAt = null;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
_isDisconnecting = false;
|
||||
_syncWithHivemind();
|
||||
notifyListeners();
|
||||
void _reconcileElapsedTime() {
|
||||
final syncedAt = _lastSuccessfulSyncAt;
|
||||
if (!_hasSyncedOnce || syncedAt == null) return;
|
||||
|
||||
final elapsed = DateTime.now().difference(syncedAt).inSeconds;
|
||||
_remainingSeconds =
|
||||
(_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
// Keep ticking, but slow the poll — the 5 s one wakes the radio.
|
||||
state == AppLifecycleState.detached ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.hidden) {
|
||||
_appBackgrounded = true;
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_appBackgrounded = false;
|
||||
if (_timer != null && _timer!.isActive) {
|
||||
_syncWithHivemind();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state != AppLifecycleState.resumed || _isDisconnecting) return;
|
||||
_appBackgrounded = false;
|
||||
if (vpnConnection.status != VpnStatus.connected) return;
|
||||
|
||||
_reconcileElapsedTime();
|
||||
if (!isRunning) {
|
||||
_resumeTicking();
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(_syncWithHivemind());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _resumeTicking() {
|
||||
_reconcileElapsedTime();
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
_isDisconnecting = false;
|
||||
unawaited(_syncWithHivemind());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+160
-50
@@ -13,6 +13,78 @@ enum UpdateStatus { upToDate, updateAvailable, checkFailed }
|
||||
|
||||
enum InstallSource { playStore, sideload }
|
||||
|
||||
Uri? _trustedHttpsUri(String raw, Set<String> allowedHosts) {
|
||||
final uri = Uri.tryParse(raw);
|
||||
if (uri == null || uri.scheme != 'https' || !allowedHosts.contains(uri.host)) {
|
||||
return null;
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
class _ParsedVersion {
|
||||
final List<int> core;
|
||||
final List<String> preRelease;
|
||||
|
||||
const _ParsedVersion(this.core, this.preRelease);
|
||||
|
||||
static _ParsedVersion? tryParse(String input) {
|
||||
var value = input.trim();
|
||||
if (value.startsWith('v') || value.startsWith('V')) {
|
||||
value = value.substring(1);
|
||||
}
|
||||
value = value.split('+').first;
|
||||
final dash = value.indexOf('-');
|
||||
final corePart = dash < 0 ? value : value.substring(0, dash);
|
||||
final prePart = dash < 0 ? '' : value.substring(dash + 1);
|
||||
final rawCore = corePart.split('.');
|
||||
if (rawCore.isEmpty || rawCore.any((p) => int.tryParse(p) == null)) {
|
||||
return null;
|
||||
}
|
||||
final core = rawCore.map(int.parse).toList(growable: false);
|
||||
while (core.length < 3) {
|
||||
core.add(0);
|
||||
}
|
||||
final pre = prePart.isEmpty
|
||||
? const <String>[]
|
||||
: prePart.split('.').where((e) => e.isNotEmpty).toList(growable: false);
|
||||
return _ParsedVersion(core, pre);
|
||||
}
|
||||
|
||||
int compareTo(_ParsedVersion other) {
|
||||
final length = core.length > other.core.length ? core.length : other.core.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final left = i < core.length ? core[i] : 0;
|
||||
final right = i < other.core.length ? other.core[i] : 0;
|
||||
if (left != right) return left.compareTo(right);
|
||||
}
|
||||
|
||||
if (preRelease.isEmpty && other.preRelease.isEmpty) return 0;
|
||||
if (preRelease.isEmpty) return 1;
|
||||
if (other.preRelease.isEmpty) return -1;
|
||||
|
||||
final preLength = preRelease.length > other.preRelease.length
|
||||
? preRelease.length
|
||||
: other.preRelease.length;
|
||||
for (var i = 0; i < preLength; i++) {
|
||||
if (i >= preRelease.length) return -1;
|
||||
if (i >= other.preRelease.length) return 1;
|
||||
final left = preRelease[i];
|
||||
final right = other.preRelease[i];
|
||||
final leftNum = int.tryParse(left);
|
||||
final rightNum = int.tryParse(right);
|
||||
if (leftNum != null && rightNum != null) {
|
||||
if (leftNum != rightNum) return leftNum.compareTo(rightNum);
|
||||
continue;
|
||||
}
|
||||
if (leftNum != null) return -1;
|
||||
if (rightNum != null) return 1;
|
||||
final lexical = left.compareTo(right);
|
||||
if (lexical != 0) return lexical;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
class PlayStoreUpdater {
|
||||
PlayStoreUpdater._();
|
||||
|
||||
@@ -20,9 +92,13 @@ class PlayStoreUpdater {
|
||||
'https://play.google.com/store/apps/details?id=${AppConfig.applicationId}';
|
||||
|
||||
static Future<bool> open() async {
|
||||
final uri = _trustedHttpsUri(_url, const {'play.google.com'});
|
||||
if (uri == null) {
|
||||
debugPrint('[Updater] Refusing invalid Play Store URL.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await launchUrl(Uri.parse(_url),
|
||||
mode: LaunchMode.externalApplication);
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (e) {
|
||||
debugPrint('[Updater] Failed to open Play Store: $e');
|
||||
return false;
|
||||
@@ -36,13 +112,14 @@ class GitHubUpdater {
|
||||
static String? _cachedDownloadUrl;
|
||||
|
||||
static Future<String?> fetchLatestVersion() async {
|
||||
final url =
|
||||
'https://api.github.com/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest';
|
||||
final apiUri = Uri.https(
|
||||
'api.github.com',
|
||||
'/repos/${AppConfig.githubOwner}/${AppConfig.githubRepo}/releases/latest',
|
||||
);
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse(url),
|
||||
headers: {'Accept': 'application/vnd.github+json'})
|
||||
.get(apiUri, headers: {'Accept': 'application/vnd.github+json'})
|
||||
.timeout(const Duration(seconds: 8));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
@@ -50,38 +127,48 @@ class GitHubUpdater {
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
_cachedDownloadUrl = data['html_url'] as String?;
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('[Updater] Invalid GitHub release payload.');
|
||||
return null;
|
||||
}
|
||||
|
||||
final tag = data['tag_name'] as String? ?? '0.0.0';
|
||||
return tag.startsWith('v') ? tag.substring(1) : tag;
|
||||
final htmlUrl = decoded['html_url'];
|
||||
if (htmlUrl is String &&
|
||||
_trustedHttpsUri(htmlUrl, const {'github.com'}) != null) {
|
||||
_cachedDownloadUrl = htmlUrl;
|
||||
} else {
|
||||
_cachedDownloadUrl = null;
|
||||
}
|
||||
|
||||
final rawTag = decoded['tag_name'];
|
||||
if (rawTag is! String || rawTag.isEmpty || rawTag.length > 64) {
|
||||
debugPrint('[Updater] Invalid release tag.');
|
||||
return null;
|
||||
}
|
||||
|
||||
final normalized = rawTag.startsWith('v') ? rawTag.substring(1) : rawTag;
|
||||
if (_ParsedVersion.tryParse(normalized) == null) {
|
||||
debugPrint('[Updater] Release tag is not a supported version: $rawTag');
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
} catch (e) {
|
||||
debugPrint('[Updater] GitHub API error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isTrustedReleaseUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') {
|
||||
static Future<bool> open() async {
|
||||
final raw = _cachedDownloadUrl ?? AppConfig.githubReleasesUrl;
|
||||
final uri = _trustedHttpsUri(raw, const {'github.com'});
|
||||
if (uri == null) {
|
||||
debugPrint('[Updater] Refusing untrusted GitHub update URL.');
|
||||
return false;
|
||||
}
|
||||
// Host alone is not enough: anyone can host a release on github.com.
|
||||
return uri.path
|
||||
.startsWith('/${AppConfig.githubOwner}/${AppConfig.githubRepo}/');
|
||||
}
|
||||
|
||||
static Future<bool> open() async {
|
||||
final cached = _cachedDownloadUrl;
|
||||
final url = (cached != null && _isTrustedReleaseUrl(cached))
|
||||
? cached
|
||||
: AppConfig.githubReleasesUrl;
|
||||
if (cached != null && url != cached) {
|
||||
debugPrint('[Updater] Rejected untrusted release URL: $cached');
|
||||
}
|
||||
try {
|
||||
return await launchUrl(Uri.parse(url),
|
||||
mode: LaunchMode.externalApplication);
|
||||
return await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (e) {
|
||||
debugPrint('[Updater] Failed to open GitHub: $e');
|
||||
return false;
|
||||
@@ -116,14 +203,13 @@ class Updater {
|
||||
}
|
||||
|
||||
static int _compareVersions(String a, String b) {
|
||||
final aParts = a.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
||||
final bParts = b.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
final diff = (aParts.length > i ? aParts[i] : 0) -
|
||||
(bParts.length > i ? bParts[i] : 0);
|
||||
if (diff != 0) return diff;
|
||||
final left = _ParsedVersion.tryParse(a);
|
||||
final right = _ParsedVersion.tryParse(b);
|
||||
if (left == null || right == null) {
|
||||
// Invalid local metadata should not invent an ordering.
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
return left.compareTo(right);
|
||||
}
|
||||
|
||||
static String _versionLabel(String v, bool isLocal) {
|
||||
@@ -131,15 +217,27 @@ class Updater {
|
||||
return '$prefix$v${isLocal ? ' (installed)' : ''}';
|
||||
}
|
||||
|
||||
static Future<void> check({
|
||||
static Future<void> check(BuildContext context) {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
return checkWithHandles(
|
||||
navigator: navigator,
|
||||
messenger: messenger,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> checkWithHandles({
|
||||
required NavigatorState navigator,
|
||||
required ScaffoldMessengerState messenger,
|
||||
}) async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final localVersion = info.version;
|
||||
if (localVersion.isEmpty) {
|
||||
_showSnackBar(messenger, 'Could not determine app version',
|
||||
isError: true);
|
||||
if (localVersion.isEmpty || _ParsedVersion.tryParse(localVersion) == null) {
|
||||
_showSnackBar(
|
||||
messenger,
|
||||
'Could not determine app version',
|
||||
isError: true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,15 +253,18 @@ class Updater {
|
||||
}
|
||||
|
||||
final source = await installSource;
|
||||
final storeName = source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
||||
final storeName =
|
||||
source == InstallSource.playStore ? 'Play Store' : 'GitHub';
|
||||
if (!navigator.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: navigator.context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.bgCard,
|
||||
title: const Text('Update available',
|
||||
style: TextStyle(color: AppColors.textWhite)),
|
||||
title: const Text(
|
||||
'Update available',
|
||||
style: TextStyle(color: AppColors.textWhite),
|
||||
),
|
||||
content: Text(
|
||||
'${_versionLabel(latestVersion, false)} is available on $storeName.\n'
|
||||
'You have ${_versionLabel(localVersion, true)}.',
|
||||
@@ -172,8 +273,10 @@ class Updater {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Later',
|
||||
style: TextStyle(color: AppColors.slate70)),
|
||||
child: const Text(
|
||||
'Later',
|
||||
style: TextStyle(color: AppColors.slate70),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@@ -182,24 +285,31 @@ class Updater {
|
||||
? PlayStoreUpdater.open()
|
||||
: GitHubUpdater.open();
|
||||
},
|
||||
child: Text('Open $storeName',
|
||||
style: const TextStyle(color: AppColors.accent)),
|
||||
child: Text(
|
||||
'Open $storeName',
|
||||
style: const TextStyle(color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void _showSnackBar(ScaffoldMessengerState messenger, String message,
|
||||
{bool isError = false}) {
|
||||
static void _showSnackBar(
|
||||
ScaffoldMessengerState messenger,
|
||||
String message, {
|
||||
bool isError = false,
|
||||
}) {
|
||||
if (!messenger.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(isError ? Icons.error_outline : Icons.check_circle,
|
||||
color: isError ? AppColors.red : AppColors.green,
|
||||
size: 18),
|
||||
Icon(
|
||||
isError ? Icons.error_outline : Icons.check_circle,
|
||||
color: isError ? AppColors.red : AppColors.green,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(message),
|
||||
],
|
||||
|
||||
+525
-70
@@ -1,8 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.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';
|
||||
|
||||
enum VpnStatus {
|
||||
disconnected,
|
||||
@@ -12,7 +17,21 @@ enum VpnStatus {
|
||||
error,
|
||||
}
|
||||
|
||||
class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
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;
|
||||
|
||||
bool _cancelled = false;
|
||||
VpnStatus _status = VpnStatus.disconnected;
|
||||
VpnStatus get status => _status;
|
||||
@@ -29,7 +48,83 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
bool _serverReachable = false;
|
||||
bool get serverReachable => _serverReachable;
|
||||
|
||||
ConnectionMode _activeMode = ConnectionMode.tun;
|
||||
ConnectionMode get activeMode => _activeMode;
|
||||
|
||||
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';
|
||||
|
||||
bool get canTestActiveLocalSocks =>
|
||||
_status == VpnStatus.connected &&
|
||||
_activeMode == ConnectionMode.proxy &&
|
||||
_lastSecureSocks != null;
|
||||
|
||||
Future<LocalSocksTestResult> testActiveLocalSocks() async {
|
||||
final active = _lastSecureSocks;
|
||||
if (!canTestActiveLocalSocks || active == null) {
|
||||
return const LocalSocksTestResult(
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
message: 'No active authenticated SOCKS5 session.',
|
||||
);
|
||||
}
|
||||
|
||||
return LocalSocksTester.test(
|
||||
host: '127.0.0.1',
|
||||
port: active.port,
|
||||
username: active.username,
|
||||
password: active.password,
|
||||
);
|
||||
}
|
||||
|
||||
Timer? _healthTimer;
|
||||
Timer? _restartTimer;
|
||||
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
||||
|
||||
String? _lastBaseConfig;
|
||||
String _lastRemark = 'Revolt VPN';
|
||||
List<String>? _lastBlockedApps;
|
||||
bool _lastSelectedOnly = false;
|
||||
bool _lastVerifyLocalSocks = false;
|
||||
SecureSocksSession? _lastSecureSocks;
|
||||
bool _restartInProgress = false;
|
||||
bool _userDisconnecting = false;
|
||||
|
||||
late final FlutterVless _vless;
|
||||
bool _initialized = false;
|
||||
@@ -37,7 +132,8 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
Future<void> get ready => _readyCompleter.future;
|
||||
|
||||
VpnConnection() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_activeMode = ConnectionSettings.mode;
|
||||
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
||||
_init();
|
||||
}
|
||||
|
||||
@@ -54,10 +150,16 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
Future<void> _startEngine() async {
|
||||
if (kIsWeb) return;
|
||||
|
||||
await ConnectionSettings.initialize();
|
||||
_activeMode = ConnectionSettings.mode;
|
||||
_activeResilienceMode = ConnectionSettings.resilienceMode;
|
||||
|
||||
_vless = FlutterVless(
|
||||
onStatusChanged: (status) {
|
||||
debugPrint('[VPN] Status: state=${status.state} '
|
||||
'connection=${status.connectionState.name}');
|
||||
debugPrint(
|
||||
'[VPN] Status: state=${status.state} '
|
||||
'connection=${status.connectionState.name}',
|
||||
);
|
||||
_mapStatus(status);
|
||||
},
|
||||
);
|
||||
@@ -66,14 +168,30 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
await _vless.initializeVless(
|
||||
providerBundleIdentifier: 'com.paladinvpn.app',
|
||||
notificationIconResourceType: 'drawable',
|
||||
notificationIconResourceName: 'notification_icon',
|
||||
notificationIconResourceName: 'notification_status_icon',
|
||||
);
|
||||
_initialized = true;
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
||||
}
|
||||
|
||||
_startHealthPolling();
|
||||
_networkSubscription = NetworkMonitor.changes.listen(
|
||||
(snapshot) {
|
||||
// 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) {
|
||||
debugPrint('[VPN] Network monitor error: $error');
|
||||
},
|
||||
);
|
||||
|
||||
unawaited(_checkHealth());
|
||||
_healthTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
unawaited(_checkHealth());
|
||||
});
|
||||
|
||||
try {
|
||||
final coreVersion = await _vless.getCoreVersion();
|
||||
@@ -81,53 +199,117 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
|
||||
final delay = await _vless.getConnectedServerDelay();
|
||||
if (delay > 0) {
|
||||
_lastHealthLatencyMs = delay;
|
||||
_isStartupRestoration = true;
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
_restoreRoutingPresentationFromSettings();
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _restoreRoutingPresentationFromSettings() {
|
||||
final routingMode = ConnectionSettings.routingMode;
|
||||
final packages = ConnectionSettings.appPackages;
|
||||
|
||||
_lastBlockedApps = null;
|
||||
_lastSelectedOnly = false;
|
||||
_lastVerifyLocalSocks = _activeMode == ConnectionMode.proxy;
|
||||
_selectedRoutingActive = false;
|
||||
|
||||
if (routingMode == AppRoutingMode.selected) {
|
||||
_lastSelectedOnly = packages.isNotEmpty;
|
||||
_selectedRoutingActive = packages.isNotEmpty;
|
||||
return;
|
||||
}
|
||||
|
||||
if (routingMode == AppRoutingMode.exclude && packages.isNotEmpty) {
|
||||
_lastBlockedApps = List.of(packages);
|
||||
}
|
||||
}
|
||||
|
||||
String get _connectedLabel {
|
||||
switch (_activeMode) {
|
||||
case ConnectionMode.proxy:
|
||||
return _selectedRoutingActive
|
||||
? 'SOCKS5 · selected apps secured'
|
||||
: 'SOCKS5 gateway active';
|
||||
case ConnectionMode.tun:
|
||||
return _selectedRoutingActive ? 'Selected apps secured' : 'Secured';
|
||||
}
|
||||
}
|
||||
|
||||
void _mapStatus(VlessStatus status) {
|
||||
switch (status.connectionState) {
|
||||
case VlessConnectionState.connected:
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
break;
|
||||
|
||||
case VlessConnectionState.disconnected:
|
||||
_isStartupRestoration = false;
|
||||
// Repeated DISCONNECTED broadcasts during an Extreme recovery debounce
|
||||
// must not clear the config snapshot the pending recovery needs.
|
||||
if (_restartInProgress || (_restartTimer?.isActive ?? false)) return;
|
||||
|
||||
final canRecover = !_userDisconnecting &&
|
||||
_activeResilienceMode == ResilienceMode.extreme &&
|
||||
_lastBaseConfig != null &&
|
||||
_status == VpnStatus.connected;
|
||||
if (canRecover) {
|
||||
_scheduleRuntimeRestart('Runtime disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
break;
|
||||
|
||||
case VlessConnectionState.connecting:
|
||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||
if (!_restartInProgress) {
|
||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||
}
|
||||
break;
|
||||
|
||||
case VlessConnectionState.disconnecting:
|
||||
_isStartupRestoration = false;
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
if (!_restartInProgress) {
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
}
|
||||
break;
|
||||
|
||||
case VlessConnectionState.unknown:
|
||||
if (_status != VpnStatus.connected &&
|
||||
_status != VpnStatus.disconnected) {
|
||||
_status != VpnStatus.disconnected &&
|
||||
!_restartInProgress) {
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect ────────────────────────────────────────────────────────
|
||||
//
|
||||
// 1. Direct HTTPS to the public API → get per-user VLESS URL
|
||||
// 2. Start tunnel directly with that URL (no bootstrap, no switch)
|
||||
//
|
||||
// The only hardcoded data in the APK is the Reality public key and
|
||||
// shortId (needed for every Reality handshake — unavoidable).
|
||||
// The tunnel UUID is per-user, assigned by Hivemind via AdMob SSV.
|
||||
|
||||
Future<bool> connect({
|
||||
bool skipAdBypass = false,
|
||||
}) async {
|
||||
Future<bool> connect({bool skipAdBypass = false}) async {
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_cancelled = false;
|
||||
_userDisconnecting = false;
|
||||
_restartTimer?.cancel();
|
||||
_reconnectCount = 0;
|
||||
_lastRecoveryReason = null;
|
||||
_lastHealthLatencyMs = null;
|
||||
_selectedRoutingActive = false;
|
||||
|
||||
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.';
|
||||
@@ -135,6 +317,8 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
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) {
|
||||
final ok = await _vless.requestPermission();
|
||||
if (!ok) {
|
||||
@@ -144,7 +328,15 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.connecting, 'Establishing secure channel…');
|
||||
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…',
|
||||
};
|
||||
_setStatus(VpnStatus.connecting, startingMessage);
|
||||
_errorMessage = null;
|
||||
|
||||
if (kIsWeb) {
|
||||
@@ -161,7 +353,9 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
skipAdBypass: skipAdBypass,
|
||||
onAttempt: (attempt, total) {
|
||||
_setStatus(
|
||||
VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
||||
VpnStatus.connecting,
|
||||
'Contacting server ($attempt/$total)…',
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -173,7 +367,7 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
'The server did not respond in time.\nCheck your connection and try again.';
|
||||
_setStatus(VpnStatus.error, 'Server unreachable');
|
||||
} else {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
_errorMessage = raw;
|
||||
_setStatus(VpnStatus.error, 'Config fetch error');
|
||||
}
|
||||
return false;
|
||||
@@ -184,31 +378,299 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return false;
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.connecting, 'Securing connection…');
|
||||
_setStatus(VpnStatus.connecting, 'Starting secure route…');
|
||||
|
||||
try {
|
||||
final parsed = FlutterVless.parse(realUrl);
|
||||
final baseConfig = parsed.getFullConfiguration();
|
||||
final remark = parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN';
|
||||
final secureSocks = await SecureSocksSession.create(baseConfig);
|
||||
|
||||
await _vless.startVless(
|
||||
remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN',
|
||||
config: parsed.getFullConfiguration(),
|
||||
// Set this before the native callback can report CONNECTED so the first
|
||||
// visible label already reflects Selected only accurately.
|
||||
_selectedRoutingActive = routingPlan.selectedOnly;
|
||||
|
||||
await _startRuntime(
|
||||
config: secureSocks.configJson,
|
||||
remark: remark,
|
||||
blockedApps: routingPlan.blockedApps,
|
||||
);
|
||||
|
||||
_setStatus(VpnStatus.connecting, 'Waiting for VPN interface…');
|
||||
if (!await _waitForNativeConnected()) {
|
||||
throw StateError('VPN runtime did not report CONNECTED');
|
||||
}
|
||||
|
||||
if (routingPlan.verifyLocalSocks) {
|
||||
_setStatus(VpnStatus.connecting, 'Checking Local SOCKS5…');
|
||||
if (!await _waitForLocalSocksListener(secureSocks)) {
|
||||
throw StateError('Local SOCKS5 listener did not become ready');
|
||||
}
|
||||
}
|
||||
|
||||
_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;
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Tunnel start error: $e');
|
||||
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
||||
await _stopRuntime();
|
||||
_clearRuntimeSnapshot();
|
||||
_errorMessage = _activeMode == ConnectionMode.proxy
|
||||
? 'SOCKS5 gateway failed to start.\nTry reconnecting.'
|
||||
: 'Connection failed to start.\nTry reconnecting.';
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
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,
|
||||
}) async {
|
||||
await _vless.startVless(
|
||||
remark: remark,
|
||||
config: config,
|
||||
blockedApps: blockedApps,
|
||||
proxyOnly: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _waitForLocalSocksListener([
|
||||
SecureSocksSession? session,
|
||||
]) async {
|
||||
final active = session ?? _lastSecureSocks;
|
||||
if (active == null) return false;
|
||||
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
final result = await LocalSocksTester.testListener(
|
||||
host: '127.0.0.1',
|
||||
port: active.port,
|
||||
username: active.username,
|
||||
password: active.password,
|
||||
);
|
||||
if (result.ok) {
|
||||
_lastHealthLatencyMs = result.latencyMs;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
if (attempt < 3) {
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _waitForNativeConnected() async {
|
||||
// startVless() queues the Android foreground service. Wait for the native
|
||||
// service/core broadcast rather than treating MethodChannel completion as
|
||||
// proof that a TUN interface was established.
|
||||
for (var attempt = 0; attempt < 24; attempt++) {
|
||||
if (_status == VpnStatus.connected) return true;
|
||||
if (_status == VpnStatus.error) return false;
|
||||
if (_cancelled || _userDisconnecting) return false;
|
||||
await Future.delayed(const Duration(milliseconds: 250));
|
||||
}
|
||||
return _status == VpnStatus.connected;
|
||||
}
|
||||
|
||||
void _scheduleRuntimeRestart(String reason) {
|
||||
if (_activeResilienceMode != ResilienceMode.extreme ||
|
||||
_restartInProgress ||
|
||||
_userDisconnecting ||
|
||||
_lastBaseConfig == null ||
|
||||
(_restartTimer?.isActive ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_lastRecoveryReason = reason;
|
||||
_setStatus(VpnStatus.connecting, 'Reconnecting…');
|
||||
_restartTimer = Timer(const Duration(milliseconds: 1200), () {
|
||||
unawaited(_restartActiveRuntime(reason));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _restartActiveRuntime(String reason) async {
|
||||
if (_restartInProgress || _userDisconnecting || _lastBaseConfig == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_restartInProgress = true;
|
||||
Object? lastError;
|
||||
|
||||
try {
|
||||
for (var attempt = 1; attempt <= _maxExtremeRecoveryAttempts; attempt++) {
|
||||
if (_userDisconnecting || _lastBaseConfig == null) return;
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'[VPN] Extreme recovery attempt '
|
||||
'$attempt/$_maxExtremeRecoveryAttempts: $reason',
|
||||
);
|
||||
|
||||
await _stopRuntime();
|
||||
if (_userDisconnecting) return;
|
||||
|
||||
await Future.delayed(
|
||||
Duration(milliseconds: attempt == 1 ? 600 : 900),
|
||||
);
|
||||
if (_userDisconnecting || _lastBaseConfig == null) return;
|
||||
|
||||
await _startRuntime(
|
||||
config: _lastBaseConfig!,
|
||||
remark: _lastRemark,
|
||||
blockedApps: _lastBlockedApps,
|
||||
);
|
||||
|
||||
if (!await _waitForNativeConnected()) {
|
||||
throw StateError('VPN runtime did not recover');
|
||||
}
|
||||
if (_lastVerifyLocalSocks && !(await _waitForLocalSocksListener())) {
|
||||
throw StateError('Local SOCKS5 listener did not recover');
|
||||
}
|
||||
|
||||
if (_userDisconnecting) return;
|
||||
|
||||
_reconnectCount++;
|
||||
_lastRecoveryReason = reason;
|
||||
_selectedRoutingActive = _lastSelectedOnly;
|
||||
_errorMessage = null;
|
||||
_setStatus(VpnStatus.connected, _connectedLabel);
|
||||
return;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
debugPrint(
|
||||
'[VPN] Extreme recovery attempt '
|
||||
'$attempt/$_maxExtremeRecoveryAttempts failed: $e',
|
||||
);
|
||||
await _stopRuntime();
|
||||
if (_userDisconnecting) return;
|
||||
|
||||
if (attempt < _maxExtremeRecoveryAttempts) {
|
||||
_setStatus(
|
||||
VpnStatus.connecting,
|
||||
'Reconnecting… (${attempt + 1}/$_maxExtremeRecoveryAttempts)',
|
||||
);
|
||||
await Future.delayed(const Duration(milliseconds: 700));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_userDisconnecting) return;
|
||||
|
||||
debugPrint('[VPN] Extreme recovery exhausted: $lastError');
|
||||
_clearRuntimeSnapshot();
|
||||
_errorMessage = 'Could not restore the connection.';
|
||||
_setStatus(VpnStatus.error, 'Reconnect failed');
|
||||
} finally {
|
||||
_restartInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopRuntime() async {
|
||||
try {
|
||||
await _vless.stopVless().timeout(const Duration(seconds: 5));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_cancelled = true;
|
||||
_userDisconnecting = true;
|
||||
_restartTimer?.cancel();
|
||||
HivemindService.cancel();
|
||||
|
||||
if (_status == VpnStatus.disconnected ||
|
||||
_status == VpnStatus.disconnecting) {
|
||||
_clearRuntimeSnapshot();
|
||||
_userDisconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -216,7 +678,9 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
|
||||
if (kIsWeb) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
_userDisconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -229,65 +693,56 @@ class VpnConnection extends ChangeNotifier with WidgetsBindingObserver {
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] VLESS stop error: $e');
|
||||
_errorMessage = 'VPN shutdown error.\nPlease restart the app.';
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.error, 'Shutdown failed');
|
||||
_userDisconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
debugPrint('[VPN] stopVless() timed out after 5 s — '
|
||||
'tunnel may still be active.');
|
||||
_errorMessage = 'VPN did not shut down cleanly.\n'
|
||||
'Please restart the app.';
|
||||
debugPrint('[VPN] stopVless() timed out after 5 s.');
|
||||
_errorMessage = 'VPN did not shut down cleanly.\nPlease restart the app.';
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.error, 'Shutdown failed');
|
||||
_userDisconnecting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_errorMessage = null;
|
||||
_clearRuntimeSnapshot();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
_userDisconnecting = false;
|
||||
}
|
||||
|
||||
void _setStatus(VpnStatus s, String msg) {
|
||||
_status = s;
|
||||
_statusMessage = msg;
|
||||
void _clearRuntimeSnapshot() {
|
||||
_lastBaseConfig = null;
|
||||
_lastBlockedApps = null;
|
||||
_lastSelectedOnly = false;
|
||||
_lastVerifyLocalSocks = false;
|
||||
_lastSecureSocks = null;
|
||||
_selectedRoutingActive = false;
|
||||
_lastHealthLatencyMs = null;
|
||||
_isStartupRestoration = false;
|
||||
}
|
||||
|
||||
void _setStatus(VpnStatus status, String message) {
|
||||
_status = status;
|
||||
_statusMessage = message;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startHealthPolling() {
|
||||
_healthTimer?.cancel();
|
||||
_checkHealthIfIdle();
|
||||
_healthTimer = Timer.periodic(
|
||||
const Duration(seconds: 30), (_) => _checkHealthIfIdle());
|
||||
}
|
||||
|
||||
Future<void> _checkHealthIfIdle() async {
|
||||
// A live tunnel already proves reachability.
|
||||
if (_status == VpnStatus.connected) return;
|
||||
await _checkHealth();
|
||||
}
|
||||
|
||||
Future<void> _checkHealth() async {
|
||||
_serverReachable = await HivemindService.checkHealth();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_healthTimer?.cancel();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_startHealthPolling();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_healthTimer?.cancel();
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
try {
|
||||
_vless.stopVless();
|
||||
} catch (_) {}
|
||||
}
|
||||
_restartTimer?.cancel();
|
||||
_networkSubscription?.cancel();
|
||||
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
||||
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
+23
-15
@@ -1,18 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/components/app_haptic_surface.dart';
|
||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/screens/intro.dart';
|
||||
void main() {
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
await HapticSettings.initialize();
|
||||
await ConnectionSettings.initialize();
|
||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
@@ -40,23 +46,25 @@ class ReVoltApp extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// lazy: false — the native VLESS engine takes real time to start, and
|
||||
// providers are lazy by default, so it used to begin initialising only
|
||||
// once MainScreen first read it, i.e. *after* the loading screen was
|
||||
// gone. Constructing it eagerly moves that work under the intro, which
|
||||
// now awaits VpnConnection.ready before navigating.
|
||||
ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false),
|
||||
ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
|
||||
create: (ctx) => SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
|
||||
update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn),
|
||||
create: (ctx) =>
|
||||
SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
|
||||
update: (_, vpn, prev) =>
|
||||
prev ?? SessionTimer(vpnConnection: vpn),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => AdManager()),
|
||||
ChangeNotifierProvider(create: (_) => ServerList()..init(), lazy: false),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => ServerList()..init(),
|
||||
lazy: false,
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'ReVolt VPN',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: _buildDarkTheme(),
|
||||
builder: (context, child) =>
|
||||
AppHapticSurface(child: child ?? const SizedBox.shrink()),
|
||||
home: const IntroScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,11 +2,11 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/screens/main_screen.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/rain.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/lightning.dart';
|
||||
import 'package:revoltvpn/screens/settings/in_settings/haptic.dart';
|
||||
|
||||
class IntroScreen extends StatefulWidget {
|
||||
const IntroScreen({super.key});
|
||||
@@ -45,10 +45,12 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
final elapsed = Stopwatch()..start();
|
||||
final vpn = context.read<VpnConnection>();
|
||||
|
||||
// The overlays read these in initState, so they must land before
|
||||
// MainScreen mounts.
|
||||
final settingsPrefs = Future.wait(
|
||||
[loadRainPref(), loadLightningPref(), loadHapticPref()]);
|
||||
// The overlays and haptic layer read these before MainScreen mounts.
|
||||
final settingsPrefs = Future.wait([
|
||||
loadRainPref(),
|
||||
loadLightningPref(),
|
||||
HapticSettings.initialize(),
|
||||
]);
|
||||
|
||||
try {
|
||||
await vpn.ready.timeout(_bootTimeout);
|
||||
@@ -79,7 +81,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
void _navigateIfReady() {
|
||||
if (!mounted || _navigated || !_bootComplete) return;
|
||||
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) {
|
||||
return; // resume will call back in
|
||||
return;
|
||||
}
|
||||
_navigated = true;
|
||||
|
||||
@@ -101,7 +103,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
body: Column(
|
||||
children: [
|
||||
const Spacer(flex: 7),
|
||||
// Image slightly below center
|
||||
Center(
|
||||
child: Image.asset(
|
||||
'assets/brand.png',
|
||||
@@ -116,7 +117,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
),
|
||||
// Spinner + text paired together below the image
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(
|
||||
width: 40,
|
||||
@@ -137,7 +137,6 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Says what the spinner is actually waiting on.
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Text(
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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/local_socks_tester.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
|
||||
class ConnectionModeTile extends StatefulWidget {
|
||||
final ValueChanged<ConnectionMode>? onChanged;
|
||||
|
||||
const ConnectionModeTile({super.key, this.onChanged});
|
||||
|
||||
@override
|
||||
State<ConnectionModeTile> createState() => _ConnectionModeTileState();
|
||||
}
|
||||
|
||||
class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||
ConnectionMode _mode = ConnectionSettings.mode;
|
||||
bool _testingLocalSocks = false;
|
||||
LocalSocksTestResult? _lastSocksTest;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await ConnectionSettings.initialize();
|
||||
if (mounted) setState(() => _mode = ConnectionSettings.mode);
|
||||
}
|
||||
|
||||
bool _isTunnelBusy(VpnStatus status) {
|
||||
return status == VpnStatus.connected ||
|
||||
status == VpnStatus.connecting ||
|
||||
status == VpnStatus.disconnecting;
|
||||
}
|
||||
|
||||
Future<void> _changeMode(ConnectionMode? next) async {
|
||||
if (next == null || next == _mode) return;
|
||||
|
||||
final vpn = context.read<VpnConnection>();
|
||||
if (_isTunnelBusy(vpn.status)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Disconnect before changing connection mode.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ConnectionSettings.setMode(next);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_mode = next;
|
||||
_lastSocksTest = null;
|
||||
});
|
||||
widget.onChanged?.call(next);
|
||||
}
|
||||
|
||||
Future<void> _testLocalSocks() async {
|
||||
if (_testingLocalSocks) return;
|
||||
final vpn = context.read<VpnConnection>();
|
||||
|
||||
if (vpn.status != VpnStatus.connected || !vpn.canTestActiveLocalSocks) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Connect with SOCKS5 first, then run the local test.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _testingLocalSocks = true);
|
||||
final result = await vpn.testActiveLocalSocks();
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_testingLocalSocks = false;
|
||||
_lastSocksTest = result;
|
||||
});
|
||||
|
||||
final latency = result.latencyMs == null ? '' : ' (${result.latencyMs} ms)';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${result.message}$latency')),
|
||||
);
|
||||
}
|
||||
|
||||
String get _subtitle {
|
||||
switch (_mode) {
|
||||
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.';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Connection mode',
|
||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||
),
|
||||
subtitle: Text(
|
||||
_subtitle,
|
||||
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||
),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<ConnectionMode>(
|
||||
value: _mode,
|
||||
dropdownColor: AppColors.bgCard,
|
||||
onChanged: _changeMode,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ConnectionMode.tun,
|
||||
child: Text('TUN'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ConnectionMode.proxy,
|
||||
child: Text('SOCKS5'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_mode == ConnectionMode.proxy) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Text(
|
||||
'Secure Local SOCKS5 uses a private port and new credentials for every VPN session.',
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _testingLocalSocks ? null : _testLocalSocks,
|
||||
icon: _testingLocalSocks
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.network_check),
|
||||
label: Text(
|
||||
_testingLocalSocks ? 'Testing…' : 'Test Local SOCKS',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_lastSocksTest != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: Text(
|
||||
_lastSocksTest!.ok
|
||||
? 'Last test: OK${_lastSocksTest!.latencyMs == null ? '' : ' · ${_lastSocksTest!.latencyMs} ms'}'
|
||||
: 'Last test: Failed · ${_lastSocksTest!.message}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
// A plain bool, not a ValueNotifier: this is only read at the moment of a tap,
|
||||
// so nothing has to react to it changing. Default: false.
|
||||
bool hapticEnabled = false;
|
||||
|
||||
/// Hydrates [hapticEnabled] from disk. Called at boot — the toggle tile below
|
||||
/// only mounts once Settings is opened.
|
||||
Future<void> loadHapticPref() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
hapticEnabled = prefs.getBool('haptic_feedback_enabled') ?? false;
|
||||
}
|
||||
|
||||
/// Haptic calls are deliberately best-effort: a vibration failure must never
|
||||
/// block or fail a VPN connect/disconnect. `mediumImpact` is used instead of
|
||||
/// `lightImpact` — the latter is imperceptible on many Android devices.
|
||||
class Haptics {
|
||||
Haptics._();
|
||||
|
||||
static Future<void> connectionChanged() async {
|
||||
if (!hapticEnabled) return;
|
||||
try {
|
||||
await HapticFeedback.mediumImpact();
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Feedback unavailable: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> previewEnabled() async {
|
||||
try {
|
||||
await HapticFeedback.selectionClick();
|
||||
} catch (e) {
|
||||
debugPrint('[Haptics] Preview unavailable: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
import 'package:revoltvpn/logic/haptic_settings.dart';
|
||||
|
||||
class HapticFeedbackTile extends StatefulWidget {
|
||||
const HapticFeedbackTile({super.key});
|
||||
@@ -47,7 +10,8 @@ class HapticFeedbackTile extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||
bool _on = false;
|
||||
bool _on = HapticSettings.enabled;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -56,18 +20,24 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await loadHapticPref();
|
||||
if (mounted) setState(() => _on = hapticEnabled);
|
||||
await HapticSettings.initialize();
|
||||
if (mounted) setState(() => _on = HapticSettings.enabled);
|
||||
}
|
||||
|
||||
// Writes to SharedPreferences AND updates the live bool so the connect
|
||||
// button sees the change immediately — no restart needed.
|
||||
Future<void> _toggle(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('haptic_feedback_enabled', value);
|
||||
hapticEnabled = value;
|
||||
if (value) unawaited(Haptics.previewEnabled());
|
||||
setState(() => _on = value);
|
||||
if (_saving) return;
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_on = value;
|
||||
});
|
||||
|
||||
try {
|
||||
await HapticSettings.setEnabled(value);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _on = HapticSettings.enabled);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -78,7 +48,7 @@ class _HapticFeedbackTileState extends State<HapticFeedbackTile> {
|
||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||
),
|
||||
value: _on,
|
||||
onChanged: _toggle,
|
||||
onChanged: _saving ? null : _toggle,
|
||||
activeTrackColor: AppColors.accent.withAlpha(80),
|
||||
activeThumbColor: AppColors.accent,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/connection_settings.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
|
||||
class ResilienceModeTile extends StatefulWidget {
|
||||
const ResilienceModeTile({super.key});
|
||||
|
||||
@override
|
||||
State<ResilienceModeTile> createState() => _ResilienceModeTileState();
|
||||
}
|
||||
|
||||
class _ResilienceModeTileState extends State<ResilienceModeTile> {
|
||||
ResilienceMode _mode = ConnectionSettings.resilienceMode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await ConnectionSettings.initialize();
|
||||
if (mounted) setState(() => _mode = ConnectionSettings.resilienceMode);
|
||||
}
|
||||
|
||||
bool _vpnBusy() {
|
||||
final status = context.read<VpnConnection>().status;
|
||||
return status == VpnStatus.connected ||
|
||||
status == VpnStatus.connecting ||
|
||||
status == VpnStatus.disconnecting;
|
||||
}
|
||||
|
||||
Future<void> _change(ResilienceMode? next) async {
|
||||
if (next == null || next == _mode) return;
|
||||
if (_vpnBusy()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Disconnect before changing resilience mode.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ConnectionSettings.setResilienceMode(next);
|
||||
if (mounted) setState(() => _mode = next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final extreme = _mode == ResilienceMode.extreme;
|
||||
|
||||
return 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',
|
||||
style: TextStyle(color: AppColors.textWhite, fontSize: 15),
|
||||
),
|
||||
subtitle: Text(
|
||||
extreme
|
||||
? 'Extreme: keeps the exact server transport unchanged and makes up to two controlled recovery attempts after each real Xray disconnect.$active'
|
||||
: 'Standard: keeps the exact server transport unchanged and does not force health-check reconnects.$active',
|
||||
style: const TextStyle(color: AppColors.textDim, fontSize: 12),
|
||||
),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<ResilienceMode>(
|
||||
value: _mode,
|
||||
dropdownColor: AppColors.bgCard,
|
||||
onChanged: _change,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ResilienceMode.standard,
|
||||
child: Text('Standard'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ResilienceMode.extreme,
|
||||
child: Text('Extreme'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,34 @@
|
||||
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/rain.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';
|
||||
|
||||
// ── lib/screens/settings/settings_screen.dart ────────────────────────────────
|
||||
// Settings shell — full-screen page reached from the sidebar.
|
||||
//
|
||||
// Folder layout:
|
||||
// lib/screens/settings/ ← this folder: settings family
|
||||
// settings_screen.dart ← shell: Scaffold + AppBar + ListView (this file)
|
||||
// *_tile.dart ← one file per setting feature (add below)
|
||||
// lib/components/sidebar_contents/
|
||||
// settings.dart ← thin sidebar row that navigates here
|
||||
//
|
||||
// To add a setting:
|
||||
// 1. Create a new file in this folder, e.g. appearance_tile.dart
|
||||
// 2. Import it below
|
||||
// 3. Append it to the children list
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
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(
|
||||
@@ -43,12 +50,15 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: const [
|
||||
HapticFeedbackTile(),
|
||||
RainToggleTile(),
|
||||
LightningToggleTile(),
|
||||
// ── Add setting tiles here ────────────────────────────────────────
|
||||
// Each tile imports from its own file in this folder.
|
||||
children: [
|
||||
ConnectionModeTile(
|
||||
onChanged: (mode) => setState(() => _mode = mode),
|
||||
),
|
||||
const ResilienceModeTile(),
|
||||
AppRoutingTile(connectionMode: _mode),
|
||||
const HapticFeedbackTile(),
|
||||
const RainToggleTile(),
|
||||
const LightningToggleTile(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user