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