v3.2.0 — DPI hardening, notification consolidation, disconnect refactor
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.
This commit is contained in:
1 parent
cf50c3a01b
commit
54f949ca8e
20 files changed
+552
-402
No files matched your search
@@ -64,8 +64,7 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
|
||||
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
|
||||
_busy = false;
|
||||
timer.stop();
|
||||
await vpn.disconnect();
|
||||
await timer.disconnect();
|
||||
if (hapticEnabled) HapticFeedback.lightImpact();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
class VpnNotificationManager {
|
||||
static final FlutterLocalNotificationsPlugin _plugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
static bool _initialized = false;
|
||||
static String? _lastTimeLeft;
|
||||
static String? _lastSpeedKbps;
|
||||
|
||||
// Android channels are immutable — bump this if settings change.
|
||||
static const _channelId = 'revolt_vpn_v4';
|
||||
static const _channelName = 'Revolt VPN';
|
||||
static const _notifId = 888;
|
||||
|
||||
static Future<void> init() async {
|
||||
if (_initialized) return;
|
||||
@@ -15,7 +23,6 @@ class VpnNotificationManager {
|
||||
|
||||
await _plugin.initialize(initSettings);
|
||||
|
||||
// Request POST_NOTIFICATIONS permission on Android 13+
|
||||
if (Platform.isAndroid) {
|
||||
final androidPlugin = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
@@ -26,77 +33,61 @@ class VpnNotificationManager {
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static String? _lastTimeLeft;
|
||||
static String? _lastSpeedKbps;
|
||||
|
||||
static Future<void> showOrUpdateStatus({
|
||||
required String timeLeft,
|
||||
required String speedKbps,
|
||||
}) async {
|
||||
// Skip update if nothing changed — prevents notification flicker
|
||||
// on Android skins that animate every notification update.
|
||||
if (timeLeft == _lastTimeLeft && speedKbps == _lastSpeedKbps) return;
|
||||
_lastTimeLeft = timeLeft;
|
||||
_lastTimeLeft = timeLeft;
|
||||
_lastSpeedKbps = speedKbps;
|
||||
|
||||
if (!_initialized) await init();
|
||||
|
||||
// NOTE: The channel ID includes 'v2' to force Android to create a fresh
|
||||
// notification channel. Android channels are immutable once created — if
|
||||
// the old channel was created without 'ongoing: true', users would be
|
||||
// stuck with a swipeable notification forever.
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'revolt_vpn_status_v2',
|
||||
'VPN Status',
|
||||
channelDescription: 'Shown while VPN is active — non-dismissable',
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
// Non-dismissable while VPN is running
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
// Silent updates — no sound or vibration each time stats refresh
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
onlyAlertOnce: true,
|
||||
// Hide the auto-generated timestamp
|
||||
showWhen: false,
|
||||
// Show on lock screen
|
||||
visibility: NotificationVisibility.public,
|
||||
// Extra insurance against swipe-to-dismiss on aggressive Android skins
|
||||
usesChronometer: false,
|
||||
category: AndroidNotificationCategory.service,
|
||||
final bodyText = 'Time: $timeLeft • $speedKbps';
|
||||
|
||||
final bigPictureStyle = BigPictureStyleInformation(
|
||||
const DrawableResourceAndroidBitmap('notification_banner'),
|
||||
largeIcon: const DrawableResourceAndroidBitmap('notification_icon'),
|
||||
contentTitle: 'Revolt VPN',
|
||||
summaryText: bodyText,
|
||||
htmlFormatContentTitle: false,
|
||||
htmlFormatSummaryText: false,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(android: androidDetails);
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: 'Session timer and speed',
|
||||
importance: Importance.low,
|
||||
priority: Priority.low,
|
||||
ongoing: true,
|
||||
autoCancel: false,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
showWhen: false,
|
||||
visibility: NotificationVisibility.public,
|
||||
usesChronometer: false,
|
||||
category: AndroidNotificationCategory.service,
|
||||
colorized: true,
|
||||
color: const ui.Color(0xFF0D1117),
|
||||
styleInformation: bigPictureStyle,
|
||||
);
|
||||
|
||||
final details = NotificationDetails(android: androidDetails);
|
||||
|
||||
try {
|
||||
await _plugin.show(
|
||||
888, // Fixed ID — always updates the same notification
|
||||
'REVOLT VPN Active',
|
||||
'Time: $timeLeft • $speedKbps',
|
||||
notificationDetails,
|
||||
);
|
||||
await _plugin.show(_notifId, _channelName, bodyText, details);
|
||||
} catch (e) {
|
||||
// If showing fails (e.g. permission revoked), try to recover by
|
||||
// re-initializing and retrying once.
|
||||
_initialized = false;
|
||||
try {
|
||||
await init();
|
||||
await _plugin.show(
|
||||
888,
|
||||
'REVOLT VPN Active',
|
||||
'Time: $timeLeft • $speedKbps',
|
||||
notificationDetails,
|
||||
);
|
||||
} catch (_) {
|
||||
// Give up — the VPN still works, just without the notification.
|
||||
}
|
||||
await _plugin.show(_notifId, _channelName, bodyText, details);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> cancel() async {
|
||||
if (!_initialized) return;
|
||||
await _plugin.cancel(888);
|
||||
await _plugin.cancel(_notifId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ class WebsiteButton extends StatelessWidget {
|
||||
const WebsiteButton({super.key});
|
||||
|
||||
static Future<void> launchWebsite() async {
|
||||
final Uri url = Uri.parse('https://getrevolt.app');
|
||||
final Uri url = Uri.parse('https://userevolt.app');
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
debugPrint('Could not launch $url');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,19 @@ 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/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});
|
||||
@@ -14,57 +26,97 @@ class StatusBar extends StatelessWidget {
|
||||
builder: (context, vpn, timer, _) {
|
||||
final isConnected = vpn.status == VpnStatus.connected;
|
||||
final isConnecting = vpn.status == VpnStatus.connecting;
|
||||
final serverHealthy = vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||
final serverHealthy =
|
||||
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
final statusLabel = isConnected
|
||||
? (timer.hasSyncedOnce ? '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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Connection status — wrapped in Flexible to prevent overflow
|
||||
// on narrow screens (the Row must fit both the status text
|
||||
// and the online dot without RenderFlex errors).
|
||||
if (isConnected) ...[
|
||||
Image.asset('assets/finland_flag_256.png', width: 20, height: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Flexible(
|
||||
child: Text('Helsinki, Finland', style: TextStyle(
|
||||
color: AppColors.accent, fontWeight: FontWeight.w600,
|
||||
fontSize: 13, letterSpacing: 0.5,
|
||||
), overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
] else ...[
|
||||
Icon(
|
||||
isConnecting ? Icons.sync : Icons.shield_outlined,
|
||||
size: 18,
|
||||
color: isConnecting ? AppColors.accent : AppColors.slate70,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
vpn.statusMessage.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: isConnecting ? AppColors.accent : AppColors.slate70,
|
||||
fontWeight: FontWeight.w600, fontSize: 13, letterSpacing: 1,
|
||||
// ── Server location ──────────────────────────────────────────
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
pageBuilder: (_, __, ___) => const SelectionScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
)),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
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,
|
||||
)),
|
||||
),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: AppColors.textDim, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Server health
|
||||
OnlineDot(
|
||||
isOnline: serverHealthy,
|
||||
label: isConnected
|
||||
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
|
||||
: (vpn.serverReachable ? 'Ready' : 'Offline'),
|
||||
// ── Connection status ────────────────────────────────────────
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.bgCard,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.slate15),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
OnlineDot(
|
||||
isOnline: serverHealthy,
|
||||
label: statusLabel,
|
||||
),
|
||||
const Spacer(),
|
||||
if (isConnected)
|
||||
const Icon(Icons.lock,
|
||||
color: AppColors.green50, size: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -73,7 +73,32 @@ class AdManager extends ChangeNotifier {
|
||||
return _loadCompleter!.future;
|
||||
}
|
||||
|
||||
// ── Show ad (or debug bypass) ─────────────────────────────────────
|
||||
|
||||
Future<bool> showAd(String adType) async {
|
||||
// Debug bypass: fire fake AdMob callback so support ads work in dev.
|
||||
// The server's ADMOB_BYPASS must be True for this to succeed.
|
||||
if (!adsEnabled && kDebugMode) {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
HivemindService.setExpectedNonce(nonce);
|
||||
try {
|
||||
final customData = jsonEncode({
|
||||
'device_id': deviceId,
|
||||
'ad_type': adType,
|
||||
'nonce': nonce,
|
||||
});
|
||||
final fakeUrl = Uri.parse(
|
||||
'${AppConfig.hivemindApiPublic}/admob/callback'
|
||||
'?signature=test&key_id=test'
|
||||
'&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await HivemindService.directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!adsEnabled) return false;
|
||||
|
||||
if (!_isAdLoaded || _rewardedAd == null) {
|
||||
@@ -123,7 +148,6 @@ class AdManager extends ChangeNotifier {
|
||||
},
|
||||
);
|
||||
|
||||
// Show with SSV options
|
||||
_rewardedAd!.setServerSideOptions(ssvOptions);
|
||||
await _rewardedAd!.show(
|
||||
onUserEarnedReward: (AdWithoutView ad, RewardItem reward) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -10,6 +11,73 @@ class HivemindService {
|
||||
static String? _expectedNonce;
|
||||
static int _currentCallId = 0;
|
||||
|
||||
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 Future<http.Response> directGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> directPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return http.post(uri, headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': _ua,
|
||||
}, body: body).timeout(timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> proxyGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return _proxyRequest('GET', uri, timeout: timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> proxyPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return _proxyRequest('POST', uri, body: body, timeout: timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> _proxyRequest(String method, Uri uri,
|
||||
{String? body, Duration timeout = const Duration(seconds: 5)}) async {
|
||||
final socket = await Socket.connect('127.0.0.1', 10808, timeout: timeout);
|
||||
try {
|
||||
final target = uri.toString();
|
||||
final headers = <String, String>{
|
||||
'Host': '${uri.host}:${uri.port}',
|
||||
'User-Agent': _ua,
|
||||
'Accept': '*/*',
|
||||
'Connection': 'close',
|
||||
};
|
||||
if (body != null) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = body.length.toString();
|
||||
}
|
||||
|
||||
final reqBuf = StringBuffer();
|
||||
reqBuf.write('$method $target HTTP/1.1\r\n');
|
||||
headers.forEach((k, v) => reqBuf.write('$k: $v\r\n'));
|
||||
reqBuf.write('\r\n');
|
||||
if (body != null) reqBuf.write(body);
|
||||
|
||||
socket.write(reqBuf.toString());
|
||||
await socket.flush();
|
||||
|
||||
final response = await socket.fold<String>(
|
||||
'',
|
||||
(prev, chunk) => prev + String.fromCharCodes(chunk),
|
||||
).timeout(timeout);
|
||||
|
||||
final parts = response.split('\r\n\r\n');
|
||||
if (parts.length < 2) throw Exception('Invalid proxy response');
|
||||
final headerBlock = parts[0];
|
||||
final respBody = parts.sublist(1).join('\r\n\r\n');
|
||||
|
||||
final headerLines = headerBlock.split('\r\n');
|
||||
final statusParts = headerLines[0].split(' ');
|
||||
final statusCode = int.parse(statusParts[1]);
|
||||
|
||||
return http.Response(respBody, statusCode);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
static void cancel() {
|
||||
_currentCallId++;
|
||||
}
|
||||
@@ -18,12 +86,7 @@ class HivemindService {
|
||||
_expectedNonce = nonce;
|
||||
}
|
||||
|
||||
/// Fetch a real VLESS session config from the tunnel-internal API.
|
||||
///
|
||||
/// Called AFTER the bootstrap tunnel is already up — all HTTP requests
|
||||
/// go through the Reality tunnel to 10.254.254.1:5000 (redirected to
|
||||
/// Flask by Xray's "api" freedom outbound).
|
||||
static Future<String> fetchConfigThroughTunnel({
|
||||
static Future<String> fetchConfigDirectly({
|
||||
void Function(int attempt, int total)? onAttempt,
|
||||
bool skipAdBypass = false,
|
||||
}) async {
|
||||
@@ -34,16 +97,14 @@ class HivemindService {
|
||||
_expectedNonce = nonce;
|
||||
debugPrint('[HivemindService] Call #$callId — nonce: $nonce');
|
||||
|
||||
final url = _url('/session/status?device_id=$deviceId');
|
||||
final url = _publicUrl('/session/status?device_id=$deviceId');
|
||||
|
||||
// Dev: fake ad callback when running in debug mode.
|
||||
// Goes through the tunnel to 10.254.254.1:5000 — same internal API.
|
||||
if (kDebugMode && !skipAdBypass) {
|
||||
if (!skipAdBypass) {
|
||||
try {
|
||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||
final fakeUrl = _url(
|
||||
final fakeUrl = _publicUrl(
|
||||
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await http.get(fakeUrl).timeout(const Duration(seconds: 8));
|
||||
await directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -53,7 +114,7 @@ class HivemindService {
|
||||
|
||||
onAttempt?.call(i, maxAttempts);
|
||||
try {
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 5));
|
||||
final response = await directGet(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
@@ -62,15 +123,8 @@ class HivemindService {
|
||||
debugPrint('[HivemindService] Nonce mismatch — retrying…');
|
||||
} else if (data['active'] == true && data['vless_uuid'] != null) {
|
||||
final vlessUuid = data['vless_uuid'];
|
||||
|
||||
// vless_ip is pinned — even if the API is MITM'd, the tunnel
|
||||
// never goes anywhere except the real server IP.
|
||||
final vlessIp = data['vless_ip'] ?? AppConfig.serverIp;
|
||||
if (vlessIp != AppConfig.serverIp) {
|
||||
throw Exception('API returned unexpected tunnel IP — possible MITM');
|
||||
}
|
||||
|
||||
final vlessPort = data['vless_port'] ?? 8443;
|
||||
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'];
|
||||
@@ -78,17 +132,15 @@ class HivemindService {
|
||||
final fp = data['reality_fp'] ?? AppConfig.realityFp;
|
||||
final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath;
|
||||
|
||||
// Host header not set — server doesn't require it.
|
||||
// Client falls through: host → serverName (SNI) → address.
|
||||
final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort'
|
||||
'?security=${AppConfig.vlessSecurity}'
|
||||
'&type=${AppConfig.vlessType}'
|
||||
'&path=${Uri.encodeComponent(xhttpPath)}'
|
||||
'&path=$xhttpPath'
|
||||
'&pbk=${Uri.encodeComponent(pbk)}'
|
||||
'&sni=${Uri.encodeComponent(sni)}'
|
||||
'&sid=${Uri.encodeComponent(sid)}'
|
||||
'&fp=${Uri.encodeComponent(fp)}'
|
||||
'#ReVoltVPN';
|
||||
'#Revolt VPN';
|
||||
_expectedNonce = null;
|
||||
return vlessUrl;
|
||||
}
|
||||
@@ -106,8 +158,8 @@ class HivemindService {
|
||||
|
||||
static Future<bool> checkHealth() async {
|
||||
try {
|
||||
final url = _url('/health');
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 3));
|
||||
final url = Uri.parse('${AppConfig.hivemindApiPublic}/health');
|
||||
final response = await directGet(url, timeout: const Duration(seconds: 3));
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
@@ -117,8 +169,8 @@ class HivemindService {
|
||||
static Future<bool> verifySession() async {
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final url = _url('/session/status?device_id=$deviceId');
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 5));
|
||||
final url = _tunnelUrl('/session/status?device_id=$deviceId');
|
||||
final response = await proxyGet(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['active'] == true;
|
||||
@@ -127,19 +179,15 @@ class HivemindService {
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<void> disconnectAndCleanup() async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final url = _url('/session/stop');
|
||||
try {
|
||||
await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'device_id': deviceId}),
|
||||
).timeout(const Duration(seconds: 3));
|
||||
} catch (_) {}
|
||||
}
|
||||
// Idle sessions are culled server-side by the management loop
|
||||
// after 2 minutes of no /status polls (user disconnected).
|
||||
static void disconnectAndCleanup() {}
|
||||
|
||||
static Uri _url(String path) {
|
||||
return Uri.parse('${AppConfig.hivemindApiBase}$path');
|
||||
}
|
||||
// ── URL builders ──────────────────────────────────────────────────
|
||||
|
||||
static Uri _publicUrl(String path) =>
|
||||
Uri.parse('${AppConfig.hivemindApiPublic}$path');
|
||||
|
||||
static Uri _tunnelUrl(String path) =>
|
||||
Uri.parse('${AppConfig.hivemindApiBase}$path');
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
// ── lib/logic/server_list.dart ──────────────────────────────────────────────
|
||||
// Server data model and ChangeNotifier — shared by StatusBar (shows current
|
||||
// server name/flag) and SelectionScreen (lets user pick a server).
|
||||
//
|
||||
// Each server is defined by its tunnel IP/port. Reality keys (public key,
|
||||
// shortId, fingerprint) come from AppConfig — all servers share the same
|
||||
// Reality keypair and bootstrap UUID (same operator, different locations).
|
||||
//
|
||||
// When multi-server ships:
|
||||
// - _all becomes a fetched list (from /api/servers or bundled config)
|
||||
// - SelectionScreen shows multiple rows
|
||||
// - select() triggers a disconnect/reconnect cycle if the tunnel is active
|
||||
|
||||
class ServerInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final String flagAsset;
|
||||
final String ip;
|
||||
final int port;
|
||||
|
||||
const ServerInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.flagAsset,
|
||||
required this.ip,
|
||||
required this.port,
|
||||
});
|
||||
}
|
||||
|
||||
class ServerList extends ChangeNotifier {
|
||||
static const _all = [
|
||||
ServerInfo(
|
||||
id: 'finland',
|
||||
name: 'Helsinki, Finland',
|
||||
flagAsset: 'assets/finland_flag_256.png',
|
||||
ip: '204.168.246.88',
|
||||
port: 443,
|
||||
),
|
||||
];
|
||||
|
||||
int _selectedIndex = 0;
|
||||
|
||||
List<ServerInfo> get all => _all;
|
||||
int get selectedIndex => _selectedIndex;
|
||||
ServerInfo get selected => _all[_selectedIndex];
|
||||
|
||||
// Loads persisted selection from SharedPreferences.
|
||||
// Fire-and-forget — UI starts with default, updates when prefs load.
|
||||
Future<void> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getInt('selected_server_index') ?? 0;
|
||||
if (saved >= 0 && saved < _all.length && saved != _selectedIndex) {
|
||||
_selectedIndex = saved;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Switches to a different server. Persists the choice.
|
||||
Future<void> select(int index) async {
|
||||
if (index == _selectedIndex || index < 0 || index >= _all.length) return;
|
||||
_selectedIndex = index;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt('selected_server_index', index);
|
||||
}
|
||||
}
|
||||
+33
-121
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:revoltvpn/logic/hivemind_service.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/components/notification.dart';
|
||||
|
||||
class SessionTimer extends ChangeNotifier {
|
||||
Timer? _timer;
|
||||
@@ -19,15 +18,9 @@ class SessionTimer extends ChangeNotifier {
|
||||
bool _hasSyncedOnce = false;
|
||||
int _consecutiveFailures = 0;
|
||||
bool _isDisconnecting = false;
|
||||
bool _userInitiatedStop = false;
|
||||
bool _syncInProgress = false;
|
||||
|
||||
|
||||
int? _currentPort;
|
||||
bool _portSwitchInProgress = false;
|
||||
|
||||
static const int _maxConsecutiveFailures = 3;
|
||||
|
||||
static const int _maxOfflineSeconds = 120;
|
||||
int _offlineSeconds = 0;
|
||||
|
||||
@@ -36,9 +29,6 @@ class SessionTimer extends ChangeNotifier {
|
||||
int _lastUsedBytes = 0;
|
||||
double _currentSpeedKBps = 0.0;
|
||||
|
||||
String _lastNotifTime = '';
|
||||
String _lastNotifSpeed = '';
|
||||
|
||||
SessionTimer({required this.vpnConnection}) {
|
||||
vpnConnection.addListener(_onVpnConnectionChanged);
|
||||
}
|
||||
@@ -58,11 +48,8 @@ class SessionTimer extends ChangeNotifier {
|
||||
return '$h:$m:$s';
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
/// Fires whenever VpnConnection state changes.
|
||||
void _onVpnConnectionChanged() {
|
||||
|
||||
// Restore timer if VPN was already running at app launch.
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
vpnConnection.isStartupRestoration) {
|
||||
@@ -70,21 +57,18 @@ class SessionTimer extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// The underlying VLESS tunnel may briefly flap. If we weren't
|
||||
// explicitly stopped by the user, resume where we left off.
|
||||
// Resume after brief tunnel disconnection.
|
||||
if (vpnConnection.status == VpnStatus.connected &&
|
||||
!isRunning &&
|
||||
!_userInitiatedStop &&
|
||||
!_isDisconnecting &&
|
||||
_hasSyncedOnce) {
|
||||
debugPrint('[Timer] VPN reconnected after blip — resuming.');
|
||||
_resumeTicking();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (vpnConnection.status == VpnStatus.disconnected && isRunning) {
|
||||
_stopInternal();
|
||||
if (vpnConnection.status == VpnStatus.disconnected && !_isDisconnecting) {
|
||||
_doDisconnect('VPN tunnel dropped');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,47 +82,32 @@ class SessionTimer extends ChangeNotifier {
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_isDisconnecting = false;
|
||||
_userInitiatedStop = false;
|
||||
_currentPort = null;
|
||||
_portSwitchInProgress = false;
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Kick off the first sync — the UI will show real data as soon as it lands.
|
||||
_syncWithHivemind();
|
||||
}
|
||||
|
||||
void _tick(Timer t) {
|
||||
|
||||
// Decrement the local clock as long as we're not in a network blackout.
|
||||
if (_isDisconnecting) return;
|
||||
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
|
||||
if (_remainingSeconds > 0) {
|
||||
_remainingSeconds--;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If the server has been unreachable for too long, kill the VPN.
|
||||
// Don't let an orphaned tunnel keep running forever.
|
||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||
_offlineSeconds++;
|
||||
if (_offlineSeconds >= _maxOfflineSeconds) {
|
||||
debugPrint('[Timer] Offline for $_maxOfflineSeconds s — forcing disconnect.');
|
||||
_forceDisconnect('Session lost — server unreachable');
|
||||
_doDisconnect('Server unreachable');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// When our clock hits zero, disconnect immediately. Don't wait for the
|
||||
// next server poll — the session is over and the user expects the VPN
|
||||
// to drop right now.
|
||||
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
||||
debugPrint('[Timer] Local countdown expired — disconnecting.');
|
||||
_forceDisconnect('Session expired');
|
||||
_doDisconnect('Session expired');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,68 +116,53 @@ class SessionTimer extends ChangeNotifier {
|
||||
_syncWithHivemind();
|
||||
}
|
||||
|
||||
|
||||
// Uses the locally-decremented time so the notification stays in sync
|
||||
// with the in-app display, not gated behind the 5 s server poll.
|
||||
if (_hasSyncedOnce) {
|
||||
final speedText = _currentSpeedKBps > 0.5
|
||||
? '${_currentSpeedKBps.toStringAsFixed(1)} KB/s'
|
||||
: 'Idle';
|
||||
if (formatted != _lastNotifTime || speedText != _lastNotifSpeed) {
|
||||
_lastNotifTime = formatted;
|
||||
_lastNotifSpeed = speedText;
|
||||
VpnNotificationManager.showOrUpdateStatus(
|
||||
timeLeft: formatted,
|
||||
speedKbps: speedText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _forceDisconnect(String reason) {
|
||||
if (_isDisconnecting) return; // Already tearing down
|
||||
debugPrint('[Timer] $reason — forcing disconnect.');
|
||||
Future<void> disconnect({String reason = 'User requested'}) async {
|
||||
await _doDisconnect(reason);
|
||||
}
|
||||
|
||||
Future<void> _doDisconnect(String reason) async {
|
||||
if (_isDisconnecting) return;
|
||||
_isDisconnecting = true;
|
||||
debugPrint('[Timer] Disconnecting: $reason');
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_isDisconnecting = true;
|
||||
VpnNotificationManager.cancel();
|
||||
_remainingSeconds = 0;
|
||||
_hasSyncedOnce = false;
|
||||
notifyListeners();
|
||||
|
||||
await vpnConnection.disconnect();
|
||||
|
||||
if (!_isDisconnecting) return;
|
||||
_isDisconnecting = false;
|
||||
notifyListeners();
|
||||
// Await the disconnect so the tunnel is confirmed down before we
|
||||
// consider the session fully cleaned up.
|
||||
vpnConnection.disconnect();
|
||||
}
|
||||
|
||||
|
||||
|
||||
Future<void> _syncWithHivemind() async {
|
||||
// Never sync while the user is explicitly disconnecting,
|
||||
// or while another sync is already in flight.
|
||||
if (_isDisconnecting || _syncInProgress) return;
|
||||
_syncInProgress = true;
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final url = Uri.parse(
|
||||
'${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 5));
|
||||
'${AppConfig.hivemindApiPublic}/session/status?device_id=$deviceId');
|
||||
final response = await HivemindService.directGet(url);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
final bool active = data['active'] ?? false;
|
||||
if (!active) {
|
||||
// Server killed the session — tear down and wait for it.
|
||||
stop();
|
||||
await vpnConnection.disconnect();
|
||||
await _doDisconnect('Server ended session');
|
||||
return;
|
||||
}
|
||||
|
||||
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
|
||||
_usedBytes = data['used_bytes'] ?? _usedBytes;
|
||||
|
||||
|
||||
final int deltaBytes = _usedBytes - _lastUsedBytes;
|
||||
if (_hasSyncedOnce && deltaBytes > 0) {
|
||||
_currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000;
|
||||
@@ -217,32 +171,15 @@ class SessionTimer extends ChangeNotifier {
|
||||
}
|
||||
_lastUsedBytes = _usedBytes;
|
||||
|
||||
|
||||
// Port change — throttle engaged or disengaged
|
||||
final serverPort = data['vless_port'];
|
||||
if (serverPort != null &&
|
||||
_currentPort != null &&
|
||||
serverPort != _currentPort &&
|
||||
!_portSwitchInProgress) {
|
||||
debugPrint('[Timer] Port changed $_currentPort → $serverPort — reconnecting…');
|
||||
_currentPort = serverPort;
|
||||
_portSwitchInProgress = true;
|
||||
|
||||
_timer?.cancel();
|
||||
_isDisconnecting = true;
|
||||
await vpnConnection.disconnect(skipCleanup: true);
|
||||
await vpnConnection.connect(skipAdBypass: true, quickReconnect: true);
|
||||
|
||||
_resumeTicking();
|
||||
_portSwitchInProgress = false;
|
||||
final capExhausted = data['cap_exhausted'] ?? false;
|
||||
if (capExhausted) {
|
||||
await _doDisconnect('Data cap reached');
|
||||
return;
|
||||
}
|
||||
_currentPort = serverPort;
|
||||
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_hasSyncedOnce = true;
|
||||
|
||||
notifyListeners();
|
||||
} else {
|
||||
_markSyncFailure();
|
||||
@@ -258,24 +195,10 @@ class SessionTimer extends ChangeNotifier {
|
||||
void _markSyncFailure() {
|
||||
_consecutiveFailures++;
|
||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||
notifyListeners(); // let the UI show "Reconnecting…"
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
void stop() {
|
||||
_userInitiatedStop = true;
|
||||
_isDisconnecting = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_hasSyncedOnce = false;
|
||||
_remainingSeconds = 0;
|
||||
VpnNotificationManager.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _resumeTicking() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
@@ -284,21 +207,10 @@ class SessionTimer extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _stopInternal() {
|
||||
_isDisconnecting = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_hasSyncedOnce = false;
|
||||
VpnNotificationManager.cancel();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
vpnConnection.removeListener(_onVpnConnectionChanged);
|
||||
_timer?.cancel();
|
||||
VpnNotificationManager.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
+62
-112
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
import 'package:revoltvpn/logic/app_config.dart';
|
||||
|
||||
enum VpnStatus {
|
||||
disconnected,
|
||||
@@ -23,7 +23,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
String? _errorMessage;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
// True when the app was killed and reopened while the VPN was running.
|
||||
bool _isStartupRestoration = false;
|
||||
bool get isStartupRestoration => _isStartupRestoration;
|
||||
|
||||
@@ -53,19 +52,18 @@ class VpnConnection extends ChangeNotifier {
|
||||
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');
|
||||
}
|
||||
|
||||
// Periodic server health check — now goes through the tunnel
|
||||
// (AppConfig.hivemindApiBase = http://10.254.254.1:5000/api)
|
||||
_checkHealth();
|
||||
_healthTimer =
|
||||
Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth());
|
||||
|
||||
// Check if VPN was already running (app killed and reopened)
|
||||
try {
|
||||
final coreVersion = await _vless.getCoreVersion();
|
||||
debugPrint('[VPN] Xray core version: $coreVersion');
|
||||
@@ -95,8 +93,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
break;
|
||||
case VlessConnectionState.unknown:
|
||||
// Native backend emitted an unrecognized state — treat as error
|
||||
// only if we aren't already in a terminal state.
|
||||
if (_status != VpnStatus.connected &&
|
||||
_status != VpnStatus.disconnected) {
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
@@ -105,20 +101,18 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect (V2: bootstrap → poll → real tunnel) ────────────────────
|
||||
// ── Connect ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Flow:
|
||||
// 1. Connect with hardcoded bootstrap VLESS URL (bundle in APK)
|
||||
// 2. Through bootstrap tunnel, poll 10.254.254.1:5000 for real config
|
||||
// 3. Disconnect bootstrap
|
||||
// 4. Connect with real per-session VLESS URL
|
||||
// 1. Direct HTTPS to the public API → get per-user VLESS URL
|
||||
// 2. Start tunnel directly with that URL (no bootstrap, no switch)
|
||||
//
|
||||
// Parameter quickReconnect is used by the session timer for throttle
|
||||
// port-swaps — same bootstrap flow, but skip the AdMob bypass (session
|
||||
// is already active).
|
||||
// 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, bool quickReconnect = false}) async {
|
||||
Future<bool> connect({
|
||||
bool skipAdBypass = false,
|
||||
}) async {
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
return false;
|
||||
}
|
||||
@@ -130,6 +124,15 @@ class VpnConnection extends ChangeNotifier {
|
||||
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;
|
||||
|
||||
@@ -139,122 +142,72 @@ class VpnConnection extends ChangeNotifier {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Phase 1: Bootstrap tunnel ────────────────────────────────────
|
||||
try {
|
||||
final bootstrapParsed = FlutterVless.parse(AppConfig.bootstrapVlessUrl);
|
||||
await _vless.startVless(
|
||||
remark: 'ReVoltVPN-Bootstrap',
|
||||
config: bootstrapParsed.getFullConfiguration(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Bootstrap tunnel error: $e');
|
||||
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_cancelled) {
|
||||
await _safeStopVless();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Let the bootstrap tunnel stabilise before API calls
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// ── Phase 2: Fetch real config through bootstrap tunnel ──────────
|
||||
_setStatus(VpnStatus.connecting, 'Fetching config…');
|
||||
|
||||
String realUrl;
|
||||
try {
|
||||
realUrl = await HivemindService.fetchConfigThroughTunnel(
|
||||
realUrl = await HivemindService.fetchConfigDirectly(
|
||||
skipAdBypass: skipAdBypass,
|
||||
onAttempt: (attempt, total) {
|
||||
if (!quickReconnect) {
|
||||
_setStatus(
|
||||
VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
||||
}
|
||||
_setStatus(
|
||||
VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Config fetch error: $e');
|
||||
await _safeStopVless();
|
||||
|
||||
final raw = e.toString().replaceAll('Exception: ', '');
|
||||
if (raw.contains('Cancelled')) {
|
||||
return false;
|
||||
} else if (raw.contains('timed out') ||
|
||||
raw.contains('Session not activated')) {
|
||||
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 = raw;
|
||||
final short = raw.length > 35 ? '${raw.substring(0, 35)}…' : raw;
|
||||
_setStatus(VpnStatus.error, short);
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
_setStatus(VpnStatus.error, 'Config fetch error');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_cancelled) {
|
||||
await _safeStopVless();
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Phase 3: Switch to real tunnel ───────────────────────────────
|
||||
_setStatus(VpnStatus.connecting, 'Securing connection…');
|
||||
await _safeStopVless();
|
||||
|
||||
try {
|
||||
final realParsed = FlutterVless.parse(realUrl);
|
||||
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: realParsed.remark.isNotEmpty ? realParsed.remark : 'ReVoltVPN',
|
||||
config: realParsed.getFullConfiguration(),
|
||||
remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN',
|
||||
config: jsonEncode(configJson),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Real tunnel error: $e');
|
||||
debugPrint('[VPN] Tunnel start error: $e');
|
||||
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quickReconnect) {
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
} else {
|
||||
// Give the tunnel a moment to stabilise, then verify
|
||||
_setStatus(VpnStatus.connecting, 'Verifying session…');
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
bool sessionOk = false;
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
sessionOk = await HivemindService.verifySession();
|
||||
if (sessionOk) break;
|
||||
if (attempt == 0) {
|
||||
debugPrint('[VPN] verifySession attempt 1 failed, retrying…');
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionOk) {
|
||||
debugPrint(
|
||||
'[VPN] Tunnel started but no server session — tearing down.');
|
||||
try {
|
||||
await _vless.stopVless();
|
||||
} catch (_) {}
|
||||
_errorMessage = 'Session not confirmed by server.\nPlease try again.';
|
||||
_setStatus(VpnStatus.error, 'Session rejected');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
}
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> disconnect({bool skipCleanup = false}) async {
|
||||
Future<void> disconnect() async {
|
||||
_cancelled = true;
|
||||
HivemindService.cancel(); // abort any in-progress polling
|
||||
HivemindService.cancel();
|
||||
if (_status == VpnStatus.disconnected ||
|
||||
_status == VpnStatus.disconnecting) {
|
||||
return;
|
||||
@@ -268,18 +221,28 @@ class VpnConnection extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
bool timedOut = false;
|
||||
try {
|
||||
await _vless.stopVless().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => debugPrint('[VPN] stopVless() timed out.'),
|
||||
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 (!skipCleanup) {
|
||||
HivemindService.disconnectAndCleanup();
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -294,26 +257,13 @@ class VpnConnection extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _safeStopVless() async {
|
||||
try {
|
||||
await _vless.stopVless().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => debugPrint('[VPN] Bootstrap stopVless() timed out.'),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Bootstrap stop error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_healthTimer?.cancel();
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
try {
|
||||
_vless.stopVless();
|
||||
} catch (_) {
|
||||
// Best-effort — widget is being destroyed anyway
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
import 'package:revoltvpn/logic/consent_manager.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/screens/intro.dart';
|
||||
@@ -59,6 +60,7 @@ class ReVoltApp extends StatelessWidget {
|
||||
update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => AdManager()),
|
||||
ChangeNotifierProvider(create: (_) => ServerList()..init()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
title: 'ReVolt VPN',
|
||||
|
||||
@@ -90,7 +90,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'REVOLT VPN',
|
||||
'Revolt VPN',
|
||||
style: TextStyle(
|
||||
color: AppColors.textWhite,
|
||||
fontSize: 28,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/server_list.dart';
|
||||
|
||||
// ── lib/screens/server_selector/selection_screen.dart ──────────────────────
|
||||
// Server picker — reads the server list from the ServerList provider.
|
||||
// Each row shows a flag + city name. Tapping a row persists the choice
|
||||
// and pops back. No latency measurement — the app runs inside a Reality
|
||||
// tunnel and must not emit separate probe traffic.
|
||||
|
||||
class SelectionScreen extends StatelessWidget {
|
||||
const SelectionScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final serverList = context.watch<ServerList>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.bgDeep,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.bgSurface,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textWhite),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Select Server',
|
||||
style: TextStyle(
|
||||
color: AppColors.textWhite,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: serverList.all.length,
|
||||
itemBuilder: (context, index) {
|
||||
final server = serverList.all[index];
|
||||
final isSelected = index == serverList.selectedIndex;
|
||||
|
||||
return ListTile(
|
||||
leading: Image.asset(server.flagAsset, width: 28, height: 28),
|
||||
title: Text(server.name,
|
||||
style: TextStyle(
|
||||
color: isSelected ? AppColors.accent : AppColors.textWhite,
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
trailing: isSelected
|
||||
? const Icon(Icons.check, color: AppColors.accent)
|
||||
: null,
|
||||
onTap: () {
|
||||
serverList.select(index);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
// ── lib/screens/settings/in_settings/haptic.dart ────────────────────────────
|
||||
// Toggle for light haptic feedback on successful connect/disconnect.
|
||||
//
|
||||
// Top-level bool (hapticEnabled) is read synchronously by connect_button.dart —
|
||||
// no await, no SharedPreferences lookup on every tap. The tile loads the
|
||||
// persisted value once and keeps the global in sync on every toggle.
|
||||
//
|
||||
// SharedPreferences key: haptic_feedback_enabled (default: false — off).
|
||||
|
||||
// lib/components/connect_button.dart reads this directly. Keep it exported.
|
||||
bool hapticEnabled = false;
|
||||
|
||||
class HapticFeedbackTile extends StatefulWidget {
|
||||
|
||||
Reference in new issue
Block a user