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
+226 -227

No files matched your search

+41 -73
View File
@@ -1,8 +1,15 @@
# ============================================================================= # =============================================================================
# ReVoltVPN — .gitignore # 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 *.swp
.DS_Store .DS_Store
.atom/ .atom/
@@ -12,11 +19,7 @@
.idea/ .idea/
.vscode/ .vscode/
# ── Python ───────────────────────────────────────────────────────────────── # ── Flutter / Dart build output ────────────────────────────────────────────
__pycache__/
*.pyc
# ── Flutter / Dart ─────────────────────────────────────────────────────────
.dart_tool/ .dart_tool/
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
@@ -30,7 +33,7 @@ local_packages/*/.dart_tool/
**/doc/api/ **/doc/api/
**/ios/Flutter/.last_build_id **/ios/Flutter/.last_build_id
# ── Android ──────────────────────────────────────────────────────────────── # ── Android build output ───────────────────────────────────────────────────
**/android/**/gradle-wrapper.jar **/android/**/gradle-wrapper.jar
**/android/.gradle **/android/.gradle
**/android/captures/ **/android/captures/
@@ -43,80 +46,45 @@ local_packages/*/.dart_tool/
**/android/app/build/ **/android/app/build/
**/android/app/crashlytics-build.properties **/android/app/crashlytics-build.properties
# ── Secrets & Keystores ─────────────────────────────────────────────────── # ── Python bytecode ────────────────────────────────────────────────────────
__pycache__/
*.pyc
# ── Credentials & machine-local config ─────────────────────────────────────
*.keystore *.keystore
*.jks *.jks
android/key.properties android/key.properties
android/admob.properties android/admob.properties
# App config (contains real IPs/IDs — use example as template)
lib/logic/app_config.dart lib/logic/app_config.dart
devtools_options.yaml
# ── Hivemind server (deployed separately, not part of the Flutter repo) ─── # ── File types the shipped app does not contain ────────────────────────────
hivemind/ # Server code, infrastructure configs, captures, archives and logs. None of
server_hivemind_5_0.py # these extensions are tracked, so a blanket rule is safe and stays safe.
swarm_watch.py *.py
nginx_hivemind_reality.conf *.json
xray_config_reality.json *.conf
swarm_total.json *.sh
*.txt
# ── Internal docs (not for public repo) ──────────────────────────────────── *.html
AGENTS.md *.zip
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 ────────────────────────────────────────────────────────────
*.log *.log
*.class *.class
server_picture.sh
# ── AI assistant ─────────────────────────────────────────────────────────── # ── Documentation ──────────────────────────────────────────────────────────
.codewhale/ # 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 ──────────────────────────────────────────────────────── # ── Local working directories ──────────────────────────────────────────────
devtools_options.yaml # 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/ 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 package com.paladinvpn.app
import android.os.Build
import io.flutter.embedding.android.FlutterActivity 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"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="rectangle">
<solid android:color="#0d1017" /> <solid android:color="@color/splash_bg" />
</shape> </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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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"> <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: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> </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"> <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> </style>
</resources> </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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> <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: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> </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"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@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>
</resources> </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/crypto_service.dart';
import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/app_config.dart'; import 'package:revoltvpn/logic/app_config.dart';
import 'package:revoltvpn/logic/consent_manager.dart';
class AdManager extends ChangeNotifier { class AdManager extends ChangeNotifier {
static const bool adsEnabled = false; static const bool adsEnabled = false;
@@ -27,8 +28,29 @@ class AdManager extends ChangeNotifier {
if (adsEnabled) preloadAd(); 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 { Future<bool> preloadAd() async {
if (!adsEnabled) return true; if (!adsEnabled) return true;
await ensureSdkInitialized();
if (_isAdLoaded) return true; if (_isAdLoaded) return true;
if (_isAdLoading) return _loadCompleter?.future ?? Future.value(false); if (_isAdLoading) return _loadCompleter?.future ?? Future.value(false);
@@ -101,6 +123,8 @@ class AdManager extends ChangeNotifier {
if (!adsEnabled) return false; if (!adsEnabled) return false;
await ensureSdkInitialized();
if (!_isAdLoaded || _rewardedAd == null) { if (!_isAdLoaded || _rewardedAd == null) {
final loaded = await preloadAd(); final loaded = await preloadAd();
if (!loaded || _rewardedAd == null) { 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. /// Your server's public IP. The VPN tunnel is pinned to this.
static const String serverIp = '0.0.0.0'; // ◄── REPLACE 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) ──────────────────────────── // ── Bootstrap (first-connect config fetch) ────────────────────────────
/// Hardcoded Reality config. App connects with this, fetches the real /// Hardcoded Reality config. App connects with this, fetches the real
/// per-session VLESS URL through the tunnel, then reconnects. /// per-session VLESS URL through the tunnel, then reconnects.
-81
View File
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -18,66 +17,6 @@ class HivemindService {
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout); return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
} }
static Future<http.Response> directPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
return http.post(uri, headers: {
'Content-Type': 'application/json',
'User-Agent': _ua,
}, body: body).timeout(timeout);
}
static Future<http.Response> proxyGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
return _proxyRequest('GET', uri, timeout: timeout);
}
static Future<http.Response> proxyPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
return _proxyRequest('POST', uri, body: body, timeout: timeout);
}
static Future<http.Response> _proxyRequest(String method, Uri uri,
{String? body, Duration timeout = const Duration(seconds: 5)}) async {
final socket = await Socket.connect('127.0.0.1', 10808, timeout: timeout);
try {
final target = uri.toString();
final headers = <String, String>{
'Host': '${uri.host}:${uri.port}',
'User-Agent': _ua,
'Accept': '*/*',
'Connection': 'close',
};
if (body != null) {
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = body.length.toString();
}
final reqBuf = StringBuffer();
reqBuf.write('$method $target HTTP/1.1\r\n');
headers.forEach((k, v) => reqBuf.write('$k: $v\r\n'));
reqBuf.write('\r\n');
if (body != null) reqBuf.write(body);
socket.write(reqBuf.toString());
await socket.flush();
final response = await socket.fold<String>(
'',
(prev, chunk) => prev + String.fromCharCodes(chunk),
).timeout(timeout);
final parts = response.split('\r\n\r\n');
if (parts.length < 2) throw Exception('Invalid proxy response');
final headerBlock = parts[0];
final respBody = parts.sublist(1).join('\r\n\r\n');
final headerLines = headerBlock.split('\r\n');
final statusParts = headerLines[0].split(' ');
final statusCode = int.parse(statusParts[1]);
return http.Response(respBody, statusCode);
} finally {
socket.destroy();
}
}
static void cancel() { static void cancel() {
_currentCallId++; _currentCallId++;
} }
@@ -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 ────────────────────────────────────────────────── // ── URL builders ──────────────────────────────────────────────────
static Uri _publicUrl(String path) => static Uri _publicUrl(String path) =>
Uri.parse('${AppConfig.hivemindApiPublic}$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:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_vless/flutter_vless.dart'; import 'package:flutter_vless/flutter_vless.dart';
import 'package:revoltvpn/logic/hivemind_service.dart'; import 'package:revoltvpn/logic/hivemind_service.dart';
@@ -33,12 +32,24 @@ class VpnConnection extends ChangeNotifier {
late final FlutterVless _vless; late final FlutterVless _vless;
bool _initialized = false; bool _initialized = false;
final Completer<void> _readyCompleter = Completer<void>();
Future<void> get ready => _readyCompleter.future;
VpnConnection() { VpnConnection() {
_init(); _init();
} }
Future<void> _init() async { 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; if (kIsWeb) return;
_vless = FlutterVless( _vless = FlutterVless(
@@ -177,22 +188,10 @@ class VpnConnection extends ChangeNotifier {
try { try {
final parsed = FlutterVless.parse(realUrl); final parsed = FlutterVless.parse(realUrl);
final configJson =
jsonDecode(parsed.getFullConfiguration()) as Map<String, dynamic>;
// Inject HTTP inbound for tunnel-internal API calls.
final inbounds = (configJson['inbounds'] as List?) ?? [];
inbounds.add({
'tag': 'http-proxy',
'port': 10808,
'listen': '127.0.0.1',
'protocol': 'http',
});
configJson['inbounds'] = inbounds;
await _vless.startVless( await _vless.startVless(
remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN', remark: parsed.remark.isNotEmpty ? parsed.remark : 'Revolt VPN',
config: jsonEncode(configJson), config: parsed.getFullConfiguration(),
); );
} catch (e) { } catch (e) {
debugPrint('[VPN] Tunnel start error: $e'); debugPrint('[VPN] Tunnel start error: $e');
+8 -17
View File
@@ -2,18 +2,14 @@ import 'package:flutter/material.dart';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.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/vpn_connection.dart';
import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/session_timer.dart';
import 'package:revoltvpn/logic/ad_manager.dart'; import 'package:revoltvpn/logic/ad_manager.dart';
import 'package:revoltvpn/logic/server_list.dart'; import 'package:revoltvpn/logic/server_list.dart';
import 'package:revoltvpn/logic/consent_manager.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/screens/intro.dart'; import 'package:revoltvpn/screens/intro.dart';
void main() {
void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
@@ -34,33 +30,28 @@ void main() async {
return true; return true;
}; };
await ConsentManager.requestConsentIfNeeded();
await MobileAds.instance.initialize();
final packageInfo = await PackageInfo.fromPlatform();
ReVoltApp.appVersion = packageInfo.version;
runApp(const ReVoltApp()); runApp(const ReVoltApp());
} }
class ReVoltApp extends StatelessWidget { class ReVoltApp extends StatelessWidget {
const ReVoltApp({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ 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>( ChangeNotifierProxyProvider<VpnConnection, SessionTimer>(
create: (ctx) => SessionTimer(vpnConnection: ctx.read<VpnConnection>()), create: (ctx) => SessionTimer(vpnConnection: ctx.read<VpnConnection>()),
update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn), update: (_, vpn, prev) => prev ?? SessionTimer(vpnConnection: vpn),
), ),
ChangeNotifierProvider(create: (_) => AdManager()), ChangeNotifierProvider(create: (_) => AdManager()),
ChangeNotifierProvider(create: (_) => ServerList()..init()), ChangeNotifierProvider(create: (_) => ServerList()..init(), lazy: false),
], ],
child: MaterialApp( child: MaterialApp(
title: 'ReVolt VPN', title: 'ReVolt VPN',
+56 -18
View File
@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart';
import 'package:revoltvpn/screens/main_screen.dart'; import 'package:revoltvpn/screens/main_screen.dart';
class IntroScreen extends StatefulWidget { class IntroScreen extends StatefulWidget {
@@ -11,11 +13,18 @@ class IntroScreen extends StatefulWidget {
} }
class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver { 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 @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_scheduleNavigation(); _boot();
} }
@override @override
@@ -26,28 +35,43 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) _navigateIfReady();
_navigateToMain(); }
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;
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.paused) {
// App is backgrounded — defer navigation until resumed.
return;
}
_navigateToMain();
});
}
void _navigateToMain() {
if (!mounted) return; 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( Navigator.of(context).pushReplacement(
PageRouteBuilder( PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 600), transitionDuration: const Duration(milliseconds: 400),
pageBuilder: (_, __, ___) => const MainScreen(), pageBuilder: (_, __, ___) => const MainScreen(),
transitionsBuilder: (_, animation, __, child) { transitionsBuilder: (_, animation, __, child) {
return FadeTransition(opacity: animation, child: child); return FadeTransition(opacity: animation, child: child);
@@ -98,6 +122,20 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
letterSpacing: 2.0, 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 Spacer(flex: 5),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],