v2.0.0: VLESS cleanup — remove bonus ads, throttling, fix Xray compliance, rebrand colors

This commit is contained in:
esefxdz committed 2026-07-25 00:34:14 +03:00
1 parent 10a1450292
commit d106356ca6
20 files changed
+318 -308

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ So here it is.
## How it works ## 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. 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.
--- ---
+4 -11
View File
@@ -2,17 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.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/app_colors.dart';
import 'package:revoltvpn/components/online_dot.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 /// 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.
class BottomBar extends StatelessWidget { class BottomBar extends StatelessWidget {
const BottomBar({super.key}); const BottomBar({super.key});
static const Color _cardBg = Color(0xFF1E2533);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer2<VpnConnection, SessionTimer>( return Consumer2<VpnConnection, SessionTimer>(
@@ -26,10 +22,10 @@ class BottomBar extends StatelessWidget {
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _cardBg, color: AppColors.bgCard,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: const Color.fromRGBO(74, 85, 104, 0.15), color: AppColors.slate15,
), ),
), ),
child: Row( child: Row(
@@ -43,9 +39,6 @@ class BottomBar extends StatelessWidget {
? 'Server Online' ? 'Server Online'
: 'Disconnected', : 'Disconnected',
), ),
const Spacer(),
// Support button — only visible while connected AND ads are enabled
if (isConnected && AdManager.adsEnabled) const WatchAdButton(),
], ],
), ),
); );
+7 -6
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/session_timer.dart'; import 'package:revoltvpn/logic/session_timer.dart';
/// ClockDisplay renders inside the ConnectButton circle when the VPN is active. /// ClockDisplay renders inside the ConnectButton circle when the VPN is active.
@@ -36,7 +37,7 @@ class ClockDisplay extends StatelessWidget {
height: 32, height: 32,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Color.fromRGBO(0, 229, 255, 0.5), color: AppColors.cyan50,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -45,7 +46,7 @@ class ClockDisplay extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Color.fromRGBO(0, 229, 255, 0.6), color: AppColors.cyan60,
letterSpacing: 1.5, letterSpacing: 1.5,
), ),
), ),
@@ -56,11 +57,11 @@ class ClockDisplay extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
fontSize: 40, fontSize: 40,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: Colors.white, color: AppColors.textWhite,
letterSpacing: 2, letterSpacing: 2,
shadows: [ shadows: [
Shadow( Shadow(
color: Color.fromRGBO(0, 229, 255, 0.5), color: AppColors.cyan50,
blurRadius: 16, blurRadius: 16,
), ),
], ],
@@ -72,7 +73,7 @@ class ClockDisplay extends StatelessWidget {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.speed, color: Colors.white54, size: 14), const Icon(Icons.speed, color: AppColors.textDim, size: 14),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
timer.currentSpeedKBps > 0.5 timer.currentSpeedKBps > 0.5
@@ -80,7 +81,7 @@ class ClockDisplay extends StatelessWidget {
: 'Idle', : 'Idle',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.white54, color: AppColors.textDim,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
+28 -17
View File
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.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/components/clock_display.dart'; import 'package:revoltvpn/components/clock_display.dart';
class ConnectButton extends StatefulWidget { class ConnectButton extends StatefulWidget {
@@ -17,9 +19,6 @@ class _ConnectButtonState extends State<ConnectButton>
late AnimationController _pulseController; late AnimationController _pulseController;
late Animation<double> _pulseAnim; late Animation<double> _pulseAnim;
static const Color _cyanGlow = Color(0xFF00E5FF);
static const Color _bgDark = Color(0xFF1A202C);
bool _inCooldown = false; bool _inCooldown = false;
@override @override
@@ -78,6 +77,18 @@ class _ConnectButtonState extends State<ConnectButton>
} else if (vpn.status == VpnStatus.disconnected || } else if (vpn.status == VpnStatus.disconnected ||
vpn.status == VpnStatus.error) { vpn.status == VpnStatus.error) {
HapticFeedback.lightImpact(); 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<AdManager>();
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(); final success = await vpn.connect();
if (success) { if (success) {
timer.start('main_ad'); timer.start('main_ad');
@@ -104,9 +115,9 @@ class _ConnectButtonState extends State<ConnectButton>
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1A202C), backgroundColor: AppColors.bgDark,
title: const Text('VPN Permission Required', title: const Text('VPN Permission Required',
style: TextStyle(color: Colors.white)), style: TextStyle(color: AppColors.textWhite)),
content: const Text( content: const Text(
'ReVoltVPN needs permission to set up a VPN connection.\n\n' 'ReVoltVPN needs permission to set up a VPN connection.\n\n'
'Since the permission was denied once, Android won\'t ask again. ' 'Since the permission was denied once, Android won\'t ask again. '
@@ -115,16 +126,16 @@ class _ConnectButtonState extends State<ConnectButton>
'2. Go to Apps → ReVoltVPN\n' '2. Go to Apps → ReVoltVPN\n'
'3. Tap "VPN" and enable it\n' '3. Tap "VPN" and enable it\n'
'4. Return to ReVoltVPN and tap connect', '4. Return to ReVoltVPN and tap connect',
style: TextStyle(color: Color(0xFFA0AEC0)), style: TextStyle(color: AppColors.textMuted),
), ),
actions: [ actions: [
FilledButton( FilledButton(
onPressed: () => Navigator.of(ctx).pop(), onPressed: () => Navigator.of(ctx).pop(),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF00E5FF), backgroundColor: AppColors.cyan,
), ),
child: const Text('Got it', child: const Text('Got it',
style: TextStyle(color: Color(0xFF0D1117))), style: TextStyle(color: AppColors.bgDeep)),
), ),
], ],
), ),
@@ -153,15 +164,15 @@ class _ConnectButtonState extends State<ConnectButton>
height: 260, height: 260,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: _bgDark, color: AppColors.bgDark,
border: Border.all( border: Border.all(
color: _inCooldown color: _inCooldown
? Colors.grey.withValues(alpha: 0.5) ? Colors.grey.withValues(alpha: 0.5) // dynamic, OK inline
: isConnected : isConnected
? _cyanGlow ? AppColors.cyan
: isConnecting : isConnecting
? const Color.fromRGBO(0, 229, 255, 0.5) ? AppColors.cyan50
: const Color.fromRGBO(74, 85, 104, 0.5), : AppColors.slate50,
width: isConnected && !_inCooldown ? 3 : 2, width: isConnected && !_inCooldown ? 3 : 2,
), ),
boxShadow: isConnected boxShadow: isConnected
@@ -194,7 +205,7 @@ class _ConnectButtonState extends State<ConnectButton>
height: 48, height: 48,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2.5, strokeWidth: 2.5,
color: Color.fromRGBO(0, 229, 255, 0.7), color: AppColors.cyan70,
), ),
); );
} }
@@ -206,15 +217,15 @@ class _ConnectButtonState extends State<ConnectButton>
if (inCooldown) { if (inCooldown) {
label = 'PLEASE WAIT'; label = 'PLEASE WAIT';
icon = Icons.hourglass_bottom; icon = Icons.hourglass_bottom;
tint = Colors.grey; tint = Colors.grey; // dynamic, OK inline
} else if (status == VpnStatus.error) { } else if (status == VpnStatus.error) {
label = 'TRY AGAIN'; label = 'TRY AGAIN';
icon = Icons.refresh; icon = Icons.refresh;
tint = const Color(0xFFEF5350); // soft red — something went wrong tint = AppColors.red; // soft red — something went wrong
} else { } else {
label = 'TAP TO CONNECT'; label = 'TAP TO CONNECT';
icon = Icons.power_settings_new; icon = Icons.power_settings_new;
tint = const Color.fromRGBO(74, 85, 104, 0.7); tint = AppColors.slate70;
} }
return Column( return Column(
+2 -1
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
/// GdprButton lets the user review or change their ad consent choices. /// GdprButton lets the user review or change their ad consent choices.
@@ -83,7 +84,7 @@ class GdprButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( return IconButton(
icon: const Icon(Icons.admin_panel_settings_outlined, icon: const Icon(Icons.admin_panel_settings_outlined,
color: Colors.white, size: 28), color: AppColors.textWhite, size: 28),
onPressed: _showConsentForm, onPressed: _showConsentForm,
tooltip: 'Ad consent & privacy choices', tooltip: 'Ad consent & privacy choices',
splashRadius: 24, splashRadius: 24,
+4 -6
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/app_colors.dart';
/// Header displays the ReVolt brand logo, title, and subtitle. /// Header displays the ReVolt brand logo, title, and subtitle.
/// ///
@@ -7,9 +8,6 @@ import 'package:flutter/material.dart';
class Header extends StatelessWidget { class Header extends StatelessWidget {
const Header({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@@ -23,7 +21,7 @@ class Header extends StatelessWidget {
errorBuilder: (context, error, stackTrace) => const Icon( errorBuilder: (context, error, stackTrace) => const Icon(
Icons.shield, Icons.shield,
size: 120, size: 120,
color: _cyanGlow, color: AppColors.cyan,
), ),
), ),
const SizedBox(height: 8), // Standard padding between the logo and the title const SizedBox(height: 8), // Standard padding between the logo and the title
@@ -35,7 +33,7 @@ class Header extends StatelessWidget {
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: 8, letterSpacing: 8,
color: Colors.white, color: AppColors.textWhite,
), ),
), ),
const SizedBox(height: 2), // Close spacing for secondary information const SizedBox(height: 2), // Close spacing for secondary information
@@ -47,7 +45,7 @@ class Header extends StatelessWidget {
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 4, letterSpacing: 4,
color: Color(0xAA4A5568), // Semi-transparent slate gray color: AppColors.slateAA, // Semi-transparent slate gray
), ),
), ),
], ],
+2 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:revoltvpn/logic/app_colors.dart';
class InfoButton extends StatelessWidget { class InfoButton extends StatelessWidget {
const InfoButton({super.key}); const InfoButton({super.key});
@@ -14,7 +15,7 @@ class InfoButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( 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, onPressed: _launchGitHub,
tooltip: 'About ReVoltVPN', tooltip: 'About ReVoltVPN',
splashRadius: 24, splashRadius: 24,
+5 -4
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/app_colors.dart';
/// OnlineDot displays a glowing green dot when the server is healthy, /// OnlineDot displays a glowing green dot when the server is healthy,
/// and a muted gray dot when disconnected or still syncing. /// and a muted gray dot when disconnected or still syncing.
@@ -30,11 +31,11 @@ class OnlineDot extends StatelessWidget {
height: 8, height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: isOnline ? const Color(0xFF00E676) : const Color(0xFF4A5568), color: isOnline ? AppColors.green : AppColors.slate,
boxShadow: isOnline boxShadow: isOnline
? const [ ? const [
BoxShadow( BoxShadow(
color: Color.fromRGBO(0, 230, 118, 0.5), color: AppColors.green50,
blurRadius: 6, blurRadius: 6,
), ),
] ]
@@ -48,8 +49,8 @@ class OnlineDot extends StatelessWidget {
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: isOnline color: isOnline
? const Color.fromRGBO(255, 255, 255, 0.8) ? AppColors.textWhite80
: const Color(0xFF4A5568), : AppColors.slate,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),
+2 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:revoltvpn/logic/app_colors.dart';
class PrivacyPolicyButton extends StatelessWidget { class PrivacyPolicyButton extends StatelessWidget {
const PrivacyPolicyButton({super.key}); const PrivacyPolicyButton({super.key});
@@ -14,7 +15,7 @@ class PrivacyPolicyButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( 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, onPressed: _launchPrivacyPolicy,
tooltip: 'Privacy Policy', tooltip: 'Privacy Policy',
splashRadius: 24, splashRadius: 24,
+9 -8
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/vpn_connection.dart'; import 'package:revoltvpn/logic/vpn_connection.dart';
/// StatusText displays the status of the VPN tunnel. /// StatusText displays the status of the VPN tunnel.
@@ -22,12 +23,12 @@ class StatusText extends StatelessWidget {
key: ValueKey(vpn.status), key: ValueKey(vpn.status),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0x22FFFFFF), // Subtle glass effect background color: AppColors.glassBg, // Subtle glass effect background
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all( border: Border.all(
color: isConnected color: isConnected
? const Color(0x8800E5FF) // Glowing cyan border when connected ? AppColors.cyanGlow // Glowing cyan border when connected
: const Color(0x33FFFFFF), // Muted translucent border when disconnected/connecting : AppColors.glassBorder, // Muted translucent border when disconnected/connecting
width: 1, width: 1,
), ),
), ),
@@ -45,7 +46,7 @@ class StatusText extends StatelessWidget {
const Text( const Text(
'Helsinki, Finland', 'Helsinki, Finland',
style: TextStyle( style: TextStyle(
color: Color(0xFF00E5FF), color: AppColors.cyan,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 13, fontSize: 13,
letterSpacing: 0.5, letterSpacing: 0.5,
@@ -58,22 +59,22 @@ class StatusText extends StatelessWidget {
height: 14, height: 14,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 1.5, strokeWidth: 1.5,
color: Color(0xFF00E5FF), color: AppColors.cyan,
), ),
) )
else else
const Icon( const Icon(
Icons.shield_outlined, Icons.shield_outlined,
size: 18, size: 18,
color: Color(0xAA4A5568), color: AppColors.slateAA,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
vpn.statusMessage.toUpperCase(), vpn.statusMessage.toUpperCase(),
style: TextStyle( style: TextStyle(
color: isConnecting color: isConnecting
? const Color(0xFF00E5FF) ? AppColors.cyan
: const Color(0xAA4A5568), : AppColors.slateAA,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 13, fontSize: 13,
letterSpacing: 1, letterSpacing: 1,
-86
View File
@@ -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<AdManager>().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<SessionTimer>().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,
),
),
],
),
),
),
);
}
}
+74
View File
@@ -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);
}
-1
View File
@@ -17,7 +17,6 @@ abstract final class AppConfig {
// ── VLESS ────────────────────────────────────────────────────────────── // ── VLESS ──────────────────────────────────────────────────────────────
static const int serverPort = 443; static const int serverPort = 443;
static const String vlessFlow = 'xtls-rprx-vision';
static const String vlessSecurity = 'tls'; static const String vlessSecurity = 'tls';
static const String vlessType = 'ws'; static const String vlessType = 'ws';
-2
View File
@@ -42,7 +42,6 @@ class HivemindService {
try { try {
final customData = jsonEncode({ final customData = jsonEncode({
'device_id': deviceId, 'device_id': deviceId,
'ad_type': 'main_ad',
'nonce': nonce, 'nonce': nonce,
}); });
final fakeAdmobPingUrl = Uri.parse( final fakeAdmobPingUrl = Uri.parse(
@@ -75,7 +74,6 @@ class HivemindService {
'?path=${Uri.encodeComponent(AppConfig.vlessPath)}' '?path=${Uri.encodeComponent(AppConfig.vlessPath)}'
'&security=${AppConfig.vlessSecurity}' '&security=${AppConfig.vlessSecurity}'
'&type=${AppConfig.vlessType}' '&type=${AppConfig.vlessType}'
'&flow=${AppConfig.vlessFlow}'
'#ReVoltVPN'; '#ReVoltVPN';
_expectedNonce = null; // consumed successfully _expectedNonce = null; // consumed successfully
-24
View File
@@ -22,7 +22,6 @@ class SessionTimer extends ChangeNotifier {
int _remainingSeconds = 0; int _remainingSeconds = 0;
int _quotaBytes = 0; int _quotaBytes = 0;
int _usedBytes = 0; int _usedBytes = 0;
bool _isThrottled = false;
// ── Sync / Network Health ────────────────────────────────────────────────── // ── Sync / Network Health ──────────────────────────────────────────────────
bool _hasSyncedOnce = false; bool _hasSyncedOnce = false;
@@ -65,7 +64,6 @@ class SessionTimer extends ChangeNotifier {
int get quotaBytes => _quotaBytes; int get quotaBytes => _quotaBytes;
int get usedBytes => _usedBytes; int get usedBytes => _usedBytes;
int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0; int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0;
bool get isThrottled => _isThrottled;
double get currentSpeedKBps => _currentSpeedKBps; double get currentSpeedKBps => _currentSpeedKBps;
String get formatted { String get formatted {
@@ -136,7 +134,6 @@ class SessionTimer extends ChangeNotifier {
_usedBytes = 0; _usedBytes = 0;
_lastUsedBytes = 0; _lastUsedBytes = 0;
_currentSpeedKBps = 0.0; _currentSpeedKBps = 0.0;
_isThrottled = false;
_tickCount = 0; _tickCount = 0;
_hasSyncedOnce = false; _hasSyncedOnce = false;
_consecutiveFailures = 0; _consecutiveFailures = 0;
@@ -251,7 +248,6 @@ class SessionTimer extends ChangeNotifier {
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; _remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
_quotaBytes = data['quota_bytes'] ?? _quotaBytes; _quotaBytes = data['quota_bytes'] ?? _quotaBytes;
_usedBytes = data['used_bytes'] ?? _usedBytes; _usedBytes = data['used_bytes'] ?? _usedBytes;
_isThrottled = data['is_throttled'] ?? false;
// ── Speed calculation over the polling interval ──────────────────── // ── Speed calculation over the polling interval ────────────────────
final int deltaBytes = _usedBytes - _lastUsedBytes; 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<bool> addBonusTime() async {
try {
await _syncWithHivemind();
return _hasSyncedOnce;
} catch (e) {
debugPrint('Bonus-time sync error: $e');
return false;
}
}
// ── Stop ─────────────────────────────────────────────────────────────────── // ── Stop ───────────────────────────────────────────────────────────────────
/// Called externally (e.g. from ConnectButton) to begin tearing down. /// Called externally (e.g. from ConnectButton) to begin tearing down.
+12
View File
@@ -77,12 +77,16 @@ class VpnConnection extends ChangeNotifier {
switch (status.connectionState) { switch (status.connectionState) {
case VlessConnectionState.connected: case VlessConnectionState.connected:
_setStatus(VpnStatus.connected, 'Secured'); _setStatus(VpnStatus.connected, 'Secured');
break;
case VlessConnectionState.disconnected: case VlessConnectionState.disconnected:
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
break;
case VlessConnectionState.connecting: case VlessConnectionState.connecting:
_setStatus(VpnStatus.connecting, 'Establishing tunnel…'); _setStatus(VpnStatus.connecting, 'Establishing tunnel…');
break;
case VlessConnectionState.disconnecting: case VlessConnectionState.disconnecting:
_setStatus(VpnStatus.disconnecting, 'Tearing down…'); _setStatus(VpnStatus.disconnecting, 'Tearing down…');
break;
case VlessConnectionState.unknown: case VlessConnectionState.unknown:
// Native backend emitted an unrecognized state — treat as error // Native backend emitted an unrecognized state — treat as error
// only if we aren't already in a terminal state. // only if we aren't already in a terminal state.
@@ -90,6 +94,7 @@ class VpnConnection extends ChangeNotifier {
_status != VpnStatus.disconnected) { _status != VpnStatus.disconnected) {
_setStatus(VpnStatus.error, 'Connection failed'); _setStatus(VpnStatus.error, 'Connection failed');
} }
break;
} }
} }
@@ -221,6 +226,13 @@ class VpnConnection extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
try {
_vless.stopVless();
} catch (_) {
// Best-effort — widget is being destroyed anyway
}
}
super.dispose(); super.dispose();
} }
} }
+7 -9
View File
@@ -7,6 +7,7 @@ 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/consent_manager.dart'; import 'package:revoltvpn/logic/consent_manager.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/screens/intro.dart'; import 'package:revoltvpn/screens/intro.dart';
/// The entry point for the REVOLT VPN application. /// 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. /// Constructs the global dark theme used across the application.
ThemeData _buildDarkTheme() { ThemeData _buildDarkTheme() {
const cyanAccent = Color(0xFF00E5FF);
const bgDark = Color(0xFF0D1117);
return ThemeData( return ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
scaffoldBackgroundColor: bgDark, scaffoldBackgroundColor: AppColors.bgDeep,
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
primary: cyanAccent, primary: AppColors.cyan,
secondary: cyanAccent, secondary: AppColors.cyan,
surface: Color(0xFF151C28), surface: AppColors.bgSurface,
), ),
fontFamily: 'Roboto', fontFamily: 'Roboto',
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
backgroundColor: const Color(0xFF1E2533), backgroundColor: AppColors.bgCard,
contentTextStyle: const TextStyle(color: Colors.white), contentTextStyle: const TextStyle(color: AppColors.textWhite),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
+5 -4
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/screens/main_screen.dart'; import 'package:revoltvpn/screens/main_screen.dart';
class IntroScreen extends StatefulWidget { class IntroScreen extends StatefulWidget {
@@ -58,7 +59,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF0D1117), backgroundColor: AppColors.bgDeep,
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -71,7 +72,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
return const Icon( return const Icon(
Icons.shield, Icons.shield,
size: 140, size: 140,
color: Color(0xFF00E5FF), color: AppColors.cyan,
); );
}, },
), ),
@@ -79,7 +80,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
const Text( const Text(
'REVOLT VPN', 'REVOLT VPN',
style: TextStyle( style: TextStyle(
color: Colors.white, color: AppColors.textWhite,
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 2.0, letterSpacing: 2.0,
@@ -90,7 +91,7 @@ class _IntroScreenState extends State<IntroScreen> with WidgetsBindingObserver {
width: 40, width: 40,
height: 40, height: 40,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Color(0xFF00E5FF), color: AppColors.cyan,
strokeWidth: 3.0, strokeWidth: 3.0,
), ),
), ),
+4 -6
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/components/header.dart'; import 'package:revoltvpn/components/header.dart';
import 'package:revoltvpn/components/status_text.dart'; import 'package:revoltvpn/components/status_text.dart';
import 'package:revoltvpn/components/connect_button.dart'; import 'package:revoltvpn/components/connect_button.dart';
@@ -18,23 +19,20 @@ import 'package:revoltvpn/components/gdpr_button.dart';
class MainScreen extends StatelessWidget { class MainScreen extends StatelessWidget {
const MainScreen({super.key}); const MainScreen({super.key});
// Background base color
static const Color _bgTop = Color(0xFF0D1117);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: _bgTop, backgroundColor: AppColors.bgDeep,
body: Container( body: Container(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: _bgTop, color: AppColors.bgDeep,
image: DecorationImage( image: DecorationImage(
image: AssetImage('assets/background.png'), image: AssetImage('assets/background.png'),
fit: BoxFit.cover, fit: BoxFit.cover,
colorFilter: ColorFilter.mode( 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, BlendMode.darken,
), ),
), ),
+152 -120
View File
@@ -6,13 +6,13 @@ Designed for Android-only clients.
=== TABLE OF CONTENTS =========================================================== === TABLE OF CONTENTS ===========================================================
LINE SECTION LINE SECTION
37 Configuration — ad rewards, Xray paths, throttle levels 37 Configuration — ad rewards, Xray paths
75 State & Cache — sessions dict, stats failure counter 75 State & Cache — sessions dict, stats failure counter
97 Flask Routes — /admob/callback, /session/status, /session/stop 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 270 AdMob SSV Verification — Google ECDSA signature checks
355 Management Loop — expiry + data-quota watchdog (60 s tick) 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 551 Entry Point — daemon thread + gunicorn bootstrap
================================================================================ ================================================================================
""" """
@@ -21,6 +21,8 @@ import time
import subprocess import subprocess
import threading import threading
import json import json
import os
import tempfile
import requests import requests
import shlex import shlex
import urllib.parse import urllib.parse
@@ -29,10 +31,45 @@ import base64
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes 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__) 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 # CONFIGURATION
# ============================================================================== # ==============================================================================
@@ -40,28 +77,71 @@ CHECK_INTERVAL_SECONDS = 60 # Checking every 1 minute is extremely safe and
# Main Ad Rewards # Main Ad Rewards
MAIN_AD_MINUTES = 60 MAIN_AD_MINUTES = 60
MAIN_AD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB MAIN_AD_BYTES = 10 * 1024 * 1024 * 1024 # 10 GB (generous — no mobile device hits this in 1 hr)
# Bonus Ad Rewards
BONUS_AD_MINUTES = 30
BONUS_AD_BYTES = 1 * 1024 * 1024 * 1024 # 1 GB
# VLESS server info — returned to app in /session/status # VLESS server info — returned to app in /session/status
VLESS_DOMAIN = "yourdomain.com" # TODO: set your real domain VLESS_DOMAIN = "yourdomain.com" # TODO: set your real domain
VLESS_PATH = "/tunnel" 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 paths
XRAY_CONFIG = "/usr/local/etc/xray/config.json" XRAY_CONFIG = "/usr/local/etc/xray/config.json"
XRAY_API = "http://127.0.0.1:10085" XRAY_API = "http://127.0.0.1:10085"
VLESS_INBOUND_TAG = "vless-in" 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 # AdMob SSV verification toggle
# True = dev mode, accept any callback (no signature check) # True = dev mode, accept any callback (no signature check)
# False = prod mode, enforce Google ECDSA signature verification # False = prod mode, enforce Google ECDSA signature verification
@@ -86,7 +166,6 @@ _xray_stats_failures = 0
# "vless_uuid": "550e8400-...", # "vless_uuid": "550e8400-...",
# "expires_at": 1716480000, # "expires_at": 1716480000,
# "quota_bytes": 2147483648, # "quota_bytes": 2147483648,
# "is_throttled": False,
# "nonce": "a7f3b2-1716480000000", # "nonce": "a7f3b2-1716480000000",
# } # }
# } # }
@@ -104,7 +183,7 @@ def admob_callback():
The Android client never touches this endpoint; Google calls it directly. The Android client never touches this endpoint; Google calls it directly.
custom_data (JSON, set by the app before showing the ad): custom_data (JSON, set by the app before showing the ad):
{ "device_id": "...", "ad_type": "main_ad"|"bonus_ad", "nonce": "..." } { "device_id": "...", "nonce": "..." }
""" """
try: try:
if ADMOB_BYPASS: if ADMOB_BYPASS:
@@ -130,16 +209,22 @@ def admob_callback():
print("[ADMOB] Rejected callback — invalid signature.") print("[ADMOB] Rejected callback — invalid signature.")
return "Forbidden", 403 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') device_id = custom_data.get('device_id')
if not device_id: if not device_id:
return "Invalid custom_data payload", 400 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)
nonce = custom_data.get('nonce', None) success, msg = _start_or_extend_session(device_id, nonce)
success, msg = _start_or_extend_session(device_id, ad_type, nonce)
return ("OK", 200) if success else (msg, 500) return ("OK", 200) if success else (msg, 500)
except Exception as e: except Exception as e:
@@ -156,12 +241,14 @@ def session_status():
device_id = request.args.get('device_id') device_id = request.args.get('device_id')
if not device_id: if not device_id:
return jsonify({"error": "Missing query parameter: device_id"}), 400 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: with sessions_lock:
session = active_sessions.get(device_id) session = active_sessions.get(device_id)
if not session: 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() stats = get_xray_stats()
used_bytes = stats.get(device_id, {}).get("total_bytes", 0) used_bytes = stats.get(device_id, {}).get("total_bytes", 0)
@@ -174,8 +261,6 @@ def session_status():
"server_domain": VLESS_DOMAIN, "server_domain": VLESS_DOMAIN,
"server_port": 443, "server_port": 443,
"vless_path": VLESS_PATH, "vless_path": VLESS_PATH,
"vless_flow": "xtls-rprx-vision",
"is_throttled": session["is_throttled"],
"expires_in_seconds": int(remaining_secs), "expires_in_seconds": int(remaining_secs),
"quota_bytes": session["quota_bytes"], "quota_bytes": session["quota_bytes"],
"used_bytes": used_bytes, "used_bytes": used_bytes,
@@ -189,7 +274,12 @@ def session_stop():
"""Allows the app to explicitly tear down its session on disconnect.""" """Allows the app to explicitly tear down its session on disconnect."""
data = request.get_json(silent=True) data = request.get_json(silent=True)
device_id = data.get('device_id') if data else None 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: if device_id:
with sessions_lock: with sessions_lock:
session = active_sessions.pop(device_id, None) session = active_sessions.pop(device_id, None)
@@ -203,8 +293,8 @@ def session_stop():
# SESSION LOGIC # SESSION LOGIC
# ============================================================================== # ==============================================================================
def _start_or_extend_session(device_id, ad_type, nonce=None): def _start_or_extend_session(device_id, nonce=None):
"""Core logic to provision or extend a session. """Provision a new VLESS session (or replace an existing one).
nonce -- client-generated random token; stored in session and echoed nonce -- client-generated random token; stored in session and echoed
back in /session/status so the client can reject stale sessions. 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() now = time.time()
# ── Phase 1: update in-memory state under lock ──────────────────── # ── 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: with sessions_lock:
if device_id in active_sessions and ad_type == 'bonus_ad': existing = active_sessions.get(device_id)
session = active_sessions[device_id] vless_uuid = str(uuid.uuid4())
session["expires_at"] += (BONUS_AD_MINUTES * 60)
session["quota_bytes"] += BONUS_AD_BYTES
if session["is_throttled"]: active_sessions[device_id] = {
session["is_throttled"] = False "vless_uuid": vless_uuid,
xray_action = ("set_level", 0) "expires_at": now + (MAIN_AD_MINUTES * 60),
"quota_bytes": MAIN_AD_BYTES,
print(f"[SESSION] Bonus time added for {device_id}.") "nonce": nonce,
# Fall through to Phase 2 outside the lock }
if existing:
xray_action = ("replace", vless_uuid)
else: else:
# Create a brand new session (Main Ad) xray_action = ("add", vless_uuid)
existing = active_sessions.get(device_id)
vless_uuid = str(uuid.uuid4())
active_sessions[device_id] = { print(f"[SESSION] Session started for {device_id} (UUID: {vless_uuid[:8]}...).")
"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]}...).")
# ── Phase 2: Xray file I/O + subprocess OUTSIDE the lock ────────── # ── 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] op, arg = xray_action[0], xray_action[1]
if op == "set_level": if op == "replace":
set_vless_client_level(device_id, arg)
return True, "Bonus time and data added."
elif op == "replace":
remove_vless_client(device_id) remove_vless_client(device_id)
add_vless_client(arg, device_id) add_vless_client(arg, device_id)
return True, "Main session active." return True, "Session active."
elif op == "add": elif op == "add":
add_vless_client(arg, device_id) 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 ──────────────── # ── Phase 1: decide what to do under the lock ────────────────
with sessions_lock: with sessions_lock:
to_remove = [] # (device_id,) — expired, remove from Xray to_remove = [] # (device_id,) — expired or over quota
to_throttle = [] # (device_id,) — over quota, set level 1
for device_id, session in list(active_sessions.items()): for device_id, session in list(active_sessions.items()):
@@ -376,13 +448,12 @@ def management_loop():
to_remove.append(device_id) to_remove.append(device_id)
continue 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) used_bytes = stats.get(device_id, {}).get("total_bytes", 0)
if used_bytes > session["quota_bytes"] and not session["is_throttled"]: if used_bytes > session["quota_bytes"]:
print(f"[HIVEMIND] Data cap reached for {device_id} ({used_bytes/1e9:.2f} GB used). Throttling to ~1.5 Mbps.") print(f"[HIVEMIND] Data cap reached for {device_id} ({used_bytes/1e9:.2f} GB used). Cutting off.")
session["is_throttled"] = True to_remove.append(device_id)
to_throttle.append(device_id)
for device_id in to_remove: for device_id in to_remove:
del active_sessions[device_id] del active_sessions[device_id]
@@ -391,9 +462,6 @@ def management_loop():
for device_id in to_remove: for device_id in to_remove:
remove_vless_client(device_id) remove_vless_client(device_id)
for device_id in to_throttle:
set_vless_client_level(device_id, THROTTLE_LEVEL)
except Exception as e: except Exception as e:
print(f"[HIVEMIND] Unhandled error: {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. """Add a client UUID to the Xray inbound config and hot-reload.
level -- Xray policy level for bandwidth limits: level -- Xray policy level for bandwidth limits:
0 = unlimited, 1 = throttled (~1.5 Mbps). 0 = default.
""" """
with open(XRAY_CONFIG, 'r') as f: with open(XRAY_CONFIG, 'r') as f:
config = json.load(f) config = json.load(f)
@@ -439,47 +507,13 @@ def add_vless_client(uuid, email, level=0):
inbound["settings"]["clients"].append({ inbound["settings"]["clients"].append({
"id": uuid, "id": uuid,
"email": email, "email": email,
"level": level, "level": level
"flow": "xtls-rprx-vision"
}) })
break break
with open(XRAY_CONFIG, 'w') as f: _atomic_write_json(XRAY_CONFIG, config)
json.dump(config, f, indent=2)
run_cmd("xray api adi --server=127.0.0.1:10085") run_cmd("xray api adi --server=127.0.0.1:10085")
print(f"[VLESS] Client added: {email[:16]}...") print(f"[VLESS] User 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}.")
def remove_vless_client(email): def remove_vless_client(email):
@@ -495,11 +529,9 @@ def remove_vless_client(email):
] ]
break break
with open(XRAY_CONFIG, 'w') as f: _atomic_write_json(XRAY_CONFIG, config)
json.dump(config, f, indent=2)
run_cmd("xray api adi --server=127.0.0.1:10085") 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(): def get_xray_stats():