diff --git a/.gitignore b/.gitignore index c0dc50d..e183b68 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,18 @@ DOCUMENTATION-ARCHITECTURE-V2.md config.json RESILIENCE_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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b13bc3..e9ece6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Architecture — domain-free client @@ -83,7 +117,7 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH ### Added - **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. - Dependency: `package_info_plus`. diff --git a/README.md b/README.md index d95dd81..74c7af6 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ A free VPN app for Android. Open-source client, transparent infrastructure. Watc ## How it works 1. Watch a short rewarded ad -2. Server creates a temporary VLESS session (60 min / 4 GB) -3. Your traffic routes through a single server in Finland -4. When data runs out, you're throttled to 1.5 Mbps instead of cut off -5. Session expires — watch another ad or move on +2. Server creates a temporary VLESS session (2 hours / 10 GB) +3. Your traffic routes through a single server in Finland at full speed +4. When time or data runs out, session ends — watch another ad to continue +5. No throttling, no slow lane, no mid-session punishment No email. No password. No payment. The ad pays for the server. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 347fa8c..6c95280 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,11 +14,12 @@ + android:allowBackup="false" + android:extractNativeLibs="true"> if (vpn.status == VpnStatus.connected || vpn.status == VpnStatus.connecting) { _busy = false; - timer.stop(); - await vpn.disconnect(); + await timer.disconnect(); if (hapticEnabled) HapticFeedback.lightImpact(); return; } diff --git a/lib/components/notification.dart b/lib/components/notification.dart index 18579aa..4f8ee21 100644 --- a/lib/components/notification.dart +++ b/lib/components/notification.dart @@ -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 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 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 cancel() async { if (!_initialized) return; - await _plugin.cancel(888); + await _plugin.cancel(_notifId); } } - diff --git a/lib/components/sidebar_contents/website_button.dart b/lib/components/sidebar_contents/website_button.dart index 27550b7..ef6496a 100644 --- a/lib/components/sidebar_contents/website_button.dart +++ b/lib/components/sidebar_contents/website_button.dart @@ -6,7 +6,7 @@ class WebsiteButton extends StatelessWidget { const WebsiteButton({super.key}); static Future 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'); } diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart index 4acce4d..b45b2b6 100644 --- a/lib/components/status_bar.dart +++ b/lib/components/status_bar.dart @@ -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().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( + 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), + ], + ), ), ], ), diff --git a/lib/logic/ad_manager.dart b/lib/logic/ad_manager.dart index b1b64be..259c4c0 100644 --- a/lib/logic/ad_manager.dart +++ b/lib/logic/ad_manager.dart @@ -73,7 +73,32 @@ class AdManager extends ChangeNotifier { return _loadCompleter!.future; } + // ── Show ad (or debug bypass) ───────────────────────────────────── + Future 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) { diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index 405bac2..80defcc 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -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 directGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) { + return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout); + } + + static Future 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 proxyGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) { + return _proxyRequest('GET', uri, timeout: timeout); + } + + static Future proxyPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) { + return _proxyRequest('POST', uri, body: body, timeout: timeout); + } + + static Future _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 = { + '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( + '', + (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 fetchConfigThroughTunnel({ + static Future 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 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 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 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'); } diff --git a/lib/logic/server_list.dart b/lib/logic/server_list.dart new file mode 100644 index 0000000..f2e62ac --- /dev/null +++ b/lib/logic/server_list.dart @@ -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 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 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 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); + } +} diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 6c822d7..7838ead 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -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 disconnect({String reason = 'User requested'}) async { + await _doDisconnect(reason); + } + + Future _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 _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(); } } diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 506d19d..0414e5e 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -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 connect( - {bool skipAdBypass = false, bool quickReconnect = false}) async { + Future 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; + + // 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 disconnect({bool skipCleanup = false}) async { + Future 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 _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(); } diff --git a/lib/main.dart b/lib/main.dart index b8d1473..10f7d58 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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', diff --git a/lib/screens/intro.dart b/lib/screens/intro.dart index 3c86017..4196b25 100644 --- a/lib/screens/intro.dart +++ b/lib/screens/intro.dart @@ -90,7 +90,7 @@ class _IntroScreenState extends State with WidgetsBindingObserver { ), const SizedBox(height: 4), const Text( - 'REVOLT VPN', + 'Revolt VPN', style: TextStyle( color: AppColors.textWhite, fontSize: 28, diff --git a/lib/screens/server_selector/selection_screen.dart b/lib/screens/server_selector/selection_screen.dart new file mode 100644 index 0000000..7d5dba1 --- /dev/null +++ b/lib/screens/server_selector/selection_screen.dart @@ -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(); + + 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), + ); + }, + ), + ); + } +} diff --git a/lib/screens/settings/in_settings/haptic.dart b/lib/screens/settings/in_settings/haptic.dart index cf72522..8c06622 100644 --- a/lib/screens/settings/in_settings/haptic.dart +++ b/lib/screens/settings/in_settings/haptic.dart @@ -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 { diff --git a/pubspec.lock b/pubspec.lock index 047ec74..3d27d57 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -207,26 +207,26 @@ packages: dependency: "direct main" description: name: flutter_vless - sha256: aa66ea904e7593cc9b2f8f12c291a3a1f404377125d5cd9faa6399d444f511ab + sha256: db242f7fe007d35b4d51c5bd5b895d816f141e4d4afc7038c3b6228c1c66dfb5 url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.1.5" flutter_vless_android: dependency: transitive description: name: flutter_vless_android - sha256: f2f2c81a410a7341ea05dfe3643b213530be44d6574bc91fa29c0bd417d97ac2 + sha256: "5e6fe73d3606ed8c73e87fac15efa7631e12ce18d39a4bd4e13b6c097c6d52b9" url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.1.5" flutter_vless_macos: dependency: transitive description: name: flutter_vless_macos - sha256: "096c21bd9728ee2856325c85f60bc7075dda44f99205dd0baa82e5a1223a62a2" + sha256: "2651eda1ae75aff8d0acd74f3d38980c71e7629075eee7aa57e52897479f973f" url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.1.5" flutter_vless_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 12bbd6a..99d3bf1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.1.0+22 +version: 3.2.0+23 environment: sdk: ">=3.0.0 <4.0.0" @@ -30,7 +30,7 @@ dependencies: google_fonts: ^6.1.0 provider: ^6.1.1 google_mobile_ads: ^4.0.0 - flutter_vless: 1.1.4 + flutter_vless: 1.1.5 http: ^1.6.0 shared_preferences: ^2.5.5 url_launcher: ^6.3.2