From 71468f75942036b5e46e46db72c6e1beadfe2964 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 22 Aug 2026 11:34:17 +0300 Subject: [PATCH] frontend polish and security review changes --- .gitignore | 114 +++++++----------- .../kotlin/com/paladinvpn/app/MainActivity.kt | 39 +++++- .../res/drawable/ic_launcher_background.xml | 2 +- .../src/main/res/values-night-v31/styles.xml | 16 +++ .../app/src/main/res/values-night/styles.xml | 17 ++- .../app/src/main/res/values-v31/styles.xml | 16 +++ android/app/src/main/res/values/styles.xml | 15 +-- lib/logic/ad_manager.dart | 24 ++++ lib/logic/app_config.example.dart | 3 - lib/logic/hivemind_service.dart | 81 ------------- lib/logic/vpn_connection.dart | 27 ++--- lib/main.dart | 25 ++-- lib/screens/intro.dart | 74 +++++++++--- 13 files changed, 226 insertions(+), 227 deletions(-) create mode 100644 android/app/src/main/res/values-night-v31/styles.xml create mode 100644 android/app/src/main/res/values-v31/styles.xml diff --git a/.gitignore b/.gitignore index e183b68..6918e26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,15 @@ # ============================================================================= # ReVoltVPN — .gitignore +# +# Type-based by design. This repo ships Dart, Kotlin, XML and image assets; +# everything else is excluded by extension or directory rather than by being +# listed individually. A new file is therefore ignored by default instead of +# needing to be named here, and this file does not enumerate what it protects. +# +# Machine-local exclusions live in .git/info/exclude, which is never committed. # ============================================================================= -# ── OS & Editor ──────────────────────────────────────────────────────────── +# ── OS & editor ──────────────────────────────────────────────────────────── *.swp .DS_Store .atom/ @@ -12,11 +19,7 @@ .idea/ .vscode/ -# ── Python ───────────────────────────────────────────────────────────────── -__pycache__/ -*.pyc - -# ── Flutter / Dart ───────────────────────────────────────────────────────── +# ── Flutter / Dart build output ──────────────────────────────────────────── .dart_tool/ .flutter-plugins .flutter-plugins-dependencies @@ -30,7 +33,7 @@ local_packages/*/.dart_tool/ **/doc/api/ **/ios/Flutter/.last_build_id -# ── Android ──────────────────────────────────────────────────────────────── +# ── Android build output ─────────────────────────────────────────────────── **/android/**/gradle-wrapper.jar **/android/.gradle **/android/captures/ @@ -43,80 +46,45 @@ local_packages/*/.dart_tool/ **/android/app/build/ **/android/app/crashlytics-build.properties -# ── Secrets & Keystores ─────────────────────────────────────────────────── +# ── Python bytecode ──────────────────────────────────────────────────────── +__pycache__/ +*.pyc + +# ── Credentials & machine-local config ───────────────────────────────────── *.keystore *.jks android/key.properties android/admob.properties - -# App config (contains real IPs/IDs — use example as template) lib/logic/app_config.dart +devtools_options.yaml -# ── Hivemind server (deployed separately, not part of the Flutter repo) ─── -hivemind/ -server_hivemind_5_0.py -swarm_watch.py -nginx_hivemind_reality.conf -xray_config_reality.json -swarm_total.json - -# ── Internal docs (not for public repo) ──────────────────────────────────── -AGENTS.md -FRONTEND_POLISH_REVIEW.md -RUNNING.md -SERVER_DEPLOYMENT.md -SERVER_NOTES.md -SERVER_SETUP.md -REALITY_MIGRATION_PLAN.md -SECURITY_HARDENING.md -VLESS_APP_CONTRACT.md -UI_POSITIONING.md - -# ── Technical debt & security audits ─────────────────────────────────────── -technical_debt_*.md -technical_debt_*_answer.md - -# ── Backups & reference files ────────────────────────────────────────────── -backups/ -backup/ -hivemind_backup.py -server full picture.txt -server_full_picture_2.txt -00-FULL-REPORT.md -XRAY_CONFIG_REFERENCE.md - -# ── Logs & temp ──────────────────────────────────────────────────────────── +# ── File types the shipped app does not contain ──────────────────────────── +# Server code, infrastructure configs, captures, archives and logs. None of +# these extensions are tracked, so a blanket rule is safe and stays safe. +*.py +*.json +*.conf +*.sh +*.txt +*.html +*.zip *.log *.class -server_picture.sh -# ── AI assistant ─────────────────────────────────────────────────────────── -.codewhale/ +# ── Documentation ────────────────────────────────────────────────────────── +# The published set is allow-listed; anything else, at any depth, is not. +# Leading slashes anchor these to the repo root so a same-named file in a +# subdirectory is NOT re-included by the negation. +*.md +!/README.md +!/CHANGELOG.md +!/CONTRIBUTING.md +!/PRIVACY_POLICY.md -# ── Dev tool config ──────────────────────────────────────────────────────── -devtools_options.yaml +# ── Local working directories ────────────────────────────────────────────── +# Working material that is not part of the app. Needs a committed rule rather +# than a local-only one, because it can hold source-file types the extension +# rules above deliberately do not cover. +.local/ +backup/ lib/logic/backup/ -scraped -scraped.zip -hivemind.zip -XRAY_AUDIT.md -TECHNICAL_DEBT.md -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/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt index 774ec45..ba1bedb 100644 --- a/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt @@ -1,5 +1,42 @@ package com.paladinvpn.app +import android.os.Build import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + + // Channel id only — not the application id, which is com.paladinvpn.app. + // Must match the string in lib/logic/updater.dart. + private val installerChannel = "com.revoltvpn.app/installer" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, installerChannel) + .setMethodCallHandler { call, result -> + if (call.method == "getInstallerPackage") { + result.success(installerPackage()) + } else { + result.notImplemented() + } + } + } + + /** + * Package name of whatever installed us ("com.android.vending" for Play + * Store), or null when unknown. Without this the Dart side always falls + * back to sideload and points every user at GitHub releases. + */ + private fun installerPackage(): String? = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + packageManager.getInstallerPackageName(packageName) + } + } catch (e: Exception) { + null + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml index 3f46c10..7d5a98e 100644 --- a/android/app/src/main/res/drawable/ic_launcher_background.xml +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,5 +1,5 @@ - + diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..c83e208 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..728cb8e 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -1,18 +1,15 @@ - - diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..c83e208 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index b0b909b..728cb8e 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,18 +1,15 @@ - - diff --git a/lib/logic/ad_manager.dart b/lib/logic/ad_manager.dart index 259c4c0..51a8fe7 100644 --- a/lib/logic/ad_manager.dart +++ b/lib/logic/ad_manager.dart @@ -6,6 +6,7 @@ import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:revoltvpn/logic/crypto_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/app_config.dart'; +import 'package:revoltvpn/logic/consent_manager.dart'; class AdManager extends ChangeNotifier { static const bool adsEnabled = false; @@ -27,8 +28,29 @@ class AdManager extends ChangeNotifier { if (adsEnabled) preloadAd(); } + static Future? _sdkInit; + static Future ensureSdkInitialized() { + if (!adsEnabled) return Future.value(); + return _sdkInit ??= _initSdk(); + } + + static Future _initSdk() async { + try { + await ConsentManager.requestConsentIfNeeded() + .timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[AdManager] Consent init skipped: $e'); + } + try { + await MobileAds.instance.initialize().timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[AdManager] MobileAds init skipped: $e'); + } + } + Future preloadAd() async { if (!adsEnabled) return true; + await ensureSdkInitialized(); if (_isAdLoaded) return true; if (_isAdLoading) return _loadCompleter?.future ?? Future.value(false); @@ -101,6 +123,8 @@ class AdManager extends ChangeNotifier { if (!adsEnabled) return false; + await ensureSdkInitialized(); + if (!_isAdLoaded || _rewardedAd == null) { final loaded = await preloadAd(); if (!loaded || _rewardedAd == null) { diff --git a/lib/logic/app_config.example.dart b/lib/logic/app_config.example.dart index 8198e12..a1abc5c 100644 --- a/lib/logic/app_config.example.dart +++ b/lib/logic/app_config.example.dart @@ -9,9 +9,6 @@ abstract final class AppConfig { /// Your server's public IP. The VPN tunnel is pinned to this. static const String serverIp = '0.0.0.0'; // ◄── REPLACE - /// API base — routed through the tunnel to Flask via Xray "api" outbound. - static const String hivemindApiBase = 'http://10.254.254.1:5000/api'; - // ── Bootstrap (first-connect config fetch) ──────────────────────────── /// Hardcoded Reality config. App connects with this, fetches the real /// per-session VLESS URL through the tunnel, then reconnects. diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index 80defcc..609e732 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @@ -18,66 +17,6 @@ class HivemindService { 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++; } @@ -166,28 +105,8 @@ class HivemindService { } } - static Future verifySession() async { - try { - final deviceId = await CryptoService.getDeviceId(); - 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; - } - } catch (_) {} - return false; - } - - // Idle sessions are culled server-side by the management loop - // after 2 minutes of no /status polls (user disconnected). - static void disconnectAndCleanup() {} - // ── 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/vpn_connection.dart b/lib/logic/vpn_connection.dart index 0414e5e..ef39336 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -1,5 +1,4 @@ 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'; @@ -33,12 +32,24 @@ class VpnConnection extends ChangeNotifier { late final FlutterVless _vless; bool _initialized = false; + final Completer _readyCompleter = Completer(); + Future get ready => _readyCompleter.future; VpnConnection() { _init(); } Future _init() async { + try { + await _startEngine(); + } catch (e) { + debugPrint('[VPN] Engine init failed: $e'); + } finally { + if (!_readyCompleter.isCompleted) _readyCompleter.complete(); + } + } + + Future _startEngine() async { if (kIsWeb) return; _vless = FlutterVless( @@ -177,22 +188,10 @@ class VpnConnection extends ChangeNotifier { try { 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: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN', - config: jsonEncode(configJson), + config: parsed.getFullConfiguration(), ); } catch (e) { debugPrint('[VPN] Tunnel start error: $e'); diff --git a/lib/main.dart b/lib/main.dart index 10f7d58..3d4dd22 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,18 +2,14 @@ import 'package:flutter/material.dart'; import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:google_mobile_ads/google_mobile_ads.dart'; -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'; - -void main() async { +void main() { WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); @@ -34,33 +30,28 @@ void main() async { return true; }; - await ConsentManager.requestConsentIfNeeded(); - await MobileAds.instance.initialize(); - - final packageInfo = await PackageInfo.fromPlatform(); - ReVoltApp.appVersion = packageInfo.version; - runApp(const ReVoltApp()); } class ReVoltApp extends StatelessWidget { const ReVoltApp({super.key}); - /// Set at startup from PackageInfo — used by sidebar and anywhere - /// the app version needs to be displayed. - static String appVersion = ''; - @override Widget build(BuildContext context) { return MultiProvider( providers: [ - ChangeNotifierProvider(create: (_) => VpnConnection()), + // lazy: false — the native VLESS engine takes real time to start, and + // providers are lazy by default, so it used to begin initialising only + // once MainScreen first read it, i.e. *after* the loading screen was + // gone. Constructing it eagerly moves that work under the intro, which + // now awaits VpnConnection.ready before navigating. + ChangeNotifierProvider(create: (_) => VpnConnection(), lazy: false), ChangeNotifierProxyProvider( create: (ctx) => SessionTimer(vpnConnection: ctx.read()), update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn), ), ChangeNotifierProvider(create: (_) => AdManager()), - ChangeNotifierProvider(create: (_) => ServerList()..init()), + ChangeNotifierProvider(create: (_) => ServerList()..init(), lazy: false), ], child: MaterialApp( title: 'ReVolt VPN', diff --git a/lib/screens/intro.dart b/lib/screens/intro.dart index 4196b25..179d41a 100644 --- a/lib/screens/intro.dart +++ b/lib/screens/intro.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:revoltvpn/logic/app_colors.dart'; +import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/screens/main_screen.dart'; class IntroScreen extends StatefulWidget { @@ -11,11 +13,18 @@ class IntroScreen extends StatefulWidget { } class _IntroScreenState extends State with WidgetsBindingObserver { + static const _minDisplay = Duration(milliseconds: 500); + static const _bootTimeout = Duration(seconds: 8); + + String _status = 'Starting secure engine…'; + bool _bootComplete = false; + bool _navigated = false; + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _scheduleNavigation(); + _boot(); } @override @@ -26,28 +35,43 @@ class _IntroScreenState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - _navigateToMain(); + if (state == AppLifecycleState.resumed) _navigateIfReady(); + } + + Future _boot() async { + final elapsed = Stopwatch()..start(); + final vpn = context.read(); + + try { + await vpn.ready.timeout(_bootTimeout); + } on TimeoutException { + debugPrint('[Boot] Engine init exceeded ${_bootTimeout.inSeconds}s ' + '— continuing to the main screen anyway.'); + } catch (e) { + debugPrint('[Boot] Engine init error: $e'); } - } - void _scheduleNavigation() { - // Brief natural pause — just long enough to perceive the logo. - Timer(const Duration(milliseconds: 1200), () { - if (!mounted) return; - if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) { - // App is backgrounded — defer navigation until resumed. - return; - } - _navigateToMain(); - }); - } - - void _navigateToMain() { if (!mounted) return; + setState(() => _status = 'Ready'); + + final remaining = _minDisplay - elapsed.elapsed; + if (remaining > Duration.zero) await Future.delayed(remaining); + + if (!mounted) return; + _bootComplete = true; + _navigateIfReady(); + } + + void _navigateIfReady() { + if (!mounted || _navigated || !_bootComplete) return; + if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) { + return; // resume will call back in + } + _navigated = true; + Navigator.of(context).pushReplacement( PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 600), + transitionDuration: const Duration(milliseconds: 400), pageBuilder: (_, __, ___) => const MainScreen(), transitionsBuilder: (_, animation, __, child) { return FadeTransition(opacity: animation, child: child); @@ -98,6 +122,20 @@ class _IntroScreenState extends State with WidgetsBindingObserver { letterSpacing: 2.0, ), ), + const SizedBox(height: 10), + // Says what the spinner is actually waiting on. + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: Text( + _status, + key: ValueKey(_status), + style: const TextStyle( + color: AppColors.textDim, + fontSize: 13, + letterSpacing: 0.5, + ), + ), + ), const Spacer(flex: 5), const SizedBox(height: 12), ],