diff --git a/README.md b/README.md index 1dbc8bf..b21d468 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ So here it is. ## How it works You watch a short ad. You get 60 minutes and 2GB of full-speed traffic. That's it. The ad pays for the server (actually not even enough). No accounts, no emails, no subscriptions. -When your data runs out you get throttled to 1.5 Mbps instead of getting cut off. You can watch a bonus ad to top up anytime. +When your data runs out you get throttled to 1.5 Mbps instead of getting cut off. --- diff --git a/lib/components/bottom_bar.dart b/lib/components/bottom_bar.dart index 2d2e05a..e081c64 100644 --- a/lib/components/bottom_bar.dart +++ b/lib/components/bottom_bar.dart @@ -2,17 +2,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.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/app_colors.dart'; import 'package:revoltvpn/components/online_dot.dart'; -import 'package:revoltvpn/components/watch_ad_button.dart'; -/// BottomBar is the bottom utility panel that surfaces server connectivity status -/// and offers the user an option to support the server when the VPN tunnel is active. +/// BottomBar is the bottom utility panel that surfaces server connectivity status. class BottomBar extends StatelessWidget { const BottomBar({super.key}); - static const Color _cardBg = Color(0xFF1E2533); - @override Widget build(BuildContext context) { return Consumer2( @@ -26,10 +22,10 @@ class BottomBar extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), decoration: BoxDecoration( - color: _cardBg, + color: AppColors.bgCard, borderRadius: BorderRadius.circular(16), border: Border.all( - color: const Color.fromRGBO(74, 85, 104, 0.15), + color: AppColors.slate15, ), ), child: Row( @@ -43,9 +39,6 @@ class BottomBar extends StatelessWidget { ? 'Server Online' : 'Disconnected', ), - const Spacer(), - // Support button — only visible while connected AND ads are enabled - if (isConnected && AdManager.adsEnabled) const WatchAdButton(), ], ), ); diff --git a/lib/components/clock_display.dart b/lib/components/clock_display.dart index a0a4a04..ded6924 100644 --- a/lib/components/clock_display.dart +++ b/lib/components/clock_display.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/session_timer.dart'; /// ClockDisplay renders inside the ConnectButton circle when the VPN is active. @@ -36,7 +37,7 @@ class ClockDisplay extends StatelessWidget { height: 32, child: CircularProgressIndicator( strokeWidth: 2, - color: Color.fromRGBO(0, 229, 255, 0.5), + color: AppColors.cyan50, ), ), const SizedBox(height: 10), @@ -45,7 +46,7 @@ class ClockDisplay extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: Color.fromRGBO(0, 229, 255, 0.6), + color: AppColors.cyan60, letterSpacing: 1.5, ), ), @@ -56,11 +57,11 @@ class ClockDisplay extends StatelessWidget { style: const TextStyle( fontSize: 40, fontWeight: FontWeight.w800, - color: Colors.white, + color: AppColors.textWhite, letterSpacing: 2, shadows: [ Shadow( - color: Color.fromRGBO(0, 229, 255, 0.5), + color: AppColors.cyan50, blurRadius: 16, ), ], @@ -72,7 +73,7 @@ class ClockDisplay extends StatelessWidget { Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.speed, color: Colors.white54, size: 14), + const Icon(Icons.speed, color: AppColors.textDim, size: 14), const SizedBox(width: 4), Text( timer.currentSpeedKBps > 0.5 @@ -80,7 +81,7 @@ class ClockDisplay extends StatelessWidget { : 'Idle', style: const TextStyle( fontSize: 12, - color: Colors.white54, + color: AppColors.textDim, fontWeight: FontWeight.w500, ), ), diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart index c255db2..7d85ecd 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:revoltvpn/logic/app_colors.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/components/clock_display.dart'; class ConnectButton extends StatefulWidget { @@ -17,9 +19,6 @@ class _ConnectButtonState extends State late AnimationController _pulseController; late Animation _pulseAnim; - static const Color _cyanGlow = Color(0xFF00E5FF); - static const Color _bgDark = Color(0xFF1A202C); - bool _inCooldown = false; @override @@ -78,6 +77,18 @@ class _ConnectButtonState extends State } else if (vpn.status == VpnStatus.disconnected || vpn.status == VpnStatus.error) { HapticFeedback.lightImpact(); + + // ── Show rewarded ad ── + // In production the server creates the session via AdMob SSV callback + // only after a real ad is watched. The debug bypass in HivemindService + // handles session creation when adsEnabled is false. + final adMgr = context.read(); + final adWatched = await adMgr.showAd('main_ad'); + if (AdManager.adsEnabled && !adWatched) { + // User closed the ad before earning the reward — don't connect. + return; + } + final success = await vpn.connect(); if (success) { timer.start('main_ad'); @@ -104,9 +115,9 @@ class _ConnectButtonState extends State showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: const Color(0xFF1A202C), + backgroundColor: AppColors.bgDark, title: const Text('VPN Permission Required', - style: TextStyle(color: Colors.white)), + style: TextStyle(color: AppColors.textWhite)), content: const Text( 'ReVoltVPN needs permission to set up a VPN connection.\n\n' 'Since the permission was denied once, Android won\'t ask again. ' @@ -115,16 +126,16 @@ class _ConnectButtonState extends State '2. Go to Apps → ReVoltVPN\n' '3. Tap "VPN" and enable it\n' '4. Return to ReVoltVPN and tap connect', - style: TextStyle(color: Color(0xFFA0AEC0)), + style: TextStyle(color: AppColors.textMuted), ), actions: [ FilledButton( onPressed: () => Navigator.of(ctx).pop(), style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF00E5FF), + backgroundColor: AppColors.cyan, ), child: const Text('Got it', - style: TextStyle(color: Color(0xFF0D1117))), + style: TextStyle(color: AppColors.bgDeep)), ), ], ), @@ -153,15 +164,15 @@ class _ConnectButtonState extends State height: 260, decoration: BoxDecoration( shape: BoxShape.circle, - color: _bgDark, + color: AppColors.bgDark, border: Border.all( color: _inCooldown - ? Colors.grey.withValues(alpha: 0.5) + ? Colors.grey.withValues(alpha: 0.5) // dynamic, OK inline : isConnected - ? _cyanGlow + ? AppColors.cyan : isConnecting - ? const Color.fromRGBO(0, 229, 255, 0.5) - : const Color.fromRGBO(74, 85, 104, 0.5), + ? AppColors.cyan50 + : AppColors.slate50, width: isConnected && !_inCooldown ? 3 : 2, ), boxShadow: isConnected @@ -194,7 +205,7 @@ class _ConnectButtonState extends State height: 48, child: CircularProgressIndicator( strokeWidth: 2.5, - color: Color.fromRGBO(0, 229, 255, 0.7), + color: AppColors.cyan70, ), ); } @@ -206,15 +217,15 @@ class _ConnectButtonState extends State if (inCooldown) { label = 'PLEASE WAIT'; icon = Icons.hourglass_bottom; - tint = Colors.grey; + tint = Colors.grey; // dynamic, OK inline } else if (status == VpnStatus.error) { label = 'TRY AGAIN'; icon = Icons.refresh; - tint = const Color(0xFFEF5350); // soft red — something went wrong + tint = AppColors.red; // soft red — something went wrong } else { label = 'TAP TO CONNECT'; icon = Icons.power_settings_new; - tint = const Color.fromRGBO(74, 85, 104, 0.7); + tint = AppColors.slate70; } return Column( diff --git a/lib/components/gdpr_button.dart b/lib/components/gdpr_button.dart index 61c25e3..016d04b 100644 --- a/lib/components/gdpr_button.dart +++ b/lib/components/gdpr_button.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; import 'package:url_launcher/url_launcher.dart'; /// GdprButton lets the user review or change their ad consent choices. @@ -83,7 +84,7 @@ class GdprButton extends StatelessWidget { Widget build(BuildContext context) { return IconButton( icon: const Icon(Icons.admin_panel_settings_outlined, - color: Colors.white, size: 28), + color: AppColors.textWhite, size: 28), onPressed: _showConsentForm, tooltip: 'Ad consent & privacy choices', splashRadius: 24, diff --git a/lib/components/header.dart b/lib/components/header.dart index 0695d08..1499a99 100644 --- a/lib/components/header.dart +++ b/lib/components/header.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; /// Header displays the ReVolt brand logo, title, and subtitle. /// @@ -7,9 +8,6 @@ import 'package:flutter/material.dart'; class Header extends StatelessWidget { const Header({super.key}); - // cyber-revolt cyan glow accent color used as a fallback for the shield icon - static const Color _cyanGlow = Color(0xFF00E5FF); - @override Widget build(BuildContext context) { return Column( @@ -23,7 +21,7 @@ class Header extends StatelessWidget { errorBuilder: (context, error, stackTrace) => const Icon( Icons.shield, size: 120, - color: _cyanGlow, + color: AppColors.cyan, ), ), const SizedBox(height: 8), // Standard padding between the logo and the title @@ -35,7 +33,7 @@ class Header extends StatelessWidget { fontSize: 22, fontWeight: FontWeight.w800, letterSpacing: 8, - color: Colors.white, + color: AppColors.textWhite, ), ), const SizedBox(height: 2), // Close spacing for secondary information @@ -47,7 +45,7 @@ class Header extends StatelessWidget { fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 4, - color: Color(0xAA4A5568), // Semi-transparent slate gray + color: AppColors.slateAA, // Semi-transparent slate gray ), ), ], diff --git a/lib/components/info_button.dart b/lib/components/info_button.dart index cceba48..e8fc502 100644 --- a/lib/components/info_button.dart +++ b/lib/components/info_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; class InfoButton extends StatelessWidget { const InfoButton({super.key}); @@ -14,7 +15,7 @@ class InfoButton extends StatelessWidget { @override Widget build(BuildContext context) { return IconButton( - icon: const Icon(Icons.help_outline, color: Colors.white, size: 28), + icon: const Icon(Icons.help_outline, color: AppColors.textWhite, size: 28), onPressed: _launchGitHub, tooltip: 'About ReVoltVPN', splashRadius: 24, diff --git a/lib/components/online_dot.dart b/lib/components/online_dot.dart index 257c0b0..74d8b07 100644 --- a/lib/components/online_dot.dart +++ b/lib/components/online_dot.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; /// OnlineDot displays a glowing green dot when the server is healthy, /// and a muted gray dot when disconnected or still syncing. @@ -30,11 +31,11 @@ class OnlineDot extends StatelessWidget { height: 8, decoration: BoxDecoration( shape: BoxShape.circle, - color: isOnline ? const Color(0xFF00E676) : const Color(0xFF4A5568), + color: isOnline ? AppColors.green : AppColors.slate, boxShadow: isOnline ? const [ BoxShadow( - color: Color.fromRGBO(0, 230, 118, 0.5), + color: AppColors.green50, blurRadius: 6, ), ] @@ -48,8 +49,8 @@ class OnlineDot extends StatelessWidget { fontSize: 13, fontWeight: FontWeight.w500, color: isOnline - ? const Color.fromRGBO(255, 255, 255, 0.8) - : const Color(0xFF4A5568), + ? AppColors.textWhite80 + : AppColors.slate, letterSpacing: 0.5, ), ), diff --git a/lib/components/privacy_policy_button.dart b/lib/components/privacy_policy_button.dart index b26eae3..851f909 100644 --- a/lib/components/privacy_policy_button.dart +++ b/lib/components/privacy_policy_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; class PrivacyPolicyButton extends StatelessWidget { const PrivacyPolicyButton({super.key}); @@ -14,7 +15,7 @@ class PrivacyPolicyButton extends StatelessWidget { @override Widget build(BuildContext context) { return IconButton( - icon: const Icon(Icons.privacy_tip_outlined, color: Colors.white, size: 28), + icon: const Icon(Icons.privacy_tip_outlined, color: AppColors.textWhite, size: 28), onPressed: _launchPrivacyPolicy, tooltip: 'Privacy Policy', splashRadius: 24, diff --git a/lib/components/status_text.dart b/lib/components/status_text.dart index 69b8fb8..74cde9f 100644 --- a/lib/components/status_text.dart +++ b/lib/components/status_text.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/vpn_connection.dart'; /// StatusText displays the status of the VPN tunnel. @@ -22,12 +23,12 @@ class StatusText extends StatelessWidget { key: ValueKey(vpn.status), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: const Color(0x22FFFFFF), // Subtle glass effect background + color: AppColors.glassBg, // Subtle glass effect background borderRadius: BorderRadius.circular(24), border: Border.all( color: isConnected - ? const Color(0x8800E5FF) // Glowing cyan border when connected - : const Color(0x33FFFFFF), // Muted translucent border when disconnected/connecting + ? AppColors.cyanGlow // Glowing cyan border when connected + : AppColors.glassBorder, // Muted translucent border when disconnected/connecting width: 1, ), ), @@ -45,7 +46,7 @@ class StatusText extends StatelessWidget { const Text( 'Helsinki, Finland', style: TextStyle( - color: Color(0xFF00E5FF), + color: AppColors.cyan, fontWeight: FontWeight.w600, fontSize: 13, letterSpacing: 0.5, @@ -58,22 +59,22 @@ class StatusText extends StatelessWidget { height: 14, child: CircularProgressIndicator( strokeWidth: 1.5, - color: Color(0xFF00E5FF), + color: AppColors.cyan, ), ) else const Icon( Icons.shield_outlined, size: 18, - color: Color(0xAA4A5568), + color: AppColors.slateAA, ), const SizedBox(width: 8), Text( vpn.statusMessage.toUpperCase(), style: TextStyle( color: isConnecting - ? const Color(0xFF00E5FF) - : const Color(0xAA4A5568), + ? AppColors.cyan + : AppColors.slateAA, fontWeight: FontWeight.w600, fontSize: 13, letterSpacing: 1, diff --git a/lib/components/watch_ad_button.dart b/lib/components/watch_ad_button.dart deleted file mode 100644 index f5e4738..0000000 --- a/lib/components/watch_ad_button.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:revoltvpn/logic/session_timer.dart'; -import 'package:revoltvpn/logic/ad_manager.dart'; - -/// WatchAdButton is a voluntary option allowing the user to watch a rewarded video ad -/// and extend their active VPN tunnel session by 30 minutes as a reward. -class WatchAdButton extends StatelessWidget { - const WatchAdButton({super.key}); - - @override - Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () async { - final adWatched = await context.read().showAd('bonus_ad'); - if (!adWatched || !context.mounted) return; - - // The actual session extension happens server-side via AdMob SSV. - // addBonusTime() syncs the new limits from the server. - final synced = await context.read().addBonusTime(); - if (!context.mounted) return; - - if (synced) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text( - '+30 minutes added!', - style: TextStyle(fontWeight: FontWeight.w600), - ), - backgroundColor: const Color(0xFF006064), - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - duration: const Duration(seconds: 2), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Could not confirm bonus. Please check your connection.', - style: TextStyle(fontWeight: FontWeight.w600), - ), - backgroundColor: Color(0xFF5D4037), - behavior: SnackBarBehavior.floating, - duration: Duration(seconds: 3), - ), - ); - } - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - gradient: const LinearGradient( - colors: [Color(0x1A00E5FF), Color(0x0D00E5FF)], - ), - border: Border.all( - color: const Color.fromRGBO(0, 229, 255, 0.25), - ), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('❤️', style: TextStyle(fontSize: 14)), - SizedBox(width: 6), - Text( - 'Support +30m', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFF00E5FF), - letterSpacing: 0.5, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/logic/app_colors.dart b/lib/logic/app_colors.dart new file mode 100644 index 0000000..7799eb9 --- /dev/null +++ b/lib/logic/app_colors.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +// ============================================================================= +// app_colors.dart — Single source of truth for every color in ReVoltVPN +// ───────────────────────────────────────────────────────────────────────────── +// Import this file and reference AppColors.* instead of hardcoding hex values +// or Color.fromRGBO() calls anywhere else in the app. +// ============================================================================= + +abstract final class AppColors { + AppColors._(); + + // ── Primary: Cyan ────────────────────────────────────────────────────────── + static const Color cyan = Color(0xFF00E5FF); + static const Color cyan15 = Color(0x2600E5FF); // 15 % opacity + static const Color cyan25 = Color(0x4000E5FF); // 25 % opacity + static const Color cyan50 = Color(0x8000E5FF); // 50 % opacity + static const Color cyan60 = Color(0x9900E5FF); // 60 % opacity + static const Color cyan70 = Color(0xB300E5FF); // 70 % opacity + static const Color cyan05 = Color(0x0D00E5FF); // 5 % opacity + static const Color cyanGlow = Color(0x8800E5FF); // glowing border + + // ── Backgrounds ──────────────────────────────────────────────────────────── + static const Color bgDeep = Color(0xFF0D1117); // intro / scaffold + static const Color bgDark = Color(0xFF1A202C); // button circle + static const Color bgSurface = Color(0xFF151C28); // colorScheme surface + static const Color bgCard = Color(0xFF1E2533); // bottom bar, snackbar + static const Color bgOverlay = Color(0xAA0D1117); // semi-transparent overlay + + // ── Slate / Gray ─────────────────────────────────────────────────────────── + static const Color slate = Color(0xFF4A5568); + static const Color slate15 = Color(0x264A5568); // 15 % opacity + static const Color slate50 = Color(0x804A5568); // 50 % opacity + static const Color slate70 = Color(0xB34A5568); // 70 % opacity + static const Color slateAA = Color(0xAA4A5568); // ~67 % (legacy usage) + + // ── Text ─────────────────────────────────────────────────────────────────── + static const Color textWhite = Colors.white; + static const Color textDim = Colors.white54; + static const Color textWhite80 = Color(0xCCFFFFFF); // 80 % white + static const Color textMuted = Color(0xFFA0AEC0); // dialog body text + + // ── Semantic ─────────────────────────────────────────────────────────────── + static const Color green = Color(0xFF00E676); + static const Color green50 = Color(0x8000E676); // 50 % opacity + static const Color red = Color(0xFFEF5350); + + // ── Glass / Borders ──────────────────────────────────────────────────────── + static const Color glassBg = Color(0x22FFFFFF); // subtle glass background + static const Color glassBorder = Color(0x33FFFFFF); // muted translucent border + + // ── Intro screen snackbars ───────────────────────────────────────────────── + static const Color snackSuccess = Color(0xFF006064); + static const Color snackError = Color(0xFF5D4037); + + // ═══════════════════════════════════════════════════════════════════════════ + // FUTURE — Yellow palette (not yet wired into the UI) + // ═══════════════════════════════════════════════════════════════════════════ + static const Color yellow = Color(0xFFFFD600); + static const Color yellowDark = Color(0xFFFFAB00); + static const Color yellowLight = Color(0xFFFFFF00); + static const Color yellowFaded = Color(0x1AFFD600); + static const Color yellowAmber = Color(0xFFFFC107); + static const Color yellowMustard = Color(0xFFC6A700); + + // ═══════════════════════════════════════════════════════════════════════════ + // FUTURE — Black palette (not yet wired into the UI) + // ═══════════════════════════════════════════════════════════════════════════ + static const Color black = Color(0xFF000000); + static const Color blackOff = Color(0xFF0A0A0A); + static const Color blackFaded = Color(0xAA000000); + static const Color blackWarm = Color(0xFF121212); + static const Color blackCharcoal = Color(0xFF1A1A1A); +} diff --git a/lib/logic/app_config.example.dart b/lib/logic/app_config.example.dart index 6f5d341..c6d75bb 100644 --- a/lib/logic/app_config.example.dart +++ b/lib/logic/app_config.example.dart @@ -17,7 +17,6 @@ abstract final class AppConfig { // ── VLESS ────────────────────────────────────────────────────────────── static const int serverPort = 443; - static const String vlessFlow = 'xtls-rprx-vision'; static const String vlessSecurity = 'tls'; static const String vlessType = 'ws'; diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index cc62362..08c4e80 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -42,7 +42,6 @@ class HivemindService { try { final customData = jsonEncode({ 'device_id': deviceId, - 'ad_type': 'main_ad', 'nonce': nonce, }); final fakeAdmobPingUrl = Uri.parse( @@ -75,7 +74,6 @@ class HivemindService { '?path=${Uri.encodeComponent(AppConfig.vlessPath)}' '&security=${AppConfig.vlessSecurity}' '&type=${AppConfig.vlessType}' - '&flow=${AppConfig.vlessFlow}' '#ReVoltVPN'; _expectedNonce = null; // consumed successfully diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index 550adf9..45fdc68 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -22,7 +22,6 @@ class SessionTimer extends ChangeNotifier { int _remainingSeconds = 0; int _quotaBytes = 0; int _usedBytes = 0; - bool _isThrottled = false; // ── Sync / Network Health ────────────────────────────────────────────────── bool _hasSyncedOnce = false; @@ -65,7 +64,6 @@ class SessionTimer extends ChangeNotifier { int get quotaBytes => _quotaBytes; int get usedBytes => _usedBytes; int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0; - bool get isThrottled => _isThrottled; double get currentSpeedKBps => _currentSpeedKBps; String get formatted { @@ -136,7 +134,6 @@ class SessionTimer extends ChangeNotifier { _usedBytes = 0; _lastUsedBytes = 0; _currentSpeedKBps = 0.0; - _isThrottled = false; _tickCount = 0; _hasSyncedOnce = false; _consecutiveFailures = 0; @@ -251,7 +248,6 @@ class SessionTimer extends ChangeNotifier { _remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; _quotaBytes = data['quota_bytes'] ?? _quotaBytes; _usedBytes = data['used_bytes'] ?? _usedBytes; - _isThrottled = data['is_throttled'] ?? false; // ── Speed calculation over the polling interval ──────────────────── final int deltaBytes = _usedBytes - _lastUsedBytes; @@ -290,26 +286,6 @@ class SessionTimer extends ChangeNotifier { } } - // ── Bonus Time ───────────────────────────────────────────────────────────── - - /// Called after the user watches a bonus rewarded ad. - /// - /// The actual session extension is handled server-side via the AdMob SSV - /// callback. This method simply re-syncs so the UI picks up the new - /// limits and un-throttled status. - /// - /// Returns `true` if the sync succeeded, so the UI can decide whether to - /// show a success snackbar. - Future addBonusTime() async { - try { - await _syncWithHivemind(); - return _hasSyncedOnce; - } catch (e) { - debugPrint('Bonus-time sync error: $e'); - return false; - } - } - // ── Stop ─────────────────────────────────────────────────────────────────── /// Called externally (e.g. from ConnectButton) to begin tearing down. diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index dae2339..e27598b 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -77,12 +77,16 @@ class VpnConnection extends ChangeNotifier { switch (status.connectionState) { case VlessConnectionState.connected: _setStatus(VpnStatus.connected, 'Secured'); + break; case VlessConnectionState.disconnected: _setStatus(VpnStatus.disconnected, 'Tap to connect'); + break; case VlessConnectionState.connecting: _setStatus(VpnStatus.connecting, 'Establishing tunnel…'); + break; case VlessConnectionState.disconnecting: _setStatus(VpnStatus.disconnecting, 'Tearing down…'); + break; case VlessConnectionState.unknown: // Native backend emitted an unrecognized state — treat as error // only if we aren't already in a terminal state. @@ -90,6 +94,7 @@ class VpnConnection extends ChangeNotifier { _status != VpnStatus.disconnected) { _setStatus(VpnStatus.error, 'Connection failed'); } + break; } } @@ -221,6 +226,13 @@ class VpnConnection extends ChangeNotifier { @override void dispose() { + if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { + try { + _vless.stopVless(); + } catch (_) { + // Best-effort — widget is being destroyed anyway + } + } super.dispose(); } } diff --git a/lib/main.dart b/lib/main.dart index a72fb2f..4c26fec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ 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/consent_manager.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/screens/intro.dart'; /// The entry point for the REVOLT VPN application. @@ -79,21 +80,18 @@ class ReVoltApp extends StatelessWidget { /// Constructs the global dark theme used across the application. ThemeData _buildDarkTheme() { - const cyanAccent = Color(0xFF00E5FF); - const bgDark = Color(0xFF0D1117); - return ThemeData( brightness: Brightness.dark, - scaffoldBackgroundColor: bgDark, + scaffoldBackgroundColor: AppColors.bgDeep, colorScheme: const ColorScheme.dark( - primary: cyanAccent, - secondary: cyanAccent, - surface: Color(0xFF151C28), + primary: AppColors.cyan, + secondary: AppColors.cyan, + surface: AppColors.bgSurface, ), fontFamily: 'Roboto', snackBarTheme: SnackBarThemeData( - backgroundColor: const Color(0xFF1E2533), - contentTextStyle: const TextStyle(color: Colors.white), + backgroundColor: AppColors.bgCard, + contentTextStyle: const TextStyle(color: AppColors.textWhite), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), behavior: SnackBarBehavior.floating, ), diff --git a/lib/screens/intro.dart b/lib/screens/intro.dart index d355461..b1869ce 100644 --- a/lib/screens/intro.dart +++ b/lib/screens/intro.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/screens/main_screen.dart'; class IntroScreen extends StatefulWidget { @@ -58,7 +59,7 @@ class _IntroScreenState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFF0D1117), + backgroundColor: AppColors.bgDeep, body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -71,7 +72,7 @@ class _IntroScreenState extends State with WidgetsBindingObserver { return const Icon( Icons.shield, size: 140, - color: Color(0xFF00E5FF), + color: AppColors.cyan, ); }, ), @@ -79,7 +80,7 @@ class _IntroScreenState extends State with WidgetsBindingObserver { const Text( 'REVOLT VPN', style: TextStyle( - color: Colors.white, + color: AppColors.textWhite, fontSize: 28, fontWeight: FontWeight.bold, letterSpacing: 2.0, @@ -90,7 +91,7 @@ class _IntroScreenState extends State with WidgetsBindingObserver { width: 40, height: 40, child: CircularProgressIndicator( - color: Color(0xFF00E5FF), + color: AppColors.cyan, strokeWidth: 3.0, ), ), diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 6bf94fa..1a96a2c 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/components/header.dart'; import 'package:revoltvpn/components/status_text.dart'; import 'package:revoltvpn/components/connect_button.dart'; @@ -18,23 +19,20 @@ import 'package:revoltvpn/components/gdpr_button.dart'; class MainScreen extends StatelessWidget { const MainScreen({super.key}); - // Background base color - static const Color _bgTop = Color(0xFF0D1117); - @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: _bgTop, + backgroundColor: AppColors.bgDeep, body: Container( width: double.infinity, height: double.infinity, decoration: const BoxDecoration( - color: _bgTop, + color: AppColors.bgDeep, image: DecorationImage( image: AssetImage('assets/background.png'), fit: BoxFit.cover, colorFilter: ColorFilter.mode( - Color(0xAA0D1117), // Deep dark tint overlay to guarantee readability of overlay elements + AppColors.bgOverlay, // Deep dark tint overlay to guarantee readability of overlay elements BlendMode.darken, ), ), diff --git a/server_hivemind_5_0.py b/server_hivemind_5_0.py index b2a7f26..302ce06 100644 --- a/server_hivemind_5_0.py +++ b/server_hivemind_5_0.py @@ -6,13 +6,13 @@ Designed for Android-only clients. === TABLE OF CONTENTS =========================================================== LINE SECTION - 37 Configuration — ad rewards, Xray paths, throttle levels + 37 Configuration — ad rewards, Xray paths 75 State & Cache — sessions dict, stats failure counter 97 Flask Routes — /admob/callback, /session/status, /session/stop - 202 Session Logic — _start_or_extend_session (create / extend / revive) + 202 Session Logic — _start_or_extend_session (create / replace) 270 AdMob SSV Verification — Google ECDSA signature checks 355 Management Loop — expiry + data-quota watchdog (60 s tick) - 403 Low-Level Plumbing — add/remove/set_level clients, Xray stats + 403 Low-Level Plumbing — add/remove users, Xray stats 551 Entry Point — daemon thread + gunicorn bootstrap ================================================================================ """ @@ -21,6 +21,8 @@ import time import subprocess import threading import json +import os +import tempfile import requests import shlex import urllib.parse @@ -29,10 +31,45 @@ import base64 from flask import Flask, request, jsonify from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.serialization import load_der_public_key, load_pem_public_key +from cryptography.hazmat.primitives.serialization import load_pem_public_key app = Flask(__name__) + +def _is_valid_device_id(device_id): + """Validate that device_id is a well-formed UUID (Flutter uuid.v4() format). + + Rejects empty strings, non-UUID garbage, and oversized payloads + before they reach the session dict or Xray config. + """ + if not isinstance(device_id, str): + return False + if len(device_id) > 64: + return False + try: + uuid.UUID(device_id) + return True + except (ValueError, AttributeError): + return False + + +def _atomic_write_json(path, data): + """Write JSON to path atomically (temp file + rename). + + Prevents Xray from reading a half-written config file during hot-reload. + """ + tmpfd, tmppath = tempfile.mkstemp(dir=os.path.dirname(path), suffix='.tmp') + try: + with os.fdopen(tmpfd, 'w') as f: + json.dump(data, f, indent=2) + os.replace(tmppath, path) + except Exception: + try: + os.unlink(tmppath) + except OSError: + pass + raise + # ============================================================================== # CONFIGURATION # ============================================================================== @@ -40,28 +77,71 @@ CHECK_INTERVAL_SECONDS = 60 # Checking every 1 minute is extremely safe and # Main Ad Rewards MAIN_AD_MINUTES = 60 -MAIN_AD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB - -# Bonus Ad Rewards -BONUS_AD_MINUTES = 30 -BONUS_AD_BYTES = 1 * 1024 * 1024 * 1024 # 1 GB +MAIN_AD_BYTES = 10 * 1024 * 1024 * 1024 # 10 GB (generous — no mobile device hits this in 1 hr) # VLESS server info — returned to app in /session/status VLESS_DOMAIN = "yourdomain.com" # TODO: set your real domain VLESS_PATH = "/tunnel" +# ── Xray config.json template ──────────────────────────────────────────────── +# Copy this to /usr/local/etc/xray/config.json on the server. +# Verified against https://xtls.github.io/llms-full.txt (2026-07-24). +# +# • "clients" is the correct VLESS key (not "users") +# • "method" is the transport selector (not "network") +# • API uses simplified mode — no separate inbound or routing rule needed +# • statsUserUplink/Downlink must be true for per-user quota tracking +# • uplinkOnly/downlinkOnly are connection timeouts (seconds), NOT bandwidth limits +# +# { +# "log": { "loglevel": "warning" }, +# "inbounds": [ +# { +# "tag": "vless-in", +# "port": 8443, +# "listen": "127.0.0.1", +# "protocol": "vless", +# "settings": { +# "clients": [], +# "decryption": "none" +# }, +# "streamSettings": { +# "method": "ws", +# "security": "none", +# "wsSettings": { "path": "/tunnel" } +# }, +# "sniffing": { "enabled": true, "destOverride": ["http", "tls"] } +# } +# ], +# "outbounds": [ +# { +# "protocol": "freedom", +# "tag": "direct", +# "mux": { "enabled": true, "concurrency": 8 } +# } +# ], +# "api": { +# "tag": "api", +# "listen": "127.0.0.1:10085", +# "services": ["HandlerService", "StatsService"] +# }, +# "stats": {}, +# "policy": { +# "system": { +# "statsInboundUplink": true, +# "statsInboundDownlink": true +# }, +# "levels": { +# "0": { "statsUserUplink": true, "statsUserDownlink": true } +# } +# } +# } + # Xray paths XRAY_CONFIG = "/usr/local/etc/xray/config.json" XRAY_API = "http://127.0.0.1:10085" VLESS_INBOUND_TAG = "vless-in" -# VLESS policy levels for bandwidth throttling -# Level 0 = unlimited (default for new sessions) -# Level 1 = 192 KB/s (~1.5 Mbps) — matches the old AWG THROTTLE_RATE -# The Xray config.json must define matching policy.levels entries. -THROTTLE_LEVEL = 1 -THROTTLE_RATE_KBPS = 192 - # AdMob SSV verification toggle # True = dev mode, accept any callback (no signature check) # False = prod mode, enforce Google ECDSA signature verification @@ -86,7 +166,6 @@ _xray_stats_failures = 0 # "vless_uuid": "550e8400-...", # "expires_at": 1716480000, # "quota_bytes": 2147483648, -# "is_throttled": False, # "nonce": "a7f3b2-1716480000000", # } # } @@ -104,7 +183,7 @@ def admob_callback(): The Android client never touches this endpoint; Google calls it directly. custom_data (JSON, set by the app before showing the ad): - { "device_id": "...", "ad_type": "main_ad"|"bonus_ad", "nonce": "..." } + { "device_id": "...", "nonce": "..." } """ try: if ADMOB_BYPASS: @@ -130,16 +209,22 @@ def admob_callback(): print("[ADMOB] Rejected callback — invalid signature.") return "Forbidden", 403 - custom_data = json.loads(custom_data_str) + try: + custom_data = json.loads(custom_data_str) + except (json.JSONDecodeError, TypeError) as e: + print(f"[ADMOB] Malformed custom_data JSON: {e}") + return "Invalid JSON in custom_data", 400 + device_id = custom_data.get('device_id') if not device_id: return "Invalid custom_data payload", 400 + if not _is_valid_device_id(device_id): + print(f"[ADMOB] Rejected — invalid device_id: {str(device_id)[:32]}...") + return "Invalid device_id", 400 - ad_type = custom_data.get('ad_type', 'main_ad') - nonce = custom_data.get('nonce', None) - - success, msg = _start_or_extend_session(device_id, ad_type, nonce) + nonce = custom_data.get('nonce', None) + success, msg = _start_or_extend_session(device_id, nonce) return ("OK", 200) if success else (msg, 500) except Exception as e: @@ -156,12 +241,14 @@ def session_status(): device_id = request.args.get('device_id') if not device_id: return jsonify({"error": "Missing query parameter: device_id"}), 400 + if not _is_valid_device_id(device_id): + return jsonify({"error": "Invalid device_id — expected UUID"}), 400 with sessions_lock: session = active_sessions.get(device_id) if not session: - return jsonify({"active": False, "message": "No session found."}), 404 + return jsonify({"active": False, "message": "No session found."}), 200 stats = get_xray_stats() used_bytes = stats.get(device_id, {}).get("total_bytes", 0) @@ -174,8 +261,6 @@ def session_status(): "server_domain": VLESS_DOMAIN, "server_port": 443, "vless_path": VLESS_PATH, - "vless_flow": "xtls-rprx-vision", - "is_throttled": session["is_throttled"], "expires_in_seconds": int(remaining_secs), "quota_bytes": session["quota_bytes"], "used_bytes": used_bytes, @@ -189,7 +274,12 @@ def session_stop(): """Allows the app to explicitly tear down its session on disconnect.""" data = request.get_json(silent=True) device_id = data.get('device_id') if data else None - + + if not device_id: + return jsonify({"error": "Missing device_id"}), 400 + if not _is_valid_device_id(device_id): + return jsonify({"error": "Invalid device_id — expected UUID"}), 400 + if device_id: with sessions_lock: session = active_sessions.pop(device_id, None) @@ -203,8 +293,8 @@ def session_stop(): # SESSION LOGIC # ============================================================================== -def _start_or_extend_session(device_id, ad_type, nonce=None): - """Core logic to provision or extend a session. +def _start_or_extend_session(device_id, nonce=None): + """Provision a new VLESS session (or replace an existing one). nonce -- client-generated random token; stored in session and echoed back in /session/status so the client can reject stale sessions. @@ -212,58 +302,41 @@ def _start_or_extend_session(device_id, ad_type, nonce=None): now = time.time() # ── Phase 1: update in-memory state under lock ──────────────────── - xray_action = None # ("add", uuid) | ("remove", None) | ("set_level", level) | None + xray_action = None # ("add", uuid) | ("replace", uuid) with sessions_lock: - if device_id in active_sessions and ad_type == 'bonus_ad': - session = active_sessions[device_id] - session["expires_at"] += (BONUS_AD_MINUTES * 60) - session["quota_bytes"] += BONUS_AD_BYTES + existing = active_sessions.get(device_id) + vless_uuid = str(uuid.uuid4()) - if session["is_throttled"]: - session["is_throttled"] = False - xray_action = ("set_level", 0) - - print(f"[SESSION] Bonus time added for {device_id}.") - # Fall through to Phase 2 outside the lock + active_sessions[device_id] = { + "vless_uuid": vless_uuid, + "expires_at": now + (MAIN_AD_MINUTES * 60), + "quota_bytes": MAIN_AD_BYTES, + "nonce": nonce, + } + if existing: + xray_action = ("replace", vless_uuid) else: - # Create a brand new session (Main Ad) - existing = active_sessions.get(device_id) - vless_uuid = str(uuid.uuid4()) + xray_action = ("add", vless_uuid) - active_sessions[device_id] = { - "vless_uuid": vless_uuid, - "expires_at": now + (MAIN_AD_MINUTES * 60), - "quota_bytes": MAIN_AD_BYTES, - "is_throttled": False, - "nonce": nonce, - } - - if existing: - xray_action = ("replace", vless_uuid) - else: - xray_action = ("add", vless_uuid) - - print(f"[SESSION] Main session started for {device_id} (UUID: {vless_uuid[:8]}...).") + print(f"[SESSION] Session started for {device_id} (UUID: {vless_uuid[:8]}...).") # ── Phase 2: Xray file I/O + subprocess OUTSIDE the lock ────────── - if xray_action is None: - return True, "Bonus time and data added." - op, arg = xray_action[0], xray_action[1] - if op == "set_level": - set_vless_client_level(device_id, arg) - return True, "Bonus time and data added." - elif op == "replace": + if op == "replace": remove_vless_client(device_id) add_vless_client(arg, device_id) - return True, "Main session active." + return True, "Session active." elif op == "add": add_vless_client(arg, device_id) - return True, "Main session active." - - + return True, "Session active." + else: + raise RuntimeError( + f"BUG: _start_or_extend_session got unexpected xray_action op={op!r}. " + f"Expected one of: replace, add. " + f"This is a code bug — add the new op to the if-chain." + ) @@ -365,8 +438,7 @@ def management_loop(): # ── Phase 1: decide what to do under the lock ──────────────── with sessions_lock: - to_remove = [] # (device_id,) — expired, remove from Xray - to_throttle = [] # (device_id,) — over quota, set level 1 + to_remove = [] # (device_id,) — expired or over quota for device_id, session in list(active_sessions.items()): @@ -376,13 +448,12 @@ def management_loop(): to_remove.append(device_id) continue - # ── 2. Check data quota — throttle when exceeded ────────── + # ── 2. Check data quota — hard cut-off when exceeded ────── used_bytes = stats.get(device_id, {}).get("total_bytes", 0) - if used_bytes > session["quota_bytes"] and not session["is_throttled"]: - print(f"[HIVEMIND] Data cap reached for {device_id} ({used_bytes/1e9:.2f} GB used). Throttling to ~1.5 Mbps.") - session["is_throttled"] = True - to_throttle.append(device_id) + if used_bytes > session["quota_bytes"]: + print(f"[HIVEMIND] Data cap reached for {device_id} ({used_bytes/1e9:.2f} GB used). Cutting off.") + to_remove.append(device_id) for device_id in to_remove: del active_sessions[device_id] @@ -391,9 +462,6 @@ def management_loop(): for device_id in to_remove: remove_vless_client(device_id) - for device_id in to_throttle: - set_vless_client_level(device_id, THROTTLE_LEVEL) - except Exception as e: print(f"[HIVEMIND] Unhandled error: {e}") @@ -429,7 +497,7 @@ def add_vless_client(uuid, email, level=0): """Add a client UUID to the Xray inbound config and hot-reload. level -- Xray policy level for bandwidth limits: - 0 = unlimited, 1 = throttled (~1.5 Mbps). + 0 = default. """ with open(XRAY_CONFIG, 'r') as f: config = json.load(f) @@ -439,47 +507,13 @@ def add_vless_client(uuid, email, level=0): inbound["settings"]["clients"].append({ "id": uuid, "email": email, - "level": level, - "flow": "xtls-rprx-vision" + "level": level }) break - with open(XRAY_CONFIG, 'w') as f: - json.dump(config, f, indent=2) - + _atomic_write_json(XRAY_CONFIG, config) run_cmd("xray api adi --server=127.0.0.1:10085") - print(f"[VLESS] Client added: {email[:16]}...") - - -def set_vless_client_level(email, level): - """Change a client's policy level in the Xray config and hot-reload. - - Used for throttling (level 1) and un-throttling (level 0) without - removing and re-adding the client. Silently no-ops if the client - is not found (e.g. already expired and removed). - """ - with open(XRAY_CONFIG, 'r') as f: - config = json.load(f) - - found = False - for inbound in config["inbounds"]: - if inbound.get("tag") == VLESS_INBOUND_TAG: - for client in inbound["settings"]["clients"]: - if client.get("email") == email: - client["level"] = level - found = True - break - break - - if not found: - print(f"[VLESS] set_level: client {email[:16]}... not found — skipped.") - return - - with open(XRAY_CONFIG, 'w') as f: - json.dump(config, f, indent=2) - - run_cmd("xray api adi --server=127.0.0.1:10085") - print(f"[VLESS] Client {email[:16]}... level -> {level}.") + print(f"[VLESS] User added: {email[:16]}...") def remove_vless_client(email): @@ -495,11 +529,9 @@ def remove_vless_client(email): ] break - with open(XRAY_CONFIG, 'w') as f: - json.dump(config, f, indent=2) - + _atomic_write_json(XRAY_CONFIG, config) run_cmd("xray api adi --server=127.0.0.1:10085") - print(f"[VLESS] Client removed: {email[:16]}...") + print(f"[VLESS] User removed: {email[:16]}...") def get_xray_stats():