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