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:
Naveax committed 2026-08-31 19:31:12 +03:00
1 parent 371db8da9f
commit 8ead3ff249
30 files changed
+3959 -484

No files matched your search

+115
View File
@@ -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;
}
}
+7 -1
View File
@@ -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);
+84
View File
@@ -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
View File
@@ -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;
}
}
+48
View File
@@ -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;
}
}
+274
View File
@@ -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();
}
+42
View File
@@ -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>),
);
}
}
+89
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}