frontend polish and security review changes
This commit is contained in:
1 parent
5642b0b811
commit
71468f7594
13 files changed
+222
-223
No files matched your search
+41
-73
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user