frontend polish and security review changes

This commit is contained in:
esefxdz committed 2026-08-22 11:34:17 +03:00
1 parent 5642b0b811
commit 71468f7594
13 files changed
+222 -223

No files matched your search

+41 -73
View File
@@ -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
@@ -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
}
}
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0d1017" />
<solid android:color="@color/splash_bg" />
</shape>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowSplashScreenBackground">@color/splash_bg</item>
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
</style>
</resources>
@@ -1,18 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
</style>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowSplashScreenBackground">@color/splash_bg</item>
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
</style>
</resources>
+6 -9
View File
@@ -1,18 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
</style>
</resources>
+24
View File
@@ -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<void>? _sdkInit;
static Future<void> ensureSdkInitialized() {
if (!adsEnabled) return Future.value();
return _sdkInit ??= _initSdk();
}
static Future<void> _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<bool> 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) {
-3
View File
@@ -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.
-81
View File
@@ -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<http.Response> directPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
return http.post(uri, headers: {
'Content-Type': 'application/json',
'User-Agent': _ua,
}, body: body).timeout(timeout);
}
static Future<http.Response> proxyGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
return _proxyRequest('GET', uri, timeout: timeout);
}
static Future<http.Response> proxyPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
return _proxyRequest('POST', uri, body: body, timeout: timeout);
}
static Future<http.Response> _proxyRequest(String method, Uri uri,
{String? body, Duration timeout = const Duration(seconds: 5)}) async {
final socket = await Socket.connect('127.0.0.1', 10808, timeout: timeout);
try {
final target = uri.toString();
final headers = <String, String>{
'Host': '${uri.host}:${uri.port}',
'User-Agent': _ua,
'Accept': '*/*',
'Connection': 'close',
};
if (body != null) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = body.length.toString();
}
final reqBuf = StringBuffer();
reqBuf.write('$method $target HTTP/1.1\r\n');
headers.forEach((k, v) => reqBuf.write('$k: $v\r\n'));
reqBuf.write('\r\n');
if (body != null) reqBuf.write(body);
socket.write(reqBuf.toString());
await socket.flush();
final response = await socket.fold<String>(
'',
(prev, chunk) => prev + String.fromCharCodes(chunk),
).timeout(timeout);
final parts = response.split('\r\n\r\n');
if (parts.length < 2) throw Exception('Invalid proxy response');
final headerBlock = parts[0];
final respBody = parts.sublist(1).join('\r\n\r\n');
final headerLines = headerBlock.split('\r\n');
final statusParts = headerLines[0].split(' ');
final statusCode = int.parse(statusParts[1]);
return http.Response(respBody, statusCode);
} finally {
socket.destroy();
}
}
static void cancel() {
_currentCallId++;
}
@@ -166,28 +105,8 @@ class HivemindService {
}
}
static Future<bool> 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');
}
+13 -14
View File
@@ -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<void> _readyCompleter = Completer<void>();
Future<void> get ready => _readyCompleter.future;
VpnConnection() {
_init();
}
Future<void> _init() async {
try {
await _startEngine();
} catch (e) {
debugPrint('[VPN] Engine init failed: $e');
} finally {
if (!_readyCompleter.isCompleted) _readyCompleter.complete();
}
}
Future<void> _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<String, dynamic>;
// Inject HTTP inbound for tunnel-internal API calls.
final inbounds = (configJson['inbounds'] as List?) ?? [];
inbounds.add({
'tag': 'http-proxy',
'port': 10808,
'listen': '127.0.0.1',
'protocol': 'http',
});
configJson['inbounds'] = inbounds;
await _vless.startVless(
remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN',
config: jsonEncode(configJson),
config: parsed.getFullConfiguration(),
);
} catch (e) {
debugPrint('[VPN] Tunnel start error: $e');
+8 -17
View File
@@ -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<VpnConnection, SessionTimer>(
create: (ctx) => SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
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',
+52 -14
View File
@@ -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<IntroScreen> 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<IntroScreen> with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_navigateToMain();
if (state == AppLifecycleState.resumed) _navigateIfReady();
}
Future<void> _boot() async {
final elapsed = Stopwatch()..start();
final vpn = context.read<VpnConnection>();
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;
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) {
// App is backgrounded — defer navigation until resumed.
return;
}
_navigateToMain();
});
return; // resume will call back in
}
_navigated = true;
void _navigateToMain() {
if (!mounted) return;
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<IntroScreen> 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),
],