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:
esefxdz committed 2026-08-08 13:06:19 +03:00
1 parent cf50c3a01b
commit 54f949ca8e
20 files changed
+529 -379

No files matched your search

+15
View File
@@ -105,3 +105,18 @@ DOCUMENTATION-ARCHITECTURE-V2.md
config.json config.json
RESILIENCE_REVIEW.md RESILIENCE_REVIEW.md
CONFIG_REVIEW.md CONFIG_REVIEW.md
SERVER_SELECTION.md
THREAT_MODEL.md
BUSINESS_MODEL.md
DPI_FINGERPRINT_ANALYSIS.md
CODE_FINGERPRINT_AUDIT.md
CONFERENCE_PREP.md
CONVERSION_CHECKLIST.md
HIVEMIND_INTERNALS.md
#why are you even looking at this file? it's a secret. go away.
SERVER_ARCHITECTURE.md
turbovpn_play.html
supervpn_play.html
hetzner*
DEPLOYMENT_REPORT_FINAL.md
+35 -1
View File
@@ -5,6 +5,40 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
--- ---
## [3.2.0] — 2026-08-08
### DPI hardening
- Spoof Chrome-on-Android User-Agent on all API calls instead of `Dart/3.x`.
- Renamed domain from `getrevolt.app` to `userevolt.app`.
### Branding
- App name changed from "REVOLT VPN" to "Revolt VPN" across manifest, splash,
notification, and VLESS URL remark.
### Notification
- Removed duplicate `flutter_local_notifications` notification. Only the
Android VPN foreground-service notification remains — non-swipeable.
- Patched `flutter_vless` native code to use custom notification icon
(`@drawable/notification_icon`) with dark background (`#0D1117`).
### Connection reliability
- Merged five separate disconnect paths into a single idempotent
`_doDisconnect()` gate. Eliminated race between timer ticks and VPN
teardown where the countdown could briefly show -1.
- `stopVless()` timeout (5 s) now correctly shows an error instead of
silently pretending the VPN disconnected while the tunnel was still alive.
### AdMob bypass
- `kDebugMode` guard removed — the debug AdMob callback now fires in release
builds so release APKs can create sessions without real ads during testing.
The real gate is `ADMOB_BYPASS` on the server.
### Fixed
- **Notification icon:** Replaced five density-specific `ic_launcher.png`
files with `notification_icon.png`, then reverted to avoid changing the
home-screen icon. The custom icon is now injected through flutter_vless's
`initializeVless()` parameters.
## [3.1.0] — 2026-08-06 ## [3.1.0] — 2026-08-06
### Architecture — domain-free client ### Architecture — domain-free client
@@ -83,7 +117,7 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
### Added ### Added
- **Update checker** in sidebar — polls GitHub Releases API. - **Update checker** in sidebar — polls GitHub Releases API.
- **Website button** in sidebar → opens `https://getrevolt.app`. - **Website button** in sidebar → opens `https://userevolt.app`.
- **Timer syncing indicator** — spinner during handshake. - **Timer syncing indicator** — spinner during handshake.
- Dependency: `package_info_plus`. - Dependency: `package_info_plus`.
+4 -4
View File
@@ -11,10 +11,10 @@ A free VPN app for Android. Open-source client, transparent infrastructure. Watc
## How it works ## How it works
1. Watch a short rewarded ad 1. Watch a short rewarded ad
2. Server creates a temporary VLESS session (60 min / 4 GB) 2. Server creates a temporary VLESS session (2 hours / 10 GB)
3. Your traffic routes through a single server in Finland 3. Your traffic routes through a single server in Finland at full speed
4. When data runs out, you're throttled to 1.5 Mbps instead of cut off 4. When time or data runs out, session ends — watch another ad to continue
5. Session expires — watch another ad or move on 5. No throttling, no slow lane, no mid-session punishment
No email. No password. No payment. The ad pays for the server. No email. No password. No payment. The ad pays for the server.
+3 -2
View File
@@ -14,11 +14,12 @@
</queries> </queries>
<application <application
android:label="REVOLT VPN" android:label="Revolt VPN"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false" android:usesCleartextTraffic="false"
android:allowBackup="false"> android:allowBackup="false"
android:extractNativeLibs="true">
<!-- AdMob App ID — injected at build time from android/admob.properties --> <!-- AdMob App ID — injected at build time from android/admob.properties -->
<meta-data <meta-data
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+1 -2
View File
@@ -64,8 +64,7 @@ class _ConnectButtonState extends State<ConnectButton>
if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) { if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) {
_busy = false; _busy = false;
timer.stop(); await timer.disconnect();
await vpn.disconnect();
if (hapticEnabled) HapticFeedback.lightImpact(); if (hapticEnabled) HapticFeedback.lightImpact();
return; return;
} }
+31 -40
View File
@@ -1,10 +1,18 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class VpnNotificationManager { class VpnNotificationManager {
static final FlutterLocalNotificationsPlugin _plugin = static final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
static bool _initialized = false; 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 { static Future<void> init() async {
if (_initialized) return; if (_initialized) return;
@@ -15,7 +23,6 @@ class VpnNotificationManager {
await _plugin.initialize(initSettings); await _plugin.initialize(initSettings);
// Request POST_NOTIFICATIONS permission on Android 13+
if (Platform.isAndroid) { if (Platform.isAndroid) {
final androidPlugin = _plugin final androidPlugin = _plugin
.resolvePlatformSpecificImplementation< .resolvePlatformSpecificImplementation<
@@ -26,77 +33,61 @@ class VpnNotificationManager {
_initialized = true; _initialized = true;
} }
static String? _lastTimeLeft;
static String? _lastSpeedKbps;
static Future<void> showOrUpdateStatus({ static Future<void> showOrUpdateStatus({
required String timeLeft, required String timeLeft,
required String speedKbps, required String speedKbps,
}) async { }) async {
// Skip update if nothing changed — prevents notification flicker
// on Android skins that animate every notification update.
if (timeLeft == _lastTimeLeft && speedKbps == _lastSpeedKbps) return; if (timeLeft == _lastTimeLeft && speedKbps == _lastSpeedKbps) return;
_lastTimeLeft = timeLeft; _lastTimeLeft = timeLeft;
_lastSpeedKbps = speedKbps; _lastSpeedKbps = speedKbps;
if (!_initialized) await init(); if (!_initialized) await init();
// NOTE: The channel ID includes 'v2' to force Android to create a fresh final bodyText = 'Time: $timeLeft • $speedKbps';
// notification channel. Android channels are immutable once created — if
// the old channel was created without 'ongoing: true', users would be final bigPictureStyle = BigPictureStyleInformation(
// stuck with a swipeable notification forever. const DrawableResourceAndroidBitmap('notification_banner'),
const androidDetails = AndroidNotificationDetails( largeIcon: const DrawableResourceAndroidBitmap('notification_icon'),
'revolt_vpn_status_v2', contentTitle: 'Revolt VPN',
'VPN Status', summaryText: bodyText,
channelDescription: 'Shown while VPN is active — non-dismissable', htmlFormatContentTitle: false,
htmlFormatSummaryText: false,
);
final androidDetails = AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: 'Session timer and speed',
importance: Importance.low, importance: Importance.low,
priority: Priority.low, priority: Priority.low,
// Non-dismissable while VPN is running
ongoing: true, ongoing: true,
autoCancel: false, autoCancel: false,
// Silent updates — no sound or vibration each time stats refresh
playSound: false, playSound: false,
enableVibration: false, enableVibration: false,
onlyAlertOnce: true,
// Hide the auto-generated timestamp
showWhen: false, showWhen: false,
// Show on lock screen
visibility: NotificationVisibility.public, visibility: NotificationVisibility.public,
// Extra insurance against swipe-to-dismiss on aggressive Android skins
usesChronometer: false, usesChronometer: false,
category: AndroidNotificationCategory.service, category: AndroidNotificationCategory.service,
colorized: true,
color: const ui.Color(0xFF0D1117),
styleInformation: bigPictureStyle,
); );
const notificationDetails = NotificationDetails(android: androidDetails); final details = NotificationDetails(android: androidDetails);
try { try {
await _plugin.show( await _plugin.show(_notifId, _channelName, bodyText, details);
888, // Fixed ID — always updates the same notification
'REVOLT VPN Active',
'Time: $timeLeft • $speedKbps',
notificationDetails,
);
} catch (e) { } catch (e) {
// If showing fails (e.g. permission revoked), try to recover by
// re-initializing and retrying once.
_initialized = false; _initialized = false;
try { try {
await init(); await init();
await _plugin.show( await _plugin.show(_notifId, _channelName, bodyText, details);
888, } catch (_) {}
'REVOLT VPN Active',
'Time: $timeLeft • $speedKbps',
notificationDetails,
);
} catch (_) {
// Give up — the VPN still works, just without the notification.
}
} }
} }
static Future<void> cancel() async { static Future<void> cancel() async {
if (!_initialized) return; if (!_initialized) return;
await _plugin.cancel(888); await _plugin.cancel(_notifId);
} }
} }
@@ -6,7 +6,7 @@ class WebsiteButton extends StatelessWidget {
const WebsiteButton({super.key}); const WebsiteButton({super.key});
static Future<void> launchWebsite() async { 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)) { if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
debugPrint('Could not launch $url'); debugPrint('Could not launch $url');
} }
+88 -36
View File
@@ -3,7 +3,19 @@ import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/session_timer.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/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 { class StatusBar extends StatelessWidget {
const StatusBar({super.key}); const StatusBar({super.key});
@@ -14,11 +26,49 @@ class StatusBar extends StatelessWidget {
builder: (context, vpn, timer, _) { builder: (context, vpn, timer, _) {
final isConnected = vpn.status == VpnStatus.connected; final isConnected = vpn.status == VpnStatus.connected;
final isConnecting = vpn.status == VpnStatus.connecting; final isConnecting = vpn.status == VpnStatus.connecting;
final serverHealthy = vpn.serverReachable || (isConnected && timer.hasSyncedOnce); final serverHealthy =
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
return Container( final statusLabel = isConnected
margin: const EdgeInsets.symmetric(horizontal: 24), ? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), : (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: [
// ── 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,
);
},
),
);
},
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.bgCard, color: AppColors.bgCard,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -26,45 +76,47 @@ class StatusBar extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
// Connection status — wrapped in Flexible to prevent overflow Image.asset(server.flagAsset, width: 20, height: 20),
// 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 SizedBox(width: 8),
const Flexible( Expanded(
child: Text('Helsinki, Finland', style: TextStyle( child: Text(server.name,
color: AppColors.accent, fontWeight: FontWeight.w600, style: const TextStyle(
fontSize: 13, letterSpacing: 0.5, color: AppColors.accent,
), overflow: TextOverflow.ellipsis), fontWeight: FontWeight.w600,
), fontSize: 13,
] else ...[ letterSpacing: 0.5,
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,
),
overflow: TextOverflow.ellipsis,
),
), ),
const Icon(Icons.chevron_right,
color: AppColors.textDim, size: 20),
], ],
),
),
),
const Spacer(), const SizedBox(height: 8),
// Server health // ── 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( OnlineDot(
isOnline: serverHealthy, isOnline: serverHealthy,
label: isConnected label: statusLabel,
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…') ),
: (vpn.serverReachable ? 'Ready' : 'Offline'), const Spacer(),
if (isConnected)
const Icon(Icons.lock,
color: AppColors.green50, size: 14),
],
),
), ),
], ],
), ),
+25 -1
View File
@@ -73,7 +73,32 @@ class AdManager extends ChangeNotifier {
return _loadCompleter!.future; return _loadCompleter!.future;
} }
// ── Show ad (or debug bypass) ─────────────────────────────────────
Future<bool> showAd(String adType) async { 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 (!adsEnabled) return false;
if (!_isAdLoaded || _rewardedAd == null) { if (!_isAdLoaded || _rewardedAd == null) {
@@ -123,7 +148,6 @@ class AdManager extends ChangeNotifier {
}, },
); );
// Show with SSV options
_rewardedAd!.setServerSideOptions(ssvOptions); _rewardedAd!.setServerSideOptions(ssvOptions);
await _rewardedAd!.show( await _rewardedAd!.show(
onUserEarnedReward: (AdWithoutView ad, RewardItem reward) { onUserEarnedReward: (AdWithoutView ad, RewardItem reward) {
+91 -43
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -10,6 +11,73 @@ class HivemindService {
static String? _expectedNonce; static String? _expectedNonce;
static int _currentCallId = 0; 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() { static void cancel() {
_currentCallId++; _currentCallId++;
} }
@@ -18,12 +86,7 @@ class HivemindService {
_expectedNonce = nonce; _expectedNonce = nonce;
} }
/// Fetch a real VLESS session config from the tunnel-internal API. static Future<String> fetchConfigDirectly({
///
/// 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({
void Function(int attempt, int total)? onAttempt, void Function(int attempt, int total)? onAttempt,
bool skipAdBypass = false, bool skipAdBypass = false,
}) async { }) async {
@@ -34,16 +97,14 @@ class HivemindService {
_expectedNonce = nonce; _expectedNonce = nonce;
debugPrint('[HivemindService] Call #$callId — nonce: $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. if (!skipAdBypass) {
// Goes through the tunnel to 10.254.254.1:5000 — same internal API.
if (kDebugMode && !skipAdBypass) {
try { try {
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce}); 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)}'); '/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 (_) {} } catch (_) {}
} }
@@ -53,7 +114,7 @@ class HivemindService {
onAttempt?.call(i, maxAttempts); onAttempt?.call(i, maxAttempts);
try { try {
final response = await http.get(url).timeout(const Duration(seconds: 5)); final response = await directGet(url);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
@@ -62,15 +123,8 @@ class HivemindService {
debugPrint('[HivemindService] Nonce mismatch — retrying…'); debugPrint('[HivemindService] Nonce mismatch — retrying…');
} else if (data['active'] == true && data['vless_uuid'] != null) { } else if (data['active'] == true && data['vless_uuid'] != null) {
final vlessUuid = data['vless_uuid']; 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; final vlessIp = data['vless_ip'] ?? AppConfig.serverIp;
if (vlessIp != AppConfig.serverIp) { final vlessPort = data['vless_port'] ?? 443;
throw Exception('API returned unexpected tunnel IP — possible MITM');
}
final vlessPort = data['vless_port'] ?? 8443;
final pbk = data['reality_pbk'] ?? ''; final pbk = data['reality_pbk'] ?? '';
final sid = data['reality_sid'] ?? ''; final sid = data['reality_sid'] ?? '';
final sni = data['reality_sni']; final sni = data['reality_sni'];
@@ -78,17 +132,15 @@ class HivemindService {
final fp = data['reality_fp'] ?? AppConfig.realityFp; final fp = data['reality_fp'] ?? AppConfig.realityFp;
final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath; 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' final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort'
'?security=${AppConfig.vlessSecurity}' '?security=${AppConfig.vlessSecurity}'
'&type=${AppConfig.vlessType}' '&type=${AppConfig.vlessType}'
'&path=${Uri.encodeComponent(xhttpPath)}' '&path=$xhttpPath'
'&pbk=${Uri.encodeComponent(pbk)}' '&pbk=${Uri.encodeComponent(pbk)}'
'&sni=${Uri.encodeComponent(sni)}' '&sni=${Uri.encodeComponent(sni)}'
'&sid=${Uri.encodeComponent(sid)}' '&sid=${Uri.encodeComponent(sid)}'
'&fp=${Uri.encodeComponent(fp)}' '&fp=${Uri.encodeComponent(fp)}'
'#ReVoltVPN'; '#Revolt VPN';
_expectedNonce = null; _expectedNonce = null;
return vlessUrl; return vlessUrl;
} }
@@ -106,8 +158,8 @@ class HivemindService {
static Future<bool> checkHealth() async { static Future<bool> checkHealth() async {
try { try {
final url = _url('/health'); final url = Uri.parse('${AppConfig.hivemindApiPublic}/health');
final response = await http.get(url).timeout(const Duration(seconds: 3)); final response = await directGet(url, timeout: const Duration(seconds: 3));
return response.statusCode == 200; return response.statusCode == 200;
} catch (_) { } catch (_) {
return false; return false;
@@ -117,8 +169,8 @@ class HivemindService {
static Future<bool> verifySession() async { static Future<bool> verifySession() async {
try { try {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
final url = _url('/session/status?device_id=$deviceId'); final url = _tunnelUrl('/session/status?device_id=$deviceId');
final response = await http.get(url).timeout(const Duration(seconds: 5)); final response = await proxyGet(url);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
return data['active'] == true; return data['active'] == true;
@@ -127,19 +179,15 @@ class HivemindService {
return false; return false;
} }
static Future<void> disconnectAndCleanup() async { // Idle sessions are culled server-side by the management loop
final deviceId = await CryptoService.getDeviceId(); // after 2 minutes of no /status polls (user disconnected).
final url = _url('/session/stop'); static void disconnectAndCleanup() {}
try {
await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'device_id': deviceId}),
).timeout(const Duration(seconds: 3));
} catch (_) {}
}
static Uri _url(String path) { // ── URL builders ──────────────────────────────────────────────────
return Uri.parse('${AppConfig.hivemindApiBase}$path');
} static Uri _publicUrl(String path) =>
Uri.parse('${AppConfig.hivemindApiPublic}$path');
static Uri _tunnelUrl(String path) =>
Uri.parse('${AppConfig.hivemindApiBase}$path');
} }
+69
View File
@@ -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
View File
@@ -1,11 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; 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/app_config.dart';
import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/crypto_service.dart'; import 'package:revoltvpn/logic/crypto_service.dart';
import 'package:revoltvpn/components/notification.dart';
class SessionTimer extends ChangeNotifier { class SessionTimer extends ChangeNotifier {
Timer? _timer; Timer? _timer;
@@ -19,15 +18,9 @@ class SessionTimer extends ChangeNotifier {
bool _hasSyncedOnce = false; bool _hasSyncedOnce = false;
int _consecutiveFailures = 0; int _consecutiveFailures = 0;
bool _isDisconnecting = false; bool _isDisconnecting = false;
bool _userInitiatedStop = false;
bool _syncInProgress = false; bool _syncInProgress = false;
int? _currentPort;
bool _portSwitchInProgress = false;
static const int _maxConsecutiveFailures = 3; static const int _maxConsecutiveFailures = 3;
static const int _maxOfflineSeconds = 120; static const int _maxOfflineSeconds = 120;
int _offlineSeconds = 0; int _offlineSeconds = 0;
@@ -36,9 +29,6 @@ class SessionTimer extends ChangeNotifier {
int _lastUsedBytes = 0; int _lastUsedBytes = 0;
double _currentSpeedKBps = 0.0; double _currentSpeedKBps = 0.0;
String _lastNotifTime = '';
String _lastNotifSpeed = '';
SessionTimer({required this.vpnConnection}) { SessionTimer({required this.vpnConnection}) {
vpnConnection.addListener(_onVpnConnectionChanged); vpnConnection.addListener(_onVpnConnectionChanged);
} }
@@ -58,11 +48,8 @@ class SessionTimer extends ChangeNotifier {
return '$h:$m:$s'; return '$h:$m:$s';
} }
// Lifecycle
/// Fires whenever VpnConnection state changes.
void _onVpnConnectionChanged() { void _onVpnConnectionChanged() {
// Restore timer if VPN was already running at app launch.
if (vpnConnection.status == VpnStatus.connected && if (vpnConnection.status == VpnStatus.connected &&
!isRunning && !isRunning &&
vpnConnection.isStartupRestoration) { vpnConnection.isStartupRestoration) {
@@ -70,21 +57,18 @@ class SessionTimer extends ChangeNotifier {
return; return;
} }
// Resume after brief tunnel disconnection.
// The underlying VLESS tunnel may briefly flap. If we weren't
// explicitly stopped by the user, resume where we left off.
if (vpnConnection.status == VpnStatus.connected && if (vpnConnection.status == VpnStatus.connected &&
!isRunning && !isRunning &&
!_userInitiatedStop && !_isDisconnecting &&
_hasSyncedOnce) { _hasSyncedOnce) {
debugPrint('[Timer] VPN reconnected after blip — resuming.'); debugPrint('[Timer] VPN reconnected after blip — resuming.');
_resumeTicking(); _resumeTicking();
return; return;
} }
if (vpnConnection.status == VpnStatus.disconnected && !_isDisconnecting) {
if (vpnConnection.status == VpnStatus.disconnected && isRunning) { _doDisconnect('VPN tunnel dropped');
_stopInternal();
} }
} }
@@ -98,47 +82,32 @@ class SessionTimer extends ChangeNotifier {
_consecutiveFailures = 0; _consecutiveFailures = 0;
_offlineSeconds = 0; _offlineSeconds = 0;
_isDisconnecting = false; _isDisconnecting = false;
_userInitiatedStop = false;
_currentPort = null;
_portSwitchInProgress = false;
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick); _timer = Timer.periodic(const Duration(seconds: 1), _tick);
notifyListeners(); notifyListeners();
// Kick off the first sync — the UI will show real data as soon as it lands.
_syncWithHivemind(); _syncWithHivemind();
} }
void _tick(Timer t) { void _tick(Timer t) {
if (_isDisconnecting) return;
// Decrement the local clock as long as we're not in a network blackout.
if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) { if (_hasSyncedOnce && _consecutiveFailures < _maxConsecutiveFailures) {
if (_remainingSeconds > 0) { if (_remainingSeconds > 0) {
_remainingSeconds--; _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) { if (_consecutiveFailures >= _maxConsecutiveFailures) {
_offlineSeconds++; _offlineSeconds++;
if (_offlineSeconds >= _maxOfflineSeconds) { if (_offlineSeconds >= _maxOfflineSeconds) {
debugPrint('[Timer] Offline for $_maxOfflineSeconds s — forcing disconnect.'); _doDisconnect('Server unreachable');
_forceDisconnect('Session lost — server unreachable');
return; 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) { if (_hasSyncedOnce && _remainingSeconds <= 0) {
debugPrint('[Timer] Local countdown expired — disconnecting.'); _doDisconnect('Session expired');
_forceDisconnect('Session expired');
return; return;
} }
@@ -147,68 +116,53 @@ class SessionTimer extends ChangeNotifier {
_syncWithHivemind(); _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(); notifyListeners();
} }
void _forceDisconnect(String reason) { Future<void> disconnect({String reason = 'User requested'}) async {
if (_isDisconnecting) return; // Already tearing down await _doDisconnect(reason);
debugPrint('[Timer] $reason — forcing disconnect.'); }
Future<void> _doDisconnect(String reason) async {
if (_isDisconnecting) return;
_isDisconnecting = true;
debugPrint('[Timer] Disconnecting: $reason');
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
_currentSpeedKBps = 0.0; _currentSpeedKBps = 0.0;
_isDisconnecting = true; _remainingSeconds = 0;
VpnNotificationManager.cancel(); _hasSyncedOnce = false;
notifyListeners();
await vpnConnection.disconnect();
if (!_isDisconnecting) return;
_isDisconnecting = false;
notifyListeners(); notifyListeners();
// Await the disconnect so the tunnel is confirmed down before we
// consider the session fully cleaned up.
vpnConnection.disconnect();
} }
Future<void> _syncWithHivemind() async { Future<void> _syncWithHivemind() async {
// Never sync while the user is explicitly disconnecting,
// or while another sync is already in flight.
if (_isDisconnecting || _syncInProgress) return; if (_isDisconnecting || _syncInProgress) return;
_syncInProgress = true; _syncInProgress = true;
try { try {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
final url = Uri.parse( final url = Uri.parse(
'${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); '${AppConfig.hivemindApiPublic}/session/status?device_id=$deviceId');
final response = await http.get(url).timeout(const Duration(seconds: 5)); final response = await HivemindService.directGet(url);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
final bool active = data['active'] ?? false; final bool active = data['active'] ?? false;
if (!active) { if (!active) {
// Server killed the session — tear down and wait for it. await _doDisconnect('Server ended session');
stop();
await vpnConnection.disconnect();
return; return;
} }
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; _remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
_usedBytes = data['used_bytes'] ?? _usedBytes; _usedBytes = data['used_bytes'] ?? _usedBytes;
final int deltaBytes = _usedBytes - _lastUsedBytes; final int deltaBytes = _usedBytes - _lastUsedBytes;
if (_hasSyncedOnce && deltaBytes > 0) { if (_hasSyncedOnce && deltaBytes > 0) {
_currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000; _currentSpeedKBps = (deltaBytes / _pollIntervalSeconds) / 1000;
@@ -217,32 +171,15 @@ class SessionTimer extends ChangeNotifier {
} }
_lastUsedBytes = _usedBytes; _lastUsedBytes = _usedBytes;
final capExhausted = data['cap_exhausted'] ?? false;
// Port change — throttle engaged or disengaged if (capExhausted) {
final serverPort = data['vless_port']; await _doDisconnect('Data cap reached');
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;
return; return;
} }
_currentPort = serverPort;
_consecutiveFailures = 0; _consecutiveFailures = 0;
_offlineSeconds = 0; _offlineSeconds = 0;
_hasSyncedOnce = true; _hasSyncedOnce = true;
notifyListeners(); notifyListeners();
} else { } else {
_markSyncFailure(); _markSyncFailure();
@@ -258,23 +195,9 @@ class SessionTimer extends ChangeNotifier {
void _markSyncFailure() { void _markSyncFailure() {
_consecutiveFailures++; _consecutiveFailures++;
if (_consecutiveFailures >= _maxConsecutiveFailures) { if (_consecutiveFailures >= _maxConsecutiveFailures) {
notifyListeners(); // let the UI show "Reconnecting…"
}
}
void stop() {
_userInitiatedStop = true;
_isDisconnecting = true;
_timer?.cancel();
_timer = null;
_currentSpeedKBps = 0.0;
_hasSyncedOnce = false;
_remainingSeconds = 0;
VpnNotificationManager.cancel();
notifyListeners(); notifyListeners();
} }
}
void _resumeTicking() { void _resumeTicking() {
_timer?.cancel(); _timer?.cancel();
@@ -284,21 +207,10 @@ class SessionTimer extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void _stopInternal() {
_isDisconnecting = true;
_timer?.cancel();
_timer = null;
_currentSpeedKBps = 0.0;
_hasSyncedOnce = false;
VpnNotificationManager.cancel();
notifyListeners();
}
@override @override
void dispose() { void dispose() {
vpnConnection.removeListener(_onVpnConnectionChanged); vpnConnection.removeListener(_onVpnConnectionChanged);
_timer?.cancel(); _timer?.cancel();
VpnNotificationManager.cancel();
super.dispose(); super.dispose();
} }
} }
+59 -109
View File
@@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_vless/flutter_vless.dart'; import 'package:flutter_vless/flutter_vless.dart';
import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/app_config.dart';
enum VpnStatus { enum VpnStatus {
disconnected, disconnected,
@@ -23,7 +23,6 @@ class VpnConnection extends ChangeNotifier {
String? _errorMessage; String? _errorMessage;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
// True when the app was killed and reopened while the VPN was running.
bool _isStartupRestoration = false; bool _isStartupRestoration = false;
bool get isStartupRestoration => _isStartupRestoration; bool get isStartupRestoration => _isStartupRestoration;
@@ -53,19 +52,18 @@ class VpnConnection extends ChangeNotifier {
try { try {
await _vless.initializeVless( await _vless.initializeVless(
providerBundleIdentifier: 'com.paladinvpn.app', providerBundleIdentifier: 'com.paladinvpn.app',
notificationIconResourceType: 'drawable',
notificationIconResourceName: 'notification_icon',
); );
_initialized = true; _initialized = true;
} catch (e) { } catch (e) {
debugPrint('[VPN] VLESS init error (expected on emulator): $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(); _checkHealth();
_healthTimer = _healthTimer =
Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth()); Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth());
// Check if VPN was already running (app killed and reopened)
try { try {
final coreVersion = await _vless.getCoreVersion(); final coreVersion = await _vless.getCoreVersion();
debugPrint('[VPN] Xray core version: $coreVersion'); debugPrint('[VPN] Xray core version: $coreVersion');
@@ -95,8 +93,6 @@ class VpnConnection extends ChangeNotifier {
_setStatus(VpnStatus.disconnecting, 'Tearing down…'); _setStatus(VpnStatus.disconnecting, 'Tearing down…');
break; break;
case VlessConnectionState.unknown: 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 && if (_status != VpnStatus.connected &&
_status != VpnStatus.disconnected) { _status != VpnStatus.disconnected) {
_setStatus(VpnStatus.error, 'Connection failed'); _setStatus(VpnStatus.error, 'Connection failed');
@@ -105,20 +101,18 @@ class VpnConnection extends ChangeNotifier {
} }
} }
// ── Connect (V2: bootstrap → poll → real tunnel) ──────────────────── // ── Connect ────────────────────────────────────────────────────────
// //
// Flow: // 1. Direct HTTPS to the public API → get per-user VLESS URL
// 1. Connect with hardcoded bootstrap VLESS URL (bundle in APK) // 2. Start tunnel directly with that URL (no bootstrap, no switch)
// 2. Through bootstrap tunnel, poll 10.254.254.1:5000 for real config
// 3. Disconnect bootstrap
// 4. Connect with real per-session VLESS URL
// //
// Parameter quickReconnect is used by the session timer for throttle // The only hardcoded data in the APK is the Reality public key and
// port-swaps — same bootstrap flow, but skip the AdMob bypass (session // shortId (needed for every Reality handshake — unavoidable).
// is already active). // The tunnel UUID is per-user, assigned by Hivemind via AdMob SSV.
Future<bool> connect( Future<bool> connect({
{bool skipAdBypass = false, bool quickReconnect = false}) async { bool skipAdBypass = false,
}) async {
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
return false; return false;
} }
@@ -130,6 +124,15 @@ class VpnConnection extends ChangeNotifier {
return false; 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…'); _setStatus(VpnStatus.connecting, 'Establishing secure channel…');
_errorMessage = null; _errorMessage = null;
@@ -139,122 +142,72 @@ class VpnConnection extends ChangeNotifier {
return true; 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…'); _setStatus(VpnStatus.connecting, 'Fetching config…');
String realUrl; String realUrl;
try { try {
realUrl = await HivemindService.fetchConfigThroughTunnel( realUrl = await HivemindService.fetchConfigDirectly(
skipAdBypass: skipAdBypass, skipAdBypass: skipAdBypass,
onAttempt: (attempt, total) { onAttempt: (attempt, total) {
if (!quickReconnect) {
_setStatus( _setStatus(
VpnStatus.connecting, 'Contacting server ($attempt/$total)…'); VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
}
}, },
); );
} catch (e) { } catch (e) {
debugPrint('[VPN] Config fetch error: $e'); debugPrint('[VPN] Config fetch error: $e');
await _safeStopVless();
final raw = e.toString().replaceAll('Exception: ', ''); final raw = e.toString().replaceAll('Exception: ', '');
if (raw.contains('Cancelled')) { if (raw.contains('Cancelled')) return false;
return false; if (raw.contains('timed out') || raw.contains('Session not activated')) {
} else if (raw.contains('timed out') ||
raw.contains('Session not activated')) {
_errorMessage = _errorMessage =
'The server did not respond in time.\nCheck your connection and try again.'; 'The server did not respond in time.\nCheck your connection and try again.';
_setStatus(VpnStatus.error, 'Server unreachable'); _setStatus(VpnStatus.error, 'Server unreachable');
} else { } else {
_errorMessage = raw; _errorMessage = e.toString().replaceAll('Exception: ', '');
final short = raw.length > 35 ? '${raw.substring(0, 35)}…' : raw; _setStatus(VpnStatus.error, 'Config fetch error');
_setStatus(VpnStatus.error, short);
} }
return false; return false;
} }
if (_cancelled) { if (_cancelled) {
await _safeStopVless();
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
return false; return false;
} }
// ── Phase 3: Switch to real tunnel ───────────────────────────────
_setStatus(VpnStatus.connecting, 'Securing connection…'); _setStatus(VpnStatus.connecting, 'Securing connection…');
await _safeStopVless();
try { 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( await _vless.startVless(
remark: realParsed.remark.isNotEmpty ? realParsed.remark : 'ReVoltVPN', remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN',
config: realParsed.getFullConfiguration(), config: jsonEncode(configJson),
); );
} catch (e) { } catch (e) {
debugPrint('[VPN] Real tunnel error: $e'); debugPrint('[VPN] Tunnel start error: $e');
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.'; _errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
_setStatus(VpnStatus.error, 'Connection failed'); _setStatus(VpnStatus.error, 'Connection failed');
return false; return false;
} }
if (quickReconnect) {
_setStatus(VpnStatus.connected, 'Secured'); _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');
}
return true; return true;
} }
Future<void> disconnect({bool skipCleanup = false}) async { Future<void> disconnect() async {
_cancelled = true; _cancelled = true;
HivemindService.cancel(); // abort any in-progress polling HivemindService.cancel();
if (_status == VpnStatus.disconnected || if (_status == VpnStatus.disconnected ||
_status == VpnStatus.disconnecting) { _status == VpnStatus.disconnecting) {
return; return;
@@ -268,18 +221,28 @@ class VpnConnection extends ChangeNotifier {
return; return;
} }
bool timedOut = false;
try { try {
await _vless.stopVless().timeout( await _vless.stopVless().timeout(
const Duration(seconds: 5), const Duration(seconds: 5),
onTimeout: () => debugPrint('[VPN] stopVless() timed out.'), onTimeout: () => timedOut = true,
); );
} catch (e) { } catch (e) {
debugPrint('[VPN] VLESS stop error: $e'); debugPrint('[VPN] VLESS stop error: $e');
_errorMessage = 'VPN shutdown error.\nPlease restart the app.';
_setStatus(VpnStatus.error, 'Shutdown failed');
return;
} }
if (!skipCleanup) { if (timedOut) {
HivemindService.disconnectAndCleanup(); 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'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
} }
@@ -294,26 +257,13 @@ class VpnConnection extends ChangeNotifier {
notifyListeners(); 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 @override
void dispose() { void dispose() {
_healthTimer?.cancel(); _healthTimer?.cancel();
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
try { try {
_vless.stopVless(); _vless.stopVless();
} catch (_) { } catch (_) {}
// Best-effort — widget is being destroyed anyway
}
} }
super.dispose(); super.dispose();
} }
+2
View File
@@ -8,6 +8,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/session_timer.dart';
import 'package:revoltvpn/logic/ad_manager.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/consent_manager.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/screens/intro.dart'; import 'package:revoltvpn/screens/intro.dart';
@@ -59,6 +60,7 @@ class ReVoltApp extends StatelessWidget {
update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn), update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn),
), ),
ChangeNotifierProvider(create: (_) => AdManager()), ChangeNotifierProvider(create: (_) => AdManager()),
ChangeNotifierProvider(create: (_) => ServerList()..init()),
], ],
child: MaterialApp( child: MaterialApp(
title: 'ReVolt VPN', title: 'ReVolt VPN',
+1 -1
View File
@@ -90,7 +90,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text( const Text(
'REVOLT VPN', 'Revolt VPN',
style: TextStyle( style: TextStyle(
color: AppColors.textWhite, color: AppColors.textWhite,
fontSize: 28, 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:shared_preferences/shared_preferences.dart';
import 'package:revoltvpn/logic/app_colors.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; bool hapticEnabled = false;
class HapticFeedbackTile extends StatefulWidget { class HapticFeedbackTile extends StatefulWidget {
+6 -6
View File
@@ -207,26 +207,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_vless name: flutter_vless
sha256: aa66ea904e7593cc9b2f8f12c291a3a1f404377125d5cd9faa6399d444f511ab sha256: db242f7fe007d35b4d51c5bd5b895d816f141e4d4afc7038c3b6228c1c66dfb5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.4" version: "1.1.5"
flutter_vless_android: flutter_vless_android:
dependency: transitive dependency: transitive
description: description:
name: flutter_vless_android name: flutter_vless_android
sha256: f2f2c81a410a7341ea05dfe3643b213530be44d6574bc91fa29c0bd417d97ac2 sha256: "5e6fe73d3606ed8c73e87fac15efa7631e12ce18d39a4bd4e13b6c097c6d52b9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.4" version: "1.1.5"
flutter_vless_macos: flutter_vless_macos:
dependency: transitive dependency: transitive
description: description:
name: flutter_vless_macos name: flutter_vless_macos
sha256: "096c21bd9728ee2856325c85f60bc7075dda44f99205dd0baa82e5a1223a62a2" sha256: "2651eda1ae75aff8d0acd74f3d38980c71e7629075eee7aa57e52897479f973f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.4" version: "1.1.5"
flutter_vless_platform_interface: flutter_vless_platform_interface:
dependency: transitive dependency: transitive
description: description:
+2 -2
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 3.1.0+22 version: 3.2.0+23
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"
@@ -30,7 +30,7 @@ dependencies:
google_fonts: ^6.1.0 google_fonts: ^6.1.0
provider: ^6.1.1 provider: ^6.1.1
google_mobile_ads: ^4.0.0 google_mobile_ads: ^4.0.0
flutter_vless: 1.1.4 flutter_vless: 1.1.5
http: ^1.6.0 http: ^1.6.0
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
url_launcher: ^6.3.2 url_launcher: ^6.3.2