User-Agent spoofed to Chrome on Android (was Dart/3.x). Domain renamed to userevolt.app. Branding to Revolt VPN. Removed duplicate notification, patched flutter_vless for custom icon + dark background. Merged 5 disconnect paths into single idempotent gate, fixed stopVless timeout silently lying about state. Removed kDebugMode guard from AdMob bypass.
271 lines
7.7 KiB
Dart
271 lines
7.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_vless/flutter_vless.dart';
|
|
import 'package:revoltvpn/logic/hivemind_service.dart';
|
|
|
|
enum VpnStatus {
|
|
disconnected,
|
|
connecting,
|
|
connected,
|
|
disconnecting,
|
|
error,
|
|
}
|
|
|
|
class VpnConnection extends ChangeNotifier {
|
|
bool _cancelled = false;
|
|
VpnStatus _status = VpnStatus.disconnected;
|
|
VpnStatus get status => _status;
|
|
|
|
String _statusMessage = 'Tap to connect';
|
|
String get statusMessage => _statusMessage;
|
|
|
|
String? _errorMessage;
|
|
String? get errorMessage => _errorMessage;
|
|
|
|
bool _isStartupRestoration = false;
|
|
bool get isStartupRestoration => _isStartupRestoration;
|
|
|
|
bool _serverReachable = false;
|
|
bool get serverReachable => _serverReachable;
|
|
|
|
Timer? _healthTimer;
|
|
|
|
late final FlutterVless _vless;
|
|
bool _initialized = false;
|
|
|
|
VpnConnection() {
|
|
_init();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
if (kIsWeb) return;
|
|
|
|
_vless = FlutterVless(
|
|
onStatusChanged: (status) {
|
|
debugPrint('[VPN] Status: state=${status.state} '
|
|
'connection=${status.connectionState.name}');
|
|
_mapStatus(status);
|
|
},
|
|
);
|
|
|
|
try {
|
|
await _vless.initializeVless(
|
|
providerBundleIdentifier: 'com.paladinvpn.app',
|
|
notificationIconResourceType: 'drawable',
|
|
notificationIconResourceName: 'notification_icon',
|
|
);
|
|
_initialized = true;
|
|
} catch (e) {
|
|
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
|
}
|
|
|
|
_checkHealth();
|
|
_healthTimer =
|
|
Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth());
|
|
|
|
try {
|
|
final coreVersion = await _vless.getCoreVersion();
|
|
debugPrint('[VPN] Xray core version: $coreVersion');
|
|
|
|
final delay = await _vless.getConnectedServerDelay();
|
|
if (delay > 0) {
|
|
_isStartupRestoration = true;
|
|
_setStatus(VpnStatus.connected, 'Secured');
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
void _mapStatus(VlessStatus status) {
|
|
switch (status.connectionState) {
|
|
case VlessConnectionState.connected:
|
|
_setStatus(VpnStatus.connected, 'Secured');
|
|
break;
|
|
case VlessConnectionState.disconnected:
|
|
_isStartupRestoration = false;
|
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
|
break;
|
|
case VlessConnectionState.connecting:
|
|
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
|
break;
|
|
case VlessConnectionState.disconnecting:
|
|
_isStartupRestoration = false;
|
|
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
|
break;
|
|
case VlessConnectionState.unknown:
|
|
if (_status != VpnStatus.connected &&
|
|
_status != VpnStatus.disconnected) {
|
|
_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 {
|
|
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
|
return false;
|
|
}
|
|
_cancelled = false;
|
|
|
|
if (!kIsWeb && !_initialized) {
|
|
_errorMessage = 'VPN service unavailable.';
|
|
_setStatus(VpnStatus.error, 'Service unavailable');
|
|
return false;
|
|
}
|
|
|
|
if (!kIsWeb) {
|
|
final ok = await _vless.requestPermission();
|
|
if (!ok) {
|
|
_errorMessage = 'VPN permission denied.';
|
|
_setStatus(VpnStatus.error, 'Permission required');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_setStatus(VpnStatus.connecting, 'Establishing secure channel…');
|
|
_errorMessage = null;
|
|
|
|
if (kIsWeb) {
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
_setStatus(VpnStatus.connected, 'Secured (dev mode)');
|
|
return true;
|
|
}
|
|
|
|
_setStatus(VpnStatus.connecting, 'Fetching config…');
|
|
|
|
String realUrl;
|
|
try {
|
|
realUrl = await HivemindService.fetchConfigDirectly(
|
|
skipAdBypass: skipAdBypass,
|
|
onAttempt: (attempt, total) {
|
|
_setStatus(
|
|
VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
|
},
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[VPN] Config fetch error: $e');
|
|
final raw = e.toString().replaceAll('Exception: ', '');
|
|
if (raw.contains('Cancelled')) return false;
|
|
if (raw.contains('timed out') || raw.contains('Session not activated')) {
|
|
_errorMessage =
|
|
'The server did not respond in time.\nCheck your connection and try again.';
|
|
_setStatus(VpnStatus.error, 'Server unreachable');
|
|
} else {
|
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
|
_setStatus(VpnStatus.error, 'Config fetch error');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (_cancelled) {
|
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
|
return false;
|
|
}
|
|
|
|
_setStatus(VpnStatus.connecting, 'Securing connection…');
|
|
|
|
try {
|
|
final parsed = FlutterVless.parse(realUrl);
|
|
final configJson =
|
|
jsonDecode(parsed.getFullConfiguration()) as Map<String, dynamic>;
|
|
|
|
// Inject HTTP inbound for tunnel-internal API calls.
|
|
final inbounds = (configJson['inbounds'] as List?) ?? [];
|
|
inbounds.add({
|
|
'tag': 'http-proxy',
|
|
'port': 10808,
|
|
'listen': '127.0.0.1',
|
|
'protocol': 'http',
|
|
});
|
|
configJson['inbounds'] = inbounds;
|
|
|
|
await _vless.startVless(
|
|
remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN',
|
|
config: jsonEncode(configJson),
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[VPN] Tunnel start error: $e');
|
|
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
|
_setStatus(VpnStatus.error, 'Connection failed');
|
|
return false;
|
|
}
|
|
|
|
_setStatus(VpnStatus.connected, 'Secured');
|
|
return true;
|
|
}
|
|
|
|
Future<void> disconnect() async {
|
|
_cancelled = true;
|
|
HivemindService.cancel();
|
|
if (_status == VpnStatus.disconnected ||
|
|
_status == VpnStatus.disconnecting) {
|
|
return;
|
|
}
|
|
|
|
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
|
|
|
if (kIsWeb) {
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
|
return;
|
|
}
|
|
|
|
bool timedOut = false;
|
|
try {
|
|
await _vless.stopVless().timeout(
|
|
const Duration(seconds: 5),
|
|
onTimeout: () => timedOut = true,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('[VPN] VLESS stop error: $e');
|
|
_errorMessage = 'VPN shutdown error.\nPlease restart the app.';
|
|
_setStatus(VpnStatus.error, 'Shutdown failed');
|
|
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.';
|
|
_setStatus(VpnStatus.error, 'Shutdown failed');
|
|
return;
|
|
}
|
|
|
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
|
}
|
|
|
|
void _setStatus(VpnStatus s, String msg) {
|
|
_status = s;
|
|
_statusMessage = msg;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _checkHealth() async {
|
|
_serverReachable = await HivemindService.checkHealth();
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_healthTimer?.cancel();
|
|
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
|
try {
|
|
_vless.stopVless();
|
|
} catch (_) {}
|
|
}
|
|
super.dispose();
|
|
}
|
|
}
|